Compare commits
33 Commits
1.0.0-beta
...
master
Author | SHA1 | Date | |
---|---|---|---|
904b11b7b7 | |||
43bfb6715c | |||
6dfbe2abda | |||
a06d5189de | |||
16575b6739 | |||
ac8745dbdc | |||
4ff19130b9 | |||
2720f5c041 | |||
c41ff54c89 | |||
c5ba4c69d2 | |||
d8f2cddb74 | |||
5883adc2c1 | |||
82327da031 | |||
7362222f16 | |||
e16202f8b2 | |||
bf5bdd1f72 | |||
298482f70e | |||
8f9ec73caf | |||
bdd46a530c | |||
b4b21561ed | |||
eb27e81d68 | |||
06e29eaba1 | |||
84c4a55073 | |||
8fdf6e6e7b | |||
674e7ac689 | |||
57d3a5056a | |||
3a9e4e1e3d | |||
69cd8e3ee1 | |||
fbf3c9b1bb | |||
7ee6990be1 | |||
d2ae4be194 | |||
e9b07374e5 | |||
fe926be0a6 |
@ -1,2 +1,3 @@
|
|||||||
*.opvault
|
*.opvault
|
||||||
lib
|
lib
|
||||||
|
dist
|
@ -6,6 +6,9 @@
|
|||||||
"node": true,
|
"node": true,
|
||||||
"browser": true
|
"browser": true
|
||||||
},
|
},
|
||||||
|
"parserOptions": {
|
||||||
|
"project": "./tsconfig.json"
|
||||||
|
},
|
||||||
"extends": [
|
"extends": [
|
||||||
"eslint:recommended",
|
"eslint:recommended",
|
||||||
"plugin:@typescript-eslint/recommended",
|
"plugin:@typescript-eslint/recommended",
|
||||||
@ -36,6 +39,7 @@
|
|||||||
"disallowTypeAnnotations": false
|
"disallowTypeAnnotations": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"@typescript-eslint/await-thenable": "error",
|
||||||
"@typescript-eslint/ban-types": [
|
"@typescript-eslint/ban-types": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
|
BIN
.github/screenshot.png
vendored
Normal file
BIN
.github/screenshot.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 380 KiB |
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,3 +1,4 @@
|
|||||||
|
drafts
|
||||||
node_modules
|
node_modules
|
||||||
mochawesome-report
|
mochawesome-report
|
||||||
lib
|
lib
|
||||||
@ -7,3 +8,4 @@ ref
|
|||||||
freddy
|
freddy
|
||||||
electron/bundled
|
electron/bundled
|
||||||
design.html
|
design.html
|
||||||
|
repl.ts
|
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
|
"cSpell.words": ["autolock"],
|
||||||
"cSpell.ignorePaths": [
|
"cSpell.ignorePaths": [
|
||||||
"**/package-lock.json",
|
"**/package-lock.json",
|
||||||
"**/node_modules/**",
|
"**/node_modules/**",
|
||||||
|
35
README.md
35
README.md
@ -1,14 +1,45 @@
|
|||||||
# opvault.js
|
# opvault.js
|
||||||
|
|
||||||
## Testing
|

|
||||||
|
|
||||||
|
## Lecteur de coffres OnePassword libre
|
||||||
|
|
||||||
|
Vos coffres OnePassword, sur n’importe quelle plateforme. Pour commencer, vous pouvez [télécharger une version compilée](../../../releases) pour votre système d’exploitation, ou [suivre les instructions de compilation](#build) ci-dessous.
|
||||||
|
|
||||||
|
## OnePassword Vault Reader
|
||||||
|
|
||||||
|
Read your OnePassword vaults on all platform. To start, you can [download a prebuilt binary](../../../releases) for your OS, or [follow the build instructions](#build) below.
|
||||||
|
|
||||||
|
## Capture d’écran / Screenshot
|
||||||
|
|
||||||
|
<img alt="linux screenshot" src=".github/screenshot.png" width="700" />
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
pnpm install
|
||||||
|
cd packages/web
|
||||||
|
pnpm run bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd packages/opvault.js/src/__tests__
|
||||||
wget -qO- https://cache.agilebits.com/security-kb/freddy-2013-12-04.tar.gz | tar xvz
|
wget -qO- https://cache.agilebits.com/security-kb/freddy-2013-12-04.tar.gz | tar xvz
|
||||||
mv onepassword_data freddy-2013-12-04.opvault
|
mv onepassword_data freddy-2013-12-04.opvault
|
||||||
|
|
||||||
|
# Run tests
|
||||||
pnpm run test
|
pnpm run test
|
||||||
```
|
```
|
||||||
|
|
||||||
## Security
|
## Sécurité / Security
|
||||||
|
|
||||||
|
### Signaler un problème de sécurité
|
||||||
|
|
||||||
|
Nous encourageons le signalement responsable des vulnerabilités détectées sur nos logiciels et ferons notre possible pour reconnaître vos contributions.
|
||||||
|
|
||||||
|
Afin de signaler un problème de sécurité, veuillez envoyer un courriel à [security@aet.ac](mailto:security@aet.ac) avec le mot « SÉCURITÉ » dans le subjet.
|
||||||
|
|
||||||
### Reporting Security Issues
|
### Reporting Security Issues
|
||||||
|
|
||||||
|
280
Untitled.ipynb
280
Untitled.ipynb
@ -1,280 +0,0 @@
|
|||||||
{
|
|
||||||
"cells": [
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 2,
|
|
||||||
"id": "aa8a7fc4",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"import fs from \"fs\";\n",
|
|
||||||
"import { resolve } from \"path\";"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 3,
|
|
||||||
"id": "d909c03d",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"import { OnePassword } from \"./src/index\";"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 9,
|
|
||||||
"id": "56b32a8e-af25-4356-8e13-ba8ca840f986",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"const instance = new OnePassword({ path: \"./freddy-2013-12-04.opvault\" });\n",
|
|
||||||
"const vault = await instance.getProfile(\"default\");\n",
|
|
||||||
"await vault.unlock(\"freddy\");\n",
|
|
||||||
"void 0;"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 8,
|
|
||||||
"id": "6c940edd-e225-4397-a92f-4fa8927854ab",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"text/plain": [
|
|
||||||
"[\n",
|
|
||||||
" { ps: 0 },\n",
|
|
||||||
" {\n",
|
|
||||||
" title: 'Personal',\n",
|
|
||||||
" ainfo: 'Wendy Appleseed',\n",
|
|
||||||
" tags: [ 'Sample', 'Personal' ],\n",
|
|
||||||
" ps: 0\n",
|
|
||||||
" },\n",
|
|
||||||
" {\n",
|
|
||||||
" title: 'Hulu',\n",
|
|
||||||
" URLs: [ [Object] ],\n",
|
|
||||||
" ainfo: 'wendy@appleseed.com',\n",
|
|
||||||
" url: 'http://www.hulu.com/',\n",
|
|
||||||
" tags: [ 'Sample' ],\n",
|
|
||||||
" ps: 66\n",
|
|
||||||
" },\n",
|
|
||||||
" {\n",
|
|
||||||
" title: \"Wendy's driver's license\",\n",
|
|
||||||
" ps: 0,\n",
|
|
||||||
" ainfo: 'D6101-40706-60905'\n",
|
|
||||||
" },\n",
|
|
||||||
" { title: 'Orders', ainfo: '10.0.1.50', tags: [ 'Sample' ], ps: 0 },\n",
|
|
||||||
" {\n",
|
|
||||||
" URLs: [ [Object] ],\n",
|
|
||||||
" tags: [ 'Sample' ],\n",
|
|
||||||
" title: 'Skype',\n",
|
|
||||||
" url: 'https://secure.skype.com/account/login?message=login_required',\n",
|
|
||||||
" ainfo: 'WendyAppleseed',\n",
|
|
||||||
" ps: 78\n",
|
|
||||||
" },\n",
|
|
||||||
" {\n",
|
|
||||||
" title: 'YouTube',\n",
|
|
||||||
" URLs: [ [Object] ],\n",
|
|
||||||
" ainfo: 'wendy@appleseed.com',\n",
|
|
||||||
" url: 'http://www.youtube.com/login?next=/index',\n",
|
|
||||||
" tags: [ 'Sample' ],\n",
|
|
||||||
" ps: 78\n",
|
|
||||||
" },\n",
|
|
||||||
" { title: 'example.com', ps: 0, ainfo: 'wappleseed' },\n",
|
|
||||||
" {\n",
|
|
||||||
" title: 'Dropbox',\n",
|
|
||||||
" URLs: [ [Object] ],\n",
|
|
||||||
" ainfo: 'wendy@appleseed.com',\n",
|
|
||||||
" url: 'https://www.getdropbox.com/',\n",
|
|
||||||
" tags: [ 'Sample' ],\n",
|
|
||||||
" ps: 78\n",
|
|
||||||
" },\n",
|
|
||||||
" {\n",
|
|
||||||
" title: \"Company's FTP\",\n",
|
|
||||||
" URLs: [ [Object] ],\n",
|
|
||||||
" ainfo: 'admin',\n",
|
|
||||||
" url: 'ftp://ftp.dreamhost.com',\n",
|
|
||||||
" tags: [ 'Sample' ],\n",
|
|
||||||
" ps: 60\n",
|
|
||||||
" },\n",
|
|
||||||
" {\n",
|
|
||||||
" title: 'Tumblr',\n",
|
|
||||||
" URLs: [ [Object] ],\n",
|
|
||||||
" ainfo: 'wendy@appleseed.com',\n",
|
|
||||||
" url: 'http://www.tumblr.com/login',\n",
|
|
||||||
" tags: [ 'Sample' ],\n",
|
|
||||||
" ps: 48\n",
|
|
||||||
" },\n",
|
|
||||||
" { title: 'Social Security', ps: 0, ainfo: 'Wendy Appleseed' },\n",
|
|
||||||
" {\n",
|
|
||||||
" title: 'Last.fm',\n",
|
|
||||||
" URLs: [ [Object] ],\n",
|
|
||||||
" ainfo: 'WendyAppleseed',\n",
|
|
||||||
" url: 'https://www.last.fm/login',\n",
|
|
||||||
" tags: [ 'Sample' ],\n",
|
|
||||||
" ps: 72\n",
|
|
||||||
" },\n",
|
|
||||||
" { title: 'Tim Hortons', ps: 0, ainfo: 'Tim Hortens' },\n",
|
|
||||||
" { title: 'Snipe Hunting License', ps: 0, ainfo: 'Wendy Appleseed' },\n",
|
|
||||||
" {\n",
|
|
||||||
" title: 'A note to Trash',\n",
|
|
||||||
" ainfo: 'Let’s create a note that we will throw in the trash but not expunge.',\n",
|
|
||||||
" ps: 0\n",
|
|
||||||
" },\n",
|
|
||||||
" {\n",
|
|
||||||
" title: 'CapitalOne MasterCard ***3456',\n",
|
|
||||||
" ainfo: '1234 *********** 3456',\n",
|
|
||||||
" tags: [ 'Sample' ],\n",
|
|
||||||
" ps: 0\n",
|
|
||||||
" },\n",
|
|
||||||
" {\n",
|
|
||||||
" title: 'What is a Secure Note?',\n",
|
|
||||||
" ainfo: '',\n",
|
|
||||||
" tags: [ 'Sample' ],\n",
|
|
||||||
" ps: 0\n",
|
|
||||||
" },\n",
|
|
||||||
" {\n",
|
|
||||||
" title: 'The Unofficial Apple Weblog',\n",
|
|
||||||
" URLs: [ [Object] ],\n",
|
|
||||||
" ainfo: 'WendyAppleseed',\n",
|
|
||||||
" url: 'http://www.tuaw.com',\n",
|
|
||||||
" tags: [ 'Sample' ],\n",
|
|
||||||
" ps: 78\n",
|
|
||||||
" },\n",
|
|
||||||
" { title: \"Wendy's passport\", ps: 0, ainfo: 'ZZ200000' },\n",
|
|
||||||
" {\n",
|
|
||||||
" title: 'Chase VISA ***4356',\n",
|
|
||||||
" ainfo: '1234 *********** 4356',\n",
|
|
||||||
" tags: [ 'Sample' ],\n",
|
|
||||||
" ps: 0\n",
|
|
||||||
" },\n",
|
|
||||||
" {\n",
|
|
||||||
" title: 'Bank of America',\n",
|
|
||||||
" URLs: [ [Object] ],\n",
|
|
||||||
" ainfo: 'WendyAppleseed',\n",
|
|
||||||
" url: 'https://www.bankofamerica.com/',\n",
|
|
||||||
" tags: [ 'Sample', 'Personal' ],\n",
|
|
||||||
" ps: 66\n",
|
|
||||||
" },\n",
|
|
||||||
" {\n",
|
|
||||||
" title: 'A note with some attachments',\n",
|
|
||||||
" ps: 0,\n",
|
|
||||||
" ainfo: 'This note has two attachments.'\n",
|
|
||||||
" },\n",
|
|
||||||
" { title: '1Password', ainfo: '3.0', tags: [ 'Sample' ], ps: 0 },\n",
|
|
||||||
" { title: 'TextExpander', ainfo: '1.3', tags: [ 'Sample' ], ps: 0 },\n",
|
|
||||||
" {\n",
|
|
||||||
" title: 'Business',\n",
|
|
||||||
" ainfo: 'Wendy Appleseed',\n",
|
|
||||||
" tags: [ 'Business', 'Sample' ],\n",
|
|
||||||
" ps: 0\n",
|
|
||||||
" },\n",
|
|
||||||
" {\n",
|
|
||||||
" title: 'MobileMe',\n",
|
|
||||||
" URLs: [ [Object] ],\n",
|
|
||||||
" ainfo: 'wendy.appleseed@me.com',\n",
|
|
||||||
" url: 'https://www.icloud.com/',\n",
|
|
||||||
" tags: [ 'Sample' ],\n",
|
|
||||||
" ps: 66\n",
|
|
||||||
" },\n",
|
|
||||||
" { title: 'Email Account', ps: 0, ainfo: 'wendy.appleseed@me.com' },\n",
|
|
||||||
" {\n",
|
|
||||||
" title: 'Johnny Appleseed Society',\n",
|
|
||||||
" ps: 0,\n",
|
|
||||||
" ainfo: 'Wendy Appleseed'\n",
|
|
||||||
" }\n",
|
|
||||||
"]"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execution_count": 8,
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "execute_result"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"vault.overviews.values()"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 6,
|
|
||||||
"id": "1547939f-3ee1-49df-9401-c54b8511acb4",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"text/plain": [
|
|
||||||
"[Object: null prototype] {\n",
|
|
||||||
" async: [Function (anonymous)],\n",
|
|
||||||
" done: [Function (anonymous)],\n",
|
|
||||||
" sendResult: [Function (anonymous)],\n",
|
|
||||||
" sendError: [Function (anonymous)],\n",
|
|
||||||
" mime: [Function (anonymous)],\n",
|
|
||||||
" text: [Function (anonymous)],\n",
|
|
||||||
" html: [Function (anonymous)],\n",
|
|
||||||
" svg: [Function (anonymous)],\n",
|
|
||||||
" png: [Function (anonymous)],\n",
|
|
||||||
" jpeg: [Function (anonymous)],\n",
|
|
||||||
" json: [Function (anonymous)],\n",
|
|
||||||
" input: [Function (anonymous)],\n",
|
|
||||||
" display: [Function (anonymous)],\n",
|
|
||||||
" clear: [Function (anonymous)]\n",
|
|
||||||
"}"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execution_count": 6,
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "execute_result"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"$$"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 6,
|
|
||||||
"id": "3625d249-b27e-4990-b433-94238a60f3db",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"text/html": [
|
|
||||||
"<a href=\"#\">meow</a>"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execution_count": 6,
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "execute_result"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"$$.jsx(<a href=\"#\">meow</a>)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"id": "9899de99-b1a9-4f91-9d48-a63808baedc1",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"metadata": {
|
|
||||||
"kernelspec": {
|
|
||||||
"display_name": "jp-Babel (Node.js)",
|
|
||||||
"language": "babel",
|
|
||||||
"name": "babel"
|
|
||||||
},
|
|
||||||
"language_info": {
|
|
||||||
"file_extension": ".js",
|
|
||||||
"mimetype": "application/javascript",
|
|
||||||
"name": "javascript",
|
|
||||||
"version": "16.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nbformat": 4,
|
|
||||||
"nbformat_minor": 5
|
|
||||||
}
|
|
@ -1,5 +1,12 @@
|
|||||||
# OPVault design
|
# OPVault design
|
||||||
|
|
||||||
|
<!--
|
||||||
|
(C) AgileBits Inc.
|
||||||
|
Canonical URL: https://support.1password.com/cs/opvault-design/
|
||||||
|
|
||||||
|
This document is reformatted into Markdown for implementation reference purposes.
|
||||||
|
-->
|
||||||
|
|
||||||
Learn about the design of the OPVault format, used by default when syncing with iCloud or Dropbox.
|
Learn about the design of the OPVault format, used by default when syncing with iCloud or Dropbox.
|
||||||
|
|
||||||
> ### Tip
|
> ### Tip
|
||||||
@ -11,7 +18,7 @@ Learn about the design of the OPVault format, used by default when syncing with
|
|||||||
|
|
||||||
The OPVault format was introduced in December 2012 and shortly thereafter became the default format for syncing with iCloud and Dropbox.
|
The OPVault format was introduced in December 2012 and shortly thereafter became the default format for syncing with iCloud and Dropbox.
|
||||||
|
|
||||||
The [Agile Keychain](/cs/agile-keychain-design/) format was introduced in 2008 as a successor to using macOS Keychain integration. It proved to be much more reliable for syncing and gave us flexibility in design, efficient and reliable syncing, and portability across a variety of platforms. We designed it not only to withstand threats from 2008 but future threats as well.
|
The [Agile Keychain](https://support.1password.com/cs/agile-keychain-design/) format was introduced in 2008 as a successor to using macOS Keychain integration. It proved to be much more reliable for syncing and gave us flexibility in design, efficient and reliable syncing, and portability across a variety of platforms. We designed it not only to withstand threats from 2008 but future threats as well.
|
||||||
|
|
||||||
Changes in available technology allowed us to improve on that design. And we again designed against threats that may not exist today, but which may develop in the coming years.
|
Changes in available technology allowed us to improve on that design. And we again designed against threats that may not exist today, but which may develop in the coming years.
|
||||||
|
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
const replaceText = (selector, text) => {
|
|
||||||
const element = document.getElementById(selector)
|
|
||||||
if (element) element.innerText = text
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const type of ["chrome", "node", "electron"]) {
|
|
||||||
replaceText(`${type}-version`, process.versions[type])
|
|
||||||
}
|
|
@ -1,22 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
|
||||||
<meta
|
|
||||||
http-equiv="Content-Security-Policy"
|
|
||||||
content="default-src 'self'; script-src 'self'"
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
http-equiv="X-Content-Security-Policy"
|
|
||||||
content="default-src 'self'; script-src 'self'"
|
|
||||||
/>
|
|
||||||
<title>Hello World!</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Hello World!</h1>
|
|
||||||
We are using Node.js <span id="node-version"></span>, Chromium
|
|
||||||
<span id="chrome-version"></span>, and Electron <span id="electron-version"></span>.
|
|
||||||
<script src="bundled/index.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
44
esbuild.js
44
esbuild.js
@ -1,44 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// @ts-check
|
|
||||||
const { build } = require("esbuild")
|
|
||||||
const sassPlugin = require("esbuild-plugin-sass")
|
|
||||||
const { nodeBuiltIns } = require("esbuild-node-builtins")
|
|
||||||
|
|
||||||
const args = process.argv.slice(2)
|
|
||||||
|
|
||||||
build({
|
|
||||||
bundle: true,
|
|
||||||
define: {
|
|
||||||
"process.browser": "true",
|
|
||||||
"process.env.BLUEPRINT_NAMESPACE": '"bp4"',
|
|
||||||
global: "globalThis",
|
|
||||||
},
|
|
||||||
entryPoints: ["electron/app/index.tsx"],
|
|
||||||
inject: ["./scripts/react-shim.js"],
|
|
||||||
outdir: "electron/bundled",
|
|
||||||
external: ["path", "glob", "fs", "util"],
|
|
||||||
jsxFactory: "esbuildCreateElement",
|
|
||||||
jsxFragment: "esbuildFragment",
|
|
||||||
plugins: [
|
|
||||||
sassPlugin(),
|
|
||||||
nodeBuiltIns({
|
|
||||||
include: ["path", "fs"],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
target: ["chrome90"],
|
|
||||||
tsconfig: "./tsconfig.json",
|
|
||||||
sourcemap: "inline",
|
|
||||||
minify: process.env.NODE_ENV === "production",
|
|
||||||
banner: {
|
|
||||||
js: "/* eslint-disable */",
|
|
||||||
},
|
|
||||||
loader: {
|
|
||||||
".png": "file",
|
|
||||||
".eot": "file",
|
|
||||||
".svg": "file",
|
|
||||||
".woff": "file",
|
|
||||||
".woff2": "file",
|
|
||||||
".ttf": "file",
|
|
||||||
},
|
|
||||||
watch: args.includes("-w") || args.includes("--watch"),
|
|
||||||
})
|
|
85
package.json
85
package.json
@ -5,60 +5,48 @@
|
|||||||
"repository": "https://git.aet.ac/aet/opvault.js.git",
|
"repository": "https://git.aet.ac/aet/opvault.js.git",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rollup -c; cp src/adapters/index.d.ts lib/adapters/; prettier --write lib >/dev/null",
|
|
||||||
"build:docs": "typedoc --out docs src/index.ts --excludePrivate",
|
|
||||||
"design": "marked -o design.html < design.md",
|
"design": "marked -o design.html < design.md",
|
||||||
"test": "node --expose-gc node_modules/mocha/bin/_mocha test/**/*.test.ts",
|
"test": "rm -rf mochawesome-report; c8 -r html node --expose-gc node_modules/mocha/bin/_mocha packages/**/*.test.ts; mv coverage mochawesome-report/coverage",
|
||||||
"repl": "node -r ts-node/register/transpile-only src/repl.ts",
|
"repl": "node -r ts-node/register/transpile-only src/repl.ts",
|
||||||
"start": "electron ./electron/index.js"
|
"dev": "cd packages/web && yarn dev",
|
||||||
|
"bundle": "cd packages/web && yarn bundle",
|
||||||
|
"i18n": "node packages/web/scripts/build-i18n-yml-typedef.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-json": "^4.1.0",
|
"@types/chai": "^4.3.4",
|
||||||
"@rollup/plugin-replace": "^3.0.0",
|
"@types/chai-as-promised": "^7.1.5",
|
||||||
"@types/chai": "^4.2.22",
|
"@types/fs-extra": "^11.0.1",
|
||||||
"@types/chai-as-promised": "^7.1.4",
|
|
||||||
"@types/fs-extra": "^9.0.13",
|
|
||||||
"@types/mocha": "github:whitecolor/mocha-types#da22474cf43f48a56c86f8c23a5a0ea36e295768",
|
"@types/mocha": "github:whitecolor/mocha-types#da22474cf43f48a56c86f8c23a5a0ea36e295768",
|
||||||
"@types/node": "^16.10.3",
|
"@types/node": "^18.16.2",
|
||||||
"@types/prompts": "^2.0.14",
|
"@types/sinon": "^10.0.14",
|
||||||
"@types/react": "^17.0.30",
|
"@types/sinon-chai": "^3.2.9",
|
||||||
"@types/react-dom": "^17.0.9",
|
"@types/wicg-file-system-access": "^2020.9.5",
|
||||||
"@types/sinon": "^10.0.4",
|
"@typescript-eslint/eslint-plugin": "5.59.1",
|
||||||
"@types/sinon-chai": "^3.2.5",
|
"@typescript-eslint/parser": "5.59.1",
|
||||||
"@types/wicg-file-system-access": "^2020.9.4",
|
"c8": "^7.13.0",
|
||||||
"@typescript-eslint/eslint-plugin": "4.33.0",
|
"chai": "^4.3.7",
|
||||||
"@typescript-eslint/parser": "4.33.0",
|
|
||||||
"chai": "^4.3.4",
|
|
||||||
"chai-as-promised": "^7.1.1",
|
"chai-as-promised": "^7.1.1",
|
||||||
"chalk": "^4.1.2",
|
"chalk": "^4.1.2",
|
||||||
"electron": "^15.2.0",
|
"eslint": "8.39.0",
|
||||||
"esbuild": "^0.13.6",
|
"eslint-config-prettier": "8.8.0",
|
||||||
"esbuild-node-builtins": "^0.1.0",
|
"eslint-import-resolver-typescript": "3.5.5",
|
||||||
"esbuild-plugin-sass": "^0.6.0",
|
"eslint-plugin-import": "2.27.5",
|
||||||
"eslint": "7.32.0",
|
"eslint-plugin-react": "7.32.2",
|
||||||
"eslint-config-prettier": "8.3.0",
|
"eslint-plugin-react-hooks": "4.6.0",
|
||||||
"eslint-import-resolver-typescript": "2.5.0",
|
"fs-extra": "^11.1.1",
|
||||||
"eslint-plugin-import": "2.24.2",
|
"marked": "^4.3.0",
|
||||||
"eslint-plugin-react": "7.26.1",
|
"mocha": "^10.2.0",
|
||||||
"eslint-plugin-react-hooks": "4.2.0",
|
"mochawesome": "^7.1.3",
|
||||||
"fs-extra": "^10.0.0",
|
"prettier": "^2.8.8",
|
||||||
"marked": "^3.0.8",
|
"react": "^18.2.0",
|
||||||
"memfs": "^3.3.0",
|
"react-dom": "^18.2.0",
|
||||||
"mocha": "^9.1.2",
|
"sass": "^1.62.1",
|
||||||
"mochawesome": "^6.3.0",
|
"sinon": "^15.0.4",
|
||||||
"prettier": "^2.4.1",
|
|
||||||
"prompts": "^2.4.1",
|
|
||||||
"react": "^17.0.2",
|
|
||||||
"react-dom": "^17.0.2",
|
|
||||||
"rollup": "^2.58.0",
|
|
||||||
"rollup-plugin-ts": "^1.4.7",
|
|
||||||
"sass": "^1.43.2",
|
|
||||||
"sinon": "^11.1.2",
|
|
||||||
"sinon-chai": "^3.7.0",
|
"sinon-chai": "^3.7.0",
|
||||||
"ts-node": "^10.2.1",
|
"ts-node": "^10.9.1",
|
||||||
"tsconfig-paths": "^3.11.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typedoc": "^0.22.5",
|
"tslib": "^2.5.0",
|
||||||
"typescript": "^4.4.3"
|
"typescript": "^5.0.4"
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"arrowParens": "avoid",
|
"arrowParens": "avoid",
|
||||||
@ -67,10 +55,5 @@
|
|||||||
"semi": false,
|
"semi": false,
|
||||||
"singleQuote": false,
|
"singleQuote": false,
|
||||||
"trailingComma": "es5"
|
"trailingComma": "es5"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"buffer": "^6.0.3",
|
|
||||||
"tiny-invariant": "1.1.0",
|
|
||||||
"tslib": "2.3.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
3
packages/opvault.js/README.md
Normal file
3
packages/opvault.js/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# opvault.js
|
||||||
|
|
||||||
|
OnePassword local vaults parser library.
|
9
packages/opvault.js/assets/doc-theme.css
Normal file
9
packages/opvault.js/assets/doc-theme.css
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
||||||
|
Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||||
|
}
|
||||||
|
code,
|
||||||
|
pre,
|
||||||
|
.tsd-signature {
|
||||||
|
font-family: D2Coding, Consolas, Menlo, Monaco, "Roboto Mono", monospace;
|
||||||
|
}
|
31
packages/opvault.js/package.json
Normal file
31
packages/opvault.js/package.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "opvault.js",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"scripts": {
|
||||||
|
"build": "rollup -c --bundleConfigAsCjs; prettier --write lib >/dev/null",
|
||||||
|
"build:docs": "typedoc --out docs src/index.ts --excludePrivate"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./lib/index.js",
|
||||||
|
"./node": "./lib/node.js",
|
||||||
|
"./filePicker": "./lib/filePicker.js",
|
||||||
|
"./webkit": "./lib/webkit.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"lib"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"buffer": "^6.0.3",
|
||||||
|
"tiny-invariant": "1.3.1",
|
||||||
|
"tslib": "2.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@rollup/plugin-json": "^6.0.0",
|
||||||
|
"@rollup/plugin-replace": "^5.0.2",
|
||||||
|
"prettier": "^2.8.8",
|
||||||
|
"rollup": "^3.21.0",
|
||||||
|
"rollup-plugin-ts": "^3.2.0",
|
||||||
|
"typedoc": "^0.24.6"
|
||||||
|
}
|
||||||
|
}
|
@ -8,7 +8,9 @@ import { dependencies } from "./package.json"
|
|||||||
export default () => ({
|
export default () => ({
|
||||||
input: {
|
input: {
|
||||||
index: "./src/index.ts",
|
index: "./src/index.ts",
|
||||||
"adapters/node": "./src/adapters/node.ts",
|
node: "./src/adapter/node.ts",
|
||||||
|
filePicker: "./src/adapter/showDirectoryPicker.ts",
|
||||||
|
webkit: "./src/adapter/webkitdirectory.ts",
|
||||||
},
|
},
|
||||||
external: builtinModules.concat(Object.keys(dependencies)),
|
external: builtinModules.concat(Object.keys(dependencies)),
|
||||||
output: {
|
output: {
|
1
packages/opvault.js/src/__tests__/decrypted.json
Normal file
1
packages/opvault.js/src/__tests__/decrypted.json
Normal file
File diff suppressed because one or more lines are too long
99
packages/opvault.js/src/__tests__/index.test.ts
Normal file
99
packages/opvault.js/src/__tests__/index.test.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { resolve } from "path"
|
||||||
|
import { describe, it, beforeEach } from "mocha"
|
||||||
|
import { expect } from "chai"
|
||||||
|
|
||||||
|
import type { Vault } from "../index"
|
||||||
|
import { base64FromByteArray } from "../buffer"
|
||||||
|
import { OnePassword } from "../index"
|
||||||
|
import { adapter } from "../adapter/node"
|
||||||
|
|
||||||
|
describe("OnePassword", () => {
|
||||||
|
const freddy = resolve(__dirname, "freddy-2013-12-04.opvault")
|
||||||
|
|
||||||
|
describe("getProfileNames", () => {
|
||||||
|
it("freddy", async () => {
|
||||||
|
const instance = new OnePassword({ path: freddy, adapter })
|
||||||
|
expect(await instance.getProfileNames()).to.deep.equal(["default"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skip("ignores faulty folders", async () => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("unlock", () => {
|
||||||
|
let vault: Vault
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vault = await new OnePassword({ path: freddy, adapter }).getProfile("default")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("accepts correct password", async () => {
|
||||||
|
await expect(vault.unlock("freddy")).to.be.fulfilled
|
||||||
|
expect(vault.isLocked).to.be.false
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects wrong password", () => {
|
||||||
|
;["Freddy", "_freddy", ""].forEach(async password => {
|
||||||
|
await expect(vault.unlock(password)).to.be.rejectedWith("Invalid password")
|
||||||
|
expect(vault.isLocked).to.be.true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("content", () => {
|
||||||
|
let vault: Vault
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vault = await new OnePassword({ path: freddy, adapter }).getProfile("default")
|
||||||
|
await vault.unlock("freddy")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("reads notes", async () => {
|
||||||
|
const item = (await vault.getItem({
|
||||||
|
title: "A note with some attachments",
|
||||||
|
}))!
|
||||||
|
expect(item).to.exist
|
||||||
|
expect(item.uuid).to.equal("F2DB5DA3FCA64372A751E0E85C67A538")
|
||||||
|
expect(item.attachments).to.have.lengthOf(2)
|
||||||
|
expect(item.details).to.deep.equal({
|
||||||
|
notesPlain: "This note has two attachments.",
|
||||||
|
})
|
||||||
|
expect(item.overview).to.deep.equal({
|
||||||
|
title: "A note with some attachments",
|
||||||
|
ps: 0,
|
||||||
|
ainfo: "This note has two attachments.",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("decrypts items", async () => {
|
||||||
|
const decrypted = require("./decrypted.json")
|
||||||
|
expect(vault.isLocked).to.be.false
|
||||||
|
for (const [uuid, item] of Object.entries<any>(decrypted)) {
|
||||||
|
const actual = await vault.getItem(uuid)
|
||||||
|
expect(actual).to.exist
|
||||||
|
expect(actual!.overview).to.deep.equal(item.overview)
|
||||||
|
expect(actual!.details).to.deep.equal(item.itemDetails)
|
||||||
|
expect(actual!.attachments).to.have.lengthOf(item.attachments.length)
|
||||||
|
for (const [i, attachment] of actual!.attachments.entries()) {
|
||||||
|
const expected = item.attachments[i]
|
||||||
|
await attachment.unlock()
|
||||||
|
expect(attachment.metadata).to.deep.equal(expected.metadata)
|
||||||
|
expect(base64FromByteArray(attachment.file)).to.deep.equal(expected.file)
|
||||||
|
expect(base64FromByteArray(attachment.icon)).to.deep.equal(expected.icon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("lock", () => {
|
||||||
|
it("locks", async () => {
|
||||||
|
const instance = new OnePassword({ path: freddy, adapter })
|
||||||
|
const vault = await instance.getProfile("default")
|
||||||
|
await vault.unlock("freddy")
|
||||||
|
expect(vault.isLocked).to.be.false
|
||||||
|
|
||||||
|
vault.lock()
|
||||||
|
expect(vault.isLocked).to.be.true
|
||||||
|
expect(vault.getItem("F2DB5DA3FCA64372A751E0E85C67A538")).to.eventually.throw
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
43
packages/opvault.js/src/__tests__/weakMap.test.ts
Normal file
43
packages/opvault.js/src/__tests__/weakMap.test.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it } from "mocha"
|
||||||
|
import { expect } from "chai"
|
||||||
|
|
||||||
|
import { WeakValueMap } from "../weakMap"
|
||||||
|
|
||||||
|
declare const gc: () => void
|
||||||
|
|
||||||
|
describe("WeakValueMap", () => {
|
||||||
|
interface Value {
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
it("covers base use cases", () => {
|
||||||
|
const map = new WeakValueMap<string, Value>()
|
||||||
|
const object = { value: 1 }
|
||||||
|
map.set("key", object)
|
||||||
|
expect(map.get("key")!.value).to.equal(1)
|
||||||
|
expect(map.delete("key")).to.be.true
|
||||||
|
expect(!map.delete("key")).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it("overrides previous value", () => {
|
||||||
|
const map = new WeakValueMap<string, Value>()
|
||||||
|
map.set("key", { value: 2 })
|
||||||
|
map.set("key", { value: 3 })
|
||||||
|
expect(map.get("key")!.value).to.equal(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("deletes garbage collected values", done => {
|
||||||
|
const map = new WeakValueMap<string, Value>()
|
||||||
|
map.set("key", { value: 1 })
|
||||||
|
setTimeout(() => {
|
||||||
|
gc()
|
||||||
|
expect(map.has("key")).to.be.false
|
||||||
|
map.set("key", { value: 2 })
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
gc()
|
||||||
|
done()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* An object that implements basic file system functionalities.
|
* An object that implements basic file system functionalities.
|
||||||
*/
|
*/
|
||||||
export interface IFileSystem {
|
export interface FileSystem {
|
||||||
/**
|
/**
|
||||||
* Asynchronously tests whether or not the given path exists by checking with the file system.
|
* Asynchronously tests whether or not the given path exists by checking with the file system.
|
||||||
* @param path A path to a file or directory.
|
* @param path A path to a file or directory.
|
||||||
@ -12,39 +12,45 @@ export interface IFileSystem {
|
|||||||
* Asynchronously reads the entire contents of a file.
|
* Asynchronously reads the entire contents of a file.
|
||||||
* @param path A path to a file.
|
* @param path A path to a file.
|
||||||
*/
|
*/
|
||||||
readBuffer(path: string): Promise<Buffer>
|
readFile(path: string): Promise<Uint8Array>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asynchronously reads the entire contents of a file.
|
* Asynchronously reads the entire contents of a file.
|
||||||
* @param path A path to a file.
|
* @param path A path to a file.
|
||||||
*/
|
*/
|
||||||
readFile(path: string): Promise<string>
|
readTextFile(path: string): Promise<string>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asynchronously writes data to a file, replacing the file if it already exists.
|
* Asynchronously writes data to a file, replacing the file if it already exists.
|
||||||
* @param path A path to a file.
|
* @param path A path to a file.
|
||||||
* @param data The data to write.
|
* @param data The data to write.
|
||||||
*/
|
*/
|
||||||
writeFile(path: string, data: string): Promise<void>
|
writeTextFile(path: string, data: string): Promise<void>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asynchronous readdir(3) - read a directory.
|
* Reads the directory given by path and returns an async iterable of `DirEntry`.
|
||||||
* @param path A path to a directory.
|
|
||||||
*/
|
*/
|
||||||
readdir(path: string): Promise<string[]>
|
readDir(path: string): AsyncIterable<DirEntry>
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the path points to a directory.
|
|
||||||
*/
|
|
||||||
isDirectory(path: string): Promise<boolean>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAdapter {
|
/**
|
||||||
|
* Information about a directory entry
|
||||||
|
*/
|
||||||
|
export interface DirEntry {
|
||||||
|
/** The file name of the entry. Does not include the full path. */
|
||||||
|
name: string
|
||||||
|
/** True if this is info for a regular file. */
|
||||||
|
isFile: boolean
|
||||||
|
/** True if this is info for a directory. */
|
||||||
|
isDirectory: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Adapter {
|
||||||
/**
|
/**
|
||||||
* Underlying `fs` module. You can replace it with a wrapper of
|
* Underlying `fs` module. You can replace it with a wrapper of
|
||||||
* `memfs` or any object that implements `IFileSystem`.
|
* `memfs` or any object that implements `IFileSystem`.
|
||||||
*/
|
*/
|
||||||
fs: IFileSystem
|
fs: FileSystem
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `SubtleCrypto` implementation. On Node.js this is
|
* `SubtleCrypto` implementation. On Node.js this is
|
||||||
@ -52,9 +58,4 @@ export interface IAdapter {
|
|||||||
* `window.crypto.subtle`.
|
* `window.crypto.subtle`.
|
||||||
*/
|
*/
|
||||||
subtle: SubtleCrypto
|
subtle: SubtleCrypto
|
||||||
|
|
||||||
/**
|
|
||||||
* Equivalent to `createHmac("sha256", key).update(data).digest()`
|
|
||||||
*/
|
|
||||||
hmacSHA256(key: Buffer, data: Buffer): Buffer
|
|
||||||
}
|
}
|
31
packages/opvault.js/src/adapter/node.ts
Normal file
31
packages/opvault.js/src/adapter/node.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { promises as fs, existsSync } from "fs"
|
||||||
|
import { webcrypto } from "crypto"
|
||||||
|
import { join } from "path"
|
||||||
|
import type { Adapter } from "./index"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default Node.js adapter. This can be used while using `opvault.js`
|
||||||
|
* in a Node.js environment.
|
||||||
|
*/
|
||||||
|
export const adapter: Adapter = {
|
||||||
|
fs: {
|
||||||
|
readTextFile: path => fs.readFile(path, "utf-8"),
|
||||||
|
readFile: path => fs.readFile(path),
|
||||||
|
writeTextFile: fs.writeFile,
|
||||||
|
async *readDir(path) {
|
||||||
|
const names = await fs.readdir(path)
|
||||||
|
for (const name of names) {
|
||||||
|
const fullPath = join(path, name)
|
||||||
|
const stat = await fs.stat(fullPath)
|
||||||
|
yield {
|
||||||
|
name,
|
||||||
|
isFile: stat.isFile(),
|
||||||
|
isDirectory: stat.isDirectory(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
exists: async path => existsSync(path),
|
||||||
|
},
|
||||||
|
subtle: (webcrypto as any).subtle,
|
||||||
|
}
|
@ -1,6 +1,4 @@
|
|||||||
import { Buffer } from "buffer"
|
import type { Adapter, DirEntry, FileSystem } from "./index"
|
||||||
import createHmac from "create-hmac"
|
|
||||||
import type { IAdapter, IFileSystem } from "./index"
|
|
||||||
|
|
||||||
function normalize(path: string) {
|
function normalize(path: string) {
|
||||||
return path.replace(/^\//, "")
|
return path.replace(/^\//, "")
|
||||||
@ -12,14 +10,9 @@ function splitPath(path: string) {
|
|||||||
return [segments, filename] as const
|
return [segments, filename] as const
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FileSystem implements IFileSystem {
|
class FS implements FileSystem {
|
||||||
constructor(private handle: FileSystemDirectoryHandle) {}
|
constructor(private handle: FileSystemDirectoryHandle) {}
|
||||||
|
|
||||||
static async create() {
|
|
||||||
const handle = await showDirectoryPicker()
|
|
||||||
return new FileSystem(handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getDirectoryHandle(segments: string[]) {
|
private async getDirectoryHandle(segments: string[]) {
|
||||||
if (!segments.length || (segments.length === 1 && !segments[0])) {
|
if (!segments.length || (segments.length === 1 && !segments[0])) {
|
||||||
return this.handle
|
return this.handle
|
||||||
@ -39,7 +32,7 @@ export class FileSystem implements IFileSystem {
|
|||||||
return fileHandle
|
return fileHandle
|
||||||
}
|
}
|
||||||
|
|
||||||
async readFile(path: string) {
|
async readTextFile(path: string) {
|
||||||
const handle = await this.getFileHandle(path)
|
const handle = await this.getFileHandle(path)
|
||||||
const file = await handle.getFile()
|
const file = await handle.getFile()
|
||||||
return file.text()
|
return file.text()
|
||||||
@ -66,30 +59,20 @@ export class FileSystem implements IFileSystem {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async readBuffer(path: string): Promise<Buffer> {
|
async readFile(path: string): Promise<Uint8Array> {
|
||||||
const handle = await this.getFileHandle(path)
|
const handle = await this.getFileHandle(path)
|
||||||
const file = await handle.getFile()
|
const file = await handle.getFile()
|
||||||
return Buffer.from(await file.arrayBuffer())
|
return new Uint8Array(await file.arrayBuffer())
|
||||||
}
|
}
|
||||||
|
|
||||||
async writeFile(path: string, data: string): Promise<void> {
|
async writeTextFile(path: string, data: string): Promise<void> {
|
||||||
const handle = await this.getFileHandle(path)
|
const handle = await this.getFileHandle(path)
|
||||||
const writable = await handle.createWritable()
|
const writable = await handle.createWritable()
|
||||||
await writable.write(data)
|
await writable.write(data)
|
||||||
await writable.close()
|
await writable.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
async readdir(path: string): Promise<string[]> {
|
private async isDirectory(path: string) {
|
||||||
const segments = normalize(path).split("/")
|
|
||||||
const dirHandle = await this.getDirectoryHandle(segments)
|
|
||||||
const keys: string[] = []
|
|
||||||
for await (const key of dirHandle.keys()) {
|
|
||||||
keys.push(key)
|
|
||||||
}
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
|
|
||||||
async isDirectory(path: string) {
|
|
||||||
const [segments, filename] = splitPath(path)
|
const [segments, filename] = splitPath(path)
|
||||||
const dirHandle = await this.getDirectoryHandle(segments)
|
const dirHandle = await this.getDirectoryHandle(segments)
|
||||||
for await (const [key, handle] of dirHandle.entries()) {
|
for await (const [key, handle] of dirHandle.entries()) {
|
||||||
@ -103,6 +86,19 @@ export class FileSystem implements IFileSystem {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async *readDir(path: string): AsyncIterable<DirEntry> {
|
||||||
|
const segments = normalize(path).split("/")
|
||||||
|
const dirHandle = await this.getDirectoryHandle(segments)
|
||||||
|
for await (const key of dirHandle.keys()) {
|
||||||
|
const isDirectory = await this.isDirectory(`${path}/${key}`)
|
||||||
|
yield {
|
||||||
|
name: key,
|
||||||
|
isDirectory,
|
||||||
|
isFile: !isDirectory,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function success(fn: () => Promise<any>) {
|
async function success(fn: () => Promise<any>) {
|
||||||
@ -117,8 +113,7 @@ async function success(fn: () => Promise<any>) {
|
|||||||
/**
|
/**
|
||||||
* Default Browser adapter.
|
* Default Browser adapter.
|
||||||
*/
|
*/
|
||||||
export const getBrowserAdapter = (handle: FileSystemDirectoryHandle): IAdapter => ({
|
export const getBrowserAdapter = (handle: FileSystemDirectoryHandle): Adapter => ({
|
||||||
fs: new FileSystem(handle),
|
fs: new FS(handle),
|
||||||
subtle: crypto.subtle,
|
subtle: crypto.subtle,
|
||||||
hmacSHA256: (key, data) => createHmac("sha256", key).update(data).digest(),
|
|
||||||
})
|
})
|
59
packages/opvault.js/src/adapter/webkitdirectory.ts
Normal file
59
packages/opvault.js/src/adapter/webkitdirectory.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { Buffer } from "buffer"
|
||||||
|
import type { Adapter, DirEntry, FileSystem } from "./index"
|
||||||
|
|
||||||
|
class FS implements FileSystem {
|
||||||
|
private paths = new Set<string>()
|
||||||
|
private pathMap = new Map<string, File>()
|
||||||
|
|
||||||
|
constructor(list: FileList) {
|
||||||
|
for (const file of list) {
|
||||||
|
this.pathMap.set(file.webkitRelativePath, file)
|
||||||
|
this.paths.add(file.webkitRelativePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async readTextFile(path: string) {
|
||||||
|
return this.pathMap.get(path)!.text()
|
||||||
|
}
|
||||||
|
|
||||||
|
async exists(path: string): Promise<boolean> {
|
||||||
|
return this.pathMap.has(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFile(path: string): Promise<Buffer> {
|
||||||
|
const arrayBuffer = await this.pathMap.get(path)!.arrayBuffer()
|
||||||
|
return Buffer.from(arrayBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
async writeTextFile(): Promise<void> {
|
||||||
|
throw new Error("fs.writeFile is not supported with webkitdirectory")
|
||||||
|
}
|
||||||
|
|
||||||
|
private isDirectory(path: string) {
|
||||||
|
const paths = [...this.paths]
|
||||||
|
return paths.some(_ => _.startsWith(`${path}/`)) && !paths.includes(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async *readDir(path: string): AsyncIterable<DirEntry> {
|
||||||
|
for (const name of [...this.paths]
|
||||||
|
.filter(_ => _.startsWith(`${path}/`))
|
||||||
|
.map(_ => _.slice(path.length + 1))
|
||||||
|
.map(_ => _.split("/")[0])) {
|
||||||
|
const isDirectory = this.isDirectory(path)
|
||||||
|
yield {
|
||||||
|
name,
|
||||||
|
isDirectory,
|
||||||
|
isFile: !isDirectory,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default Browser adapter.
|
||||||
|
*/
|
||||||
|
export const getBrowserAdapter = (list: FileList): Adapter => ({
|
||||||
|
fs: new FS(list),
|
||||||
|
subtle: crypto.subtle,
|
||||||
|
})
|
@ -1,324 +0,0 @@
|
|||||||
function bufferXor(a: Buffer, b: Buffer) {
|
|
||||||
const length = Math.min(a.length, b.length)
|
|
||||||
const buffer = Buffer.alloc(length)
|
|
||||||
|
|
||||||
for (let i = 0; i < length; ++i) {
|
|
||||||
buffer[i] = a[i] ^ b[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
return buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
function asUInt32Array(buf: Buffer) {
|
|
||||||
const len = (buf.length / 4) | 0
|
|
||||||
const out: number[] = new Array(len)
|
|
||||||
|
|
||||||
for (let i = 0; i < len; i++) {
|
|
||||||
out[i] = buf.readUInt32BE(i * 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function cryptBlock(
|
|
||||||
M: number[],
|
|
||||||
keySchedule: number[],
|
|
||||||
SUB_MIX: number[][],
|
|
||||||
SBOX: number[],
|
|
||||||
nRounds: number
|
|
||||||
) {
|
|
||||||
const SUB_MIX0 = SUB_MIX[0]
|
|
||||||
const SUB_MIX1 = SUB_MIX[1]
|
|
||||||
const SUB_MIX2 = SUB_MIX[2]
|
|
||||||
const SUB_MIX3 = SUB_MIX[3]
|
|
||||||
|
|
||||||
let s0 = M[0] ^ keySchedule[0]
|
|
||||||
let s1 = M[1] ^ keySchedule[1]
|
|
||||||
let s2 = M[2] ^ keySchedule[2]
|
|
||||||
let s3 = M[3] ^ keySchedule[3]
|
|
||||||
let t0: number
|
|
||||||
let t1: number
|
|
||||||
let t2: number
|
|
||||||
let t3: number
|
|
||||||
let ksRow = 4
|
|
||||||
|
|
||||||
for (let round = 1; round < nRounds; round++) {
|
|
||||||
t0 =
|
|
||||||
SUB_MIX0[s0 >>> 24] ^
|
|
||||||
SUB_MIX1[(s1 >>> 16) & 0xff] ^
|
|
||||||
SUB_MIX2[(s2 >>> 8) & 0xff] ^
|
|
||||||
SUB_MIX3[s3 & 0xff] ^
|
|
||||||
keySchedule[ksRow++]
|
|
||||||
t1 =
|
|
||||||
SUB_MIX0[s1 >>> 24] ^
|
|
||||||
SUB_MIX1[(s2 >>> 16) & 0xff] ^
|
|
||||||
SUB_MIX2[(s3 >>> 8) & 0xff] ^
|
|
||||||
SUB_MIX3[s0 & 0xff] ^
|
|
||||||
keySchedule[ksRow++]
|
|
||||||
t2 =
|
|
||||||
SUB_MIX0[s2 >>> 24] ^
|
|
||||||
SUB_MIX1[(s3 >>> 16) & 0xff] ^
|
|
||||||
SUB_MIX2[(s0 >>> 8) & 0xff] ^
|
|
||||||
SUB_MIX3[s1 & 0xff] ^
|
|
||||||
keySchedule[ksRow++]
|
|
||||||
t3 =
|
|
||||||
SUB_MIX0[s3 >>> 24] ^
|
|
||||||
SUB_MIX1[(s0 >>> 16) & 0xff] ^
|
|
||||||
SUB_MIX2[(s1 >>> 8) & 0xff] ^
|
|
||||||
SUB_MIX3[s2 & 0xff] ^
|
|
||||||
keySchedule[ksRow++]
|
|
||||||
s0 = t0
|
|
||||||
s1 = t1
|
|
||||||
s2 = t2
|
|
||||||
s3 = t3
|
|
||||||
}
|
|
||||||
|
|
||||||
t0 =
|
|
||||||
((SBOX[s0 >>> 24] << 24) |
|
|
||||||
(SBOX[(s1 >>> 16) & 0xff] << 16) |
|
|
||||||
(SBOX[(s2 >>> 8) & 0xff] << 8) |
|
|
||||||
SBOX[s3 & 0xff]) ^
|
|
||||||
keySchedule[ksRow++]
|
|
||||||
t1 =
|
|
||||||
((SBOX[s1 >>> 24] << 24) |
|
|
||||||
(SBOX[(s2 >>> 16) & 0xff] << 16) |
|
|
||||||
(SBOX[(s3 >>> 8) & 0xff] << 8) |
|
|
||||||
SBOX[s0 & 0xff]) ^
|
|
||||||
keySchedule[ksRow++]
|
|
||||||
t2 =
|
|
||||||
((SBOX[s2 >>> 24] << 24) |
|
|
||||||
(SBOX[(s3 >>> 16) & 0xff] << 16) |
|
|
||||||
(SBOX[(s0 >>> 8) & 0xff] << 8) |
|
|
||||||
SBOX[s1 & 0xff]) ^
|
|
||||||
keySchedule[ksRow++]
|
|
||||||
t3 =
|
|
||||||
((SBOX[s3 >>> 24] << 24) |
|
|
||||||
(SBOX[(s0 >>> 16) & 0xff] << 16) |
|
|
||||||
(SBOX[(s1 >>> 8) & 0xff] << 8) |
|
|
||||||
SBOX[s2 & 0xff]) ^
|
|
||||||
keySchedule[ksRow++]
|
|
||||||
t0 = t0 >>> 0
|
|
||||||
t1 = t1 >>> 0
|
|
||||||
t2 = t2 >>> 0
|
|
||||||
t3 = t3 >>> 0
|
|
||||||
|
|
||||||
return [t0, t1, t2, t3]
|
|
||||||
}
|
|
||||||
|
|
||||||
// AES constants
|
|
||||||
const RCON = [0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36]
|
|
||||||
const G = (function () {
|
|
||||||
// Compute double table
|
|
||||||
const d = new Array(256)
|
|
||||||
for (let j = 0; j < 256; j++) {
|
|
||||||
if (j < 128) {
|
|
||||||
d[j] = j << 1
|
|
||||||
} else {
|
|
||||||
d[j] = (j << 1) ^ 0x11b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const SBOX: number[] = []
|
|
||||||
const INV_SBOX: number[] = []
|
|
||||||
const SUB_MIX: number[][] = [[], [], [], []]
|
|
||||||
const INV_SUB_MIX: number[][] = [[], [], [], []]
|
|
||||||
|
|
||||||
// Walk GF(2^8)
|
|
||||||
let x = 0
|
|
||||||
let xi = 0
|
|
||||||
for (let i = 0; i < 256; ++i) {
|
|
||||||
// Compute sbox
|
|
||||||
let sx = xi ^ (xi << 1) ^ (xi << 2) ^ (xi << 3) ^ (xi << 4)
|
|
||||||
sx = (sx >>> 8) ^ (sx & 0xff) ^ 0x63
|
|
||||||
SBOX[x] = sx
|
|
||||||
INV_SBOX[sx] = x
|
|
||||||
|
|
||||||
// Compute multiplication
|
|
||||||
const x2 = d[x]
|
|
||||||
const x4 = d[x2]
|
|
||||||
const x8 = d[x4]
|
|
||||||
|
|
||||||
// Compute sub bytes, mix columns tables
|
|
||||||
let t = (d[sx] * 0x101) ^ (sx * 0x1010100)
|
|
||||||
SUB_MIX[0][x] = (t << 24) | (t >>> 8)
|
|
||||||
SUB_MIX[1][x] = (t << 16) | (t >>> 16)
|
|
||||||
SUB_MIX[2][x] = (t << 8) | (t >>> 24)
|
|
||||||
SUB_MIX[3][x] = t
|
|
||||||
|
|
||||||
// Compute inv sub bytes, inv mix columns tables
|
|
||||||
t = (x8 * 0x1010101) ^ (x4 * 0x10001) ^ (x2 * 0x101) ^ (x * 0x1010100)
|
|
||||||
INV_SUB_MIX[0][sx] = (t << 24) | (t >>> 8)
|
|
||||||
INV_SUB_MIX[1][sx] = (t << 16) | (t >>> 16)
|
|
||||||
INV_SUB_MIX[2][sx] = (t << 8) | (t >>> 24)
|
|
||||||
INV_SUB_MIX[3][sx] = t
|
|
||||||
|
|
||||||
if (x === 0) {
|
|
||||||
x = xi = 1
|
|
||||||
} else {
|
|
||||||
x = x2 ^ d[d[d[x8 ^ x2]]]
|
|
||||||
xi ^= d[d[xi]]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
SBOX,
|
|
||||||
INV_SBOX,
|
|
||||||
SUB_MIX,
|
|
||||||
INV_SUB_MIX,
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
class AES {
|
|
||||||
private _key: number[]
|
|
||||||
private _nRounds!: number
|
|
||||||
private _invKeySchedule!: number[]
|
|
||||||
|
|
||||||
constructor(key: Buffer) {
|
|
||||||
this._key = asUInt32Array(key)
|
|
||||||
this._reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
_reset() {
|
|
||||||
const keyWords = this._key
|
|
||||||
const keySize = keyWords.length
|
|
||||||
const nRounds = keySize + 6
|
|
||||||
const ksRows = (nRounds + 1) * 4
|
|
||||||
|
|
||||||
const keySchedule: number[] = []
|
|
||||||
for (let k = 0; k < keySize; k++) {
|
|
||||||
keySchedule[k] = keyWords[k]
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let k = keySize; k < ksRows; k++) {
|
|
||||||
let t = keySchedule[k - 1]
|
|
||||||
|
|
||||||
if (k % keySize === 0) {
|
|
||||||
t = (t << 8) | (t >>> 24)
|
|
||||||
t =
|
|
||||||
(G.SBOX[t >>> 24] << 24) |
|
|
||||||
(G.SBOX[(t >>> 16) & 0xff] << 16) |
|
|
||||||
(G.SBOX[(t >>> 8) & 0xff] << 8) |
|
|
||||||
G.SBOX[t & 0xff]
|
|
||||||
|
|
||||||
t ^= RCON[(k / keySize) | 0] << 24
|
|
||||||
} else if (keySize > 6 && k % keySize === 4) {
|
|
||||||
t =
|
|
||||||
(G.SBOX[t >>> 24] << 24) |
|
|
||||||
(G.SBOX[(t >>> 16) & 0xff] << 16) |
|
|
||||||
(G.SBOX[(t >>> 8) & 0xff] << 8) |
|
|
||||||
G.SBOX[t & 0xff]
|
|
||||||
}
|
|
||||||
|
|
||||||
keySchedule[k] = keySchedule[k - keySize] ^ t
|
|
||||||
}
|
|
||||||
|
|
||||||
const invKeySchedule = []
|
|
||||||
for (let ik = 0; ik < ksRows; ik++) {
|
|
||||||
const ksR = ksRows - ik
|
|
||||||
const tt = keySchedule[ksR - (ik % 4 ? 0 : 4)]
|
|
||||||
|
|
||||||
if (ik < 4 || ksR <= 4) {
|
|
||||||
invKeySchedule[ik] = tt
|
|
||||||
} else {
|
|
||||||
invKeySchedule[ik] =
|
|
||||||
G.INV_SUB_MIX[0][G.SBOX[tt >>> 24]] ^
|
|
||||||
G.INV_SUB_MIX[1][G.SBOX[(tt >>> 16) & 0xff]] ^
|
|
||||||
G.INV_SUB_MIX[2][G.SBOX[(tt >>> 8) & 0xff]] ^
|
|
||||||
G.INV_SUB_MIX[3][G.SBOX[tt & 0xff]]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this._nRounds = nRounds
|
|
||||||
this._invKeySchedule = invKeySchedule
|
|
||||||
}
|
|
||||||
|
|
||||||
decryptBlock(buffer: Buffer) {
|
|
||||||
const M = asUInt32Array(buffer)
|
|
||||||
|
|
||||||
// swap
|
|
||||||
const m1 = M[1]
|
|
||||||
M[1] = M[3]
|
|
||||||
M[3] = m1
|
|
||||||
|
|
||||||
const out = cryptBlock(
|
|
||||||
M,
|
|
||||||
this._invKeySchedule,
|
|
||||||
G.INV_SUB_MIX,
|
|
||||||
G.INV_SBOX,
|
|
||||||
this._nRounds
|
|
||||||
)
|
|
||||||
|
|
||||||
const buf = Buffer.allocUnsafe(16)
|
|
||||||
buf.writeUInt32BE(out[0], 0)
|
|
||||||
buf.writeUInt32BE(out[3], 4)
|
|
||||||
buf.writeUInt32BE(out[2], 8)
|
|
||||||
buf.writeUInt32BE(out[1], 12)
|
|
||||||
return buf
|
|
||||||
}
|
|
||||||
|
|
||||||
static blockSize = 4 * 4
|
|
||||||
static keySize = 256 / 8
|
|
||||||
}
|
|
||||||
|
|
||||||
class Decipher {
|
|
||||||
private _cipher: AES
|
|
||||||
private _prev: Buffer
|
|
||||||
private _cache = new Splitter()
|
|
||||||
|
|
||||||
constructor(key: Buffer, iv: Buffer) {
|
|
||||||
this._cipher = new AES(key)
|
|
||||||
this._prev = Buffer.from(iv)
|
|
||||||
}
|
|
||||||
|
|
||||||
update(data: Buffer) {
|
|
||||||
this._cache.add(data)
|
|
||||||
let chunk: Buffer | null
|
|
||||||
let thing: Buffer
|
|
||||||
const out: Buffer[] = []
|
|
||||||
while ((chunk = this._cache.get())) {
|
|
||||||
thing = this.cbc_decrypt(chunk)
|
|
||||||
out.push(thing)
|
|
||||||
}
|
|
||||||
return Buffer.concat(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
setAutoPadding(setTo: boolean) {
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
cbc_decrypt(block: Buffer) {
|
|
||||||
const pad = this._prev
|
|
||||||
|
|
||||||
this._prev = block
|
|
||||||
const out = this._cipher.decryptBlock(block)
|
|
||||||
|
|
||||||
return bufferXor(out, pad)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Splitter {
|
|
||||||
cache = Buffer.allocUnsafe(0)
|
|
||||||
|
|
||||||
add(data: Buffer) {
|
|
||||||
this.cache = Buffer.concat([this.cache, data])
|
|
||||||
}
|
|
||||||
|
|
||||||
get() {
|
|
||||||
if (this.cache.length >= 16) {
|
|
||||||
const out = this.cache.slice(0, 16)
|
|
||||||
this.cache = this.cache.slice(16)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDecipheriv(_suite: any, password: Buffer, iv: Buffer) {
|
|
||||||
if (iv.length !== 16) {
|
|
||||||
throw new TypeError(`invalid iv length ${iv.length}`)
|
|
||||||
}
|
|
||||||
if (password.length !== 256 / 8) {
|
|
||||||
throw new TypeError(`invalid key length ${password.length}`)
|
|
||||||
}
|
|
||||||
return new Decipher(password, iv)
|
|
||||||
}
|
|
@ -1,21 +0,0 @@
|
|||||||
import { promises as fs, existsSync } from "fs"
|
|
||||||
import { webcrypto, createHmac } from "crypto"
|
|
||||||
|
|
||||||
import type { IAdapter } from "./index"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default Node.js adapter. This can be used while using `opvault.js`
|
|
||||||
* in a Node.js environment.
|
|
||||||
*/
|
|
||||||
export const adapter: IAdapter = {
|
|
||||||
fs: {
|
|
||||||
readFile: path => fs.readFile(path, "utf-8"),
|
|
||||||
readBuffer: path => fs.readFile(path),
|
|
||||||
writeFile: fs.writeFile,
|
|
||||||
readdir: fs.readdir,
|
|
||||||
isDirectory: async path => fs.stat(path).then(x => x.isDirectory()),
|
|
||||||
exists: async path => existsSync(path),
|
|
||||||
},
|
|
||||||
subtle: (webcrypto as any).subtle,
|
|
||||||
hmacSHA256: (key, data) => createHmac("sha256", key).update(data).digest(),
|
|
||||||
}
|
|
362
packages/opvault.js/src/buffer.ts
Normal file
362
packages/opvault.js/src/buffer.ts
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
/**
|
||||||
|
* The buffer module from node.js, for the browser.
|
||||||
|
*
|
||||||
|
* @author Feross Aboukhadijeh <https://feross.org>
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
// The MIT License (MIT)
|
||||||
|
// Copyright (c) 2014 Jameson Little
|
||||||
|
|
||||||
|
const lookup: string[] = []
|
||||||
|
const revLookup: number[] = []
|
||||||
|
|
||||||
|
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||||
|
for (let i = 0, len = alphabet.length; i < len; ++i) {
|
||||||
|
lookup[i] = alphabet[i]
|
||||||
|
revLookup[alphabet.charCodeAt(i)] = i
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support decoding URL-safe base64 strings, as Node.js does.
|
||||||
|
// See: https://en.wikipedia.org/wiki/Base64#URL_applications
|
||||||
|
revLookup[45] = 62
|
||||||
|
revLookup[95] = 63
|
||||||
|
|
||||||
|
function base64ToByteArray(b64: string): Uint8Array {
|
||||||
|
const { length } = b64
|
||||||
|
|
||||||
|
if (length % 4 > 0) {
|
||||||
|
throw new Error("Invalid string. Length must be a multiple of 4")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim off extra bytes after placeholder bytes are found
|
||||||
|
// See: https://github.com/beatgammit/base64-js/issues/42
|
||||||
|
let validLen = b64.indexOf("=")
|
||||||
|
if (validLen === -1) validLen = length
|
||||||
|
|
||||||
|
const placeHoldersLen = validLen === length ? 0 : 4 - (validLen % 4)
|
||||||
|
|
||||||
|
const arr = new Uint8Array(((validLen + placeHoldersLen) * 3) / 4 - placeHoldersLen)
|
||||||
|
|
||||||
|
let curByte = 0
|
||||||
|
|
||||||
|
// if there are placeholders, only get up to the last complete 4 chars
|
||||||
|
const len = placeHoldersLen > 0 ? validLen - 4 : validLen
|
||||||
|
|
||||||
|
let i: number
|
||||||
|
for (i = 0; i < len; i += 4) {
|
||||||
|
const tmp =
|
||||||
|
(revLookup[b64.charCodeAt(i)] << 18) |
|
||||||
|
(revLookup[b64.charCodeAt(i + 1)] << 12) |
|
||||||
|
(revLookup[b64.charCodeAt(i + 2)] << 6) |
|
||||||
|
revLookup[b64.charCodeAt(i + 3)]
|
||||||
|
arr[curByte++] = (tmp >> 16) & 0xff
|
||||||
|
arr[curByte++] = (tmp >> 8) & 0xff
|
||||||
|
arr[curByte++] = tmp & 0xff
|
||||||
|
}
|
||||||
|
|
||||||
|
if (placeHoldersLen === 2) {
|
||||||
|
const tmp =
|
||||||
|
(revLookup[b64.charCodeAt(i)] << 2) | (revLookup[b64.charCodeAt(i + 1)] >> 4)
|
||||||
|
arr[curByte++] = tmp & 0xff
|
||||||
|
}
|
||||||
|
|
||||||
|
if (placeHoldersLen === 1) {
|
||||||
|
const tmp =
|
||||||
|
(revLookup[b64.charCodeAt(i)] << 10) |
|
||||||
|
(revLookup[b64.charCodeAt(i + 1)] << 4) |
|
||||||
|
(revLookup[b64.charCodeAt(i + 2)] >> 2)
|
||||||
|
arr[curByte++] = (tmp >> 8) & 0xff
|
||||||
|
arr[curByte++] = tmp & 0xff
|
||||||
|
}
|
||||||
|
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
const tripletToBase64 = (num: number) =>
|
||||||
|
lookup[(num >> 18) & 0x3f] +
|
||||||
|
lookup[(num >> 12) & 0x3f] +
|
||||||
|
lookup[(num >> 6) & 0x3f] +
|
||||||
|
lookup[num & 0x3f]
|
||||||
|
|
||||||
|
function encodeChunk(uint8: Uint8Array, start: number, end: number) {
|
||||||
|
const output: string[] = []
|
||||||
|
for (let i = start; i < end; i += 3) {
|
||||||
|
const tmp =
|
||||||
|
((uint8[i] << 16) & 0xff0000) +
|
||||||
|
((uint8[i + 1] << 8) & 0xff00) +
|
||||||
|
(uint8[i + 2] & 0xff)
|
||||||
|
output.push(tripletToBase64(tmp))
|
||||||
|
}
|
||||||
|
return output.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base64FromByteArray(uint8: Uint8Array): string {
|
||||||
|
let tmp: number
|
||||||
|
const len = uint8.length
|
||||||
|
const extraBytes = len % 3 // if we have 1 byte left, pad 2 bytes
|
||||||
|
const parts = []
|
||||||
|
const maxChunkLength = 16383 // must be multiple of 3
|
||||||
|
|
||||||
|
// go through the array every three bytes, we'll deal with trailing stuff later
|
||||||
|
for (let i = 0, len2 = len - extraBytes; i < len2; i += maxChunkLength) {
|
||||||
|
parts.push(
|
||||||
|
encodeChunk(uint8, i, i + maxChunkLength > len2 ? len2 : i + maxChunkLength)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pad the end with zeros, but make sure to not forget the extra bytes
|
||||||
|
if (extraBytes === 1) {
|
||||||
|
tmp = uint8[len - 1]
|
||||||
|
parts.push(lookup[tmp >> 2] + lookup[(tmp << 4) & 0x3f] + "==")
|
||||||
|
} else if (extraBytes === 2) {
|
||||||
|
tmp = (uint8[len - 2] << 8) + uint8[len - 1]
|
||||||
|
parts.push(
|
||||||
|
lookup[tmp >> 10] + lookup[(tmp >> 4) & 0x3f] + lookup[(tmp << 2) & 0x3f] + "="
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromBase64(string: string) {
|
||||||
|
const { length } = base64ToBytes(string)
|
||||||
|
let buf = new Uint8Array(length)
|
||||||
|
|
||||||
|
const actual = write(buf, string)
|
||||||
|
|
||||||
|
if (actual !== length) {
|
||||||
|
// Writing a hex string, for example, that contains invalid characters will
|
||||||
|
// cause everything after the first invalid character to be ignored. (e.g.
|
||||||
|
// 'abxxcd' will be treated as 'ab')
|
||||||
|
buf = buf.slice(0, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(array: Uint8Array, string: string) {
|
||||||
|
const offset = 0
|
||||||
|
let { length } = array
|
||||||
|
const remaining = array.length - offset
|
||||||
|
if (length === undefined || length > remaining) length = remaining
|
||||||
|
|
||||||
|
if ((string.length > 0 && (length < 0 || offset < 0)) || offset > array.length) {
|
||||||
|
throw new RangeError("Attempt to write outside buffer bounds")
|
||||||
|
}
|
||||||
|
|
||||||
|
return blitBuffer(base64ToBytes(string), array, offset, length)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function utf8Slice(array: Uint8Array, start = 0, end = array.length) {
|
||||||
|
end = Math.min(array.length, end)
|
||||||
|
const res: number[] = []
|
||||||
|
|
||||||
|
let i = start
|
||||||
|
while (i < end) {
|
||||||
|
const firstByte = array[i]
|
||||||
|
let codePoint = null
|
||||||
|
let bytesPerSequence =
|
||||||
|
firstByte > 0xef ? 4 : firstByte > 0xdf ? 3 : firstByte > 0xbf ? 2 : 1
|
||||||
|
|
||||||
|
if (i + bytesPerSequence <= end) {
|
||||||
|
let secondByte: number
|
||||||
|
let thirdByte: number
|
||||||
|
let fourthByte: number
|
||||||
|
let tempCodePoint: number
|
||||||
|
|
||||||
|
switch (bytesPerSequence) {
|
||||||
|
case 1:
|
||||||
|
if (firstByte < 0x80) {
|
||||||
|
codePoint = firstByte
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 2:
|
||||||
|
secondByte = array[i + 1]
|
||||||
|
if ((secondByte & 0xc0) === 0x80) {
|
||||||
|
tempCodePoint = ((firstByte & 0x1f) << 0x6) | (secondByte & 0x3f)
|
||||||
|
if (tempCodePoint > 0x7f) {
|
||||||
|
codePoint = tempCodePoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 3:
|
||||||
|
secondByte = array[i + 1]
|
||||||
|
thirdByte = array[i + 2]
|
||||||
|
if ((secondByte & 0xc0) === 0x80 && (thirdByte & 0xc0) === 0x80) {
|
||||||
|
tempCodePoint =
|
||||||
|
((firstByte & 0xf) << 0xc) |
|
||||||
|
((secondByte & 0x3f) << 0x6) |
|
||||||
|
(thirdByte & 0x3f)
|
||||||
|
if (
|
||||||
|
tempCodePoint > 0x7ff &&
|
||||||
|
(tempCodePoint < 0xd800 || tempCodePoint > 0xdfff)
|
||||||
|
) {
|
||||||
|
codePoint = tempCodePoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 4:
|
||||||
|
secondByte = array[i + 1]
|
||||||
|
thirdByte = array[i + 2]
|
||||||
|
fourthByte = array[i + 3]
|
||||||
|
if (
|
||||||
|
(secondByte & 0xc0) === 0x80 &&
|
||||||
|
(thirdByte & 0xc0) === 0x80 &&
|
||||||
|
(fourthByte & 0xc0) === 0x80
|
||||||
|
) {
|
||||||
|
tempCodePoint =
|
||||||
|
((firstByte & 0xf) << 0x12) |
|
||||||
|
((secondByte & 0x3f) << 0xc) |
|
||||||
|
((thirdByte & 0x3f) << 0x6) |
|
||||||
|
(fourthByte & 0x3f)
|
||||||
|
if (tempCodePoint > 0xffff && tempCodePoint < 0x110000) {
|
||||||
|
codePoint = tempCodePoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (codePoint === null) {
|
||||||
|
// we did not generate a valid codePoint so insert a
|
||||||
|
// replacement char (U+FFFD) and advance only 1 byte
|
||||||
|
codePoint = 0xfffd
|
||||||
|
bytesPerSequence = 1
|
||||||
|
} else if (codePoint > 0xffff) {
|
||||||
|
// encode to utf16 (surrogate pair dance)
|
||||||
|
codePoint -= 0x10000
|
||||||
|
res.push(((codePoint >>> 10) & 0x3ff) | 0xd800)
|
||||||
|
codePoint = 0xdc00 | (codePoint & 0x3ff)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.push(codePoint)
|
||||||
|
i += bytesPerSequence
|
||||||
|
}
|
||||||
|
|
||||||
|
return decodeCodePointsArray(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Based on http://stackoverflow.com/a/22747272/680742, the browser with
|
||||||
|
// the lowest limit is Chrome, with 0x10000 args.
|
||||||
|
// We go 1 magnitude less, for safety
|
||||||
|
const MAX_ARGUMENTS_LENGTH = 0x1000
|
||||||
|
|
||||||
|
function decodeCodePointsArray(codePoints: number[]) {
|
||||||
|
const len = codePoints.length
|
||||||
|
if (len <= MAX_ARGUMENTS_LENGTH) {
|
||||||
|
return String.fromCharCode(...codePoints) // avoid extra slice()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode in chunks to avoid "call stack size exceeded".
|
||||||
|
let res = ""
|
||||||
|
let i = 0
|
||||||
|
while (i < len) {
|
||||||
|
res += String.fromCharCode(...codePoints.slice(i, (i += MAX_ARGUMENTS_LENGTH)))
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Need to make sure that buffer isn't trying to write out of bounds.
|
||||||
|
*/
|
||||||
|
function checkOffset(offset: number, ext: number, length: number) {
|
||||||
|
if (offset % 1 !== 0 || offset < 0) throw new RangeError("offset is not uint")
|
||||||
|
if (offset + ext > length) throw new RangeError("Trying to access beyond buffer length")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readUInt32BE(array: Uint8Array, offset: number, noAssert?: boolean) {
|
||||||
|
offset = offset >>> 0
|
||||||
|
if (!noAssert) checkOffset(offset, 4, array.length)
|
||||||
|
|
||||||
|
return (
|
||||||
|
array[offset] * 0x1000000 +
|
||||||
|
((array[offset + 1] << 16) | (array[offset + 2] << 8) | array[offset + 3])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readIntLE(
|
||||||
|
array: Uint8Array,
|
||||||
|
offset: number,
|
||||||
|
byteLength: number,
|
||||||
|
noAssert?: boolean
|
||||||
|
) {
|
||||||
|
offset = offset >>> 0
|
||||||
|
byteLength = byteLength >>> 0
|
||||||
|
if (!noAssert) checkOffset(offset, byteLength, array.length)
|
||||||
|
|
||||||
|
let val = array[offset]
|
||||||
|
let mul = 1
|
||||||
|
let i = 0
|
||||||
|
while (++i < byteLength && (mul *= 0x100)) {
|
||||||
|
val += array[offset + i] * mul
|
||||||
|
}
|
||||||
|
mul *= 0x80
|
||||||
|
|
||||||
|
if (val >= mul) val -= Math.pow(2, 8 * byteLength)
|
||||||
|
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkInt(
|
||||||
|
buf: Uint8Array,
|
||||||
|
value: number,
|
||||||
|
offset: number,
|
||||||
|
ext: number,
|
||||||
|
max: number,
|
||||||
|
min: number
|
||||||
|
) {
|
||||||
|
if (value > max || value < min) {
|
||||||
|
throw new RangeError('"value" argument is out of bounds')
|
||||||
|
}
|
||||||
|
if (offset + ext > buf.length) {
|
||||||
|
throw new RangeError("Index out of range")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeUInt32BE(
|
||||||
|
array: Uint8Array,
|
||||||
|
value: number,
|
||||||
|
offset: number,
|
||||||
|
noAssert?: boolean
|
||||||
|
) {
|
||||||
|
value = +value
|
||||||
|
offset = offset >>> 0
|
||||||
|
if (!noAssert) checkInt(array, value, offset, 4, 0xffffffff, 0)
|
||||||
|
array[offset] = value >>> 24
|
||||||
|
array[offset + 1] = value >>> 16
|
||||||
|
array[offset + 2] = value >>> 8
|
||||||
|
array[offset + 3] = value & 0xff
|
||||||
|
return offset + 4
|
||||||
|
}
|
||||||
|
|
||||||
|
// HELPER FUNCTIONS
|
||||||
|
// ================
|
||||||
|
|
||||||
|
const INVALID_BASE64_RE = /[^+/0-9A-Za-z-_]/g
|
||||||
|
|
||||||
|
function base64ToBytes(str: string) {
|
||||||
|
// Node takes equal signs as end of the Base64 encoding
|
||||||
|
;[str] = str.split("=")
|
||||||
|
// Node strips out invalid characters like \n and \t from the string, base64-js does not
|
||||||
|
str = str.trim().replace(INVALID_BASE64_RE, "")
|
||||||
|
// Node converts strings with length < 2 to ''
|
||||||
|
if (str.length < 2) return new Uint8Array()
|
||||||
|
// Node allows for non-padded base64 strings (missing trailing ===), base64-js does not
|
||||||
|
while (str.length % 4 !== 0) {
|
||||||
|
str = str + "="
|
||||||
|
}
|
||||||
|
return base64ToByteArray(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
function blitBuffer(
|
||||||
|
src: Uint8Array | number[],
|
||||||
|
dst: Uint8Array,
|
||||||
|
offset: number,
|
||||||
|
length: number
|
||||||
|
) {
|
||||||
|
let i: number
|
||||||
|
for (i = 0; i < length; ++i) {
|
||||||
|
if (i + offset >= dst.length || i >= src.length) break
|
||||||
|
dst[i + offset] = src[i]
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
191
packages/opvault.js/src/crypto.ts
Normal file
191
packages/opvault.js/src/crypto.ts
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import { decryptData } from "./decipher"
|
||||||
|
import type { Adapter } from "./adapter"
|
||||||
|
import { createEventEmitter } from "./ee"
|
||||||
|
import { HMACAssertionError, OPVaultError } from "./errors"
|
||||||
|
import type { ItemDetails, Overview, Profile } from "./types"
|
||||||
|
import { setIfAbsent } from "./util"
|
||||||
|
import type { EncryptedItem } from "./models/item"
|
||||||
|
import { fromBase64, utf8Slice } from "./buffer"
|
||||||
|
|
||||||
|
/** Encryption and MAC */
|
||||||
|
export interface Cipher {
|
||||||
|
/** Encryption key */
|
||||||
|
key: Uint8Array
|
||||||
|
/** HMAC key */
|
||||||
|
hmac: Uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Crypto {
|
||||||
|
#disposables: (() => void)[] = []
|
||||||
|
#locked = true
|
||||||
|
|
||||||
|
#master!: Cipher
|
||||||
|
#overview!: Cipher
|
||||||
|
|
||||||
|
private subtle: SubtleCrypto
|
||||||
|
|
||||||
|
readonly onLock = createEventEmitter<void>()
|
||||||
|
|
||||||
|
constructor(adapter: Adapter) {
|
||||||
|
this.subtle = adapter.subtle
|
||||||
|
}
|
||||||
|
|
||||||
|
async unlock(profile: Profile, masterPassword: string) {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const key = await this.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
encoder.encode(masterPassword),
|
||||||
|
{ name: "PBKDF2" },
|
||||||
|
false,
|
||||||
|
["deriveBits"]
|
||||||
|
)
|
||||||
|
const derivedKey = await this.subtle.deriveBits(
|
||||||
|
{
|
||||||
|
name: "PBKDF2",
|
||||||
|
salt: fromBase64(profile.salt),
|
||||||
|
iterations: profile.iterations,
|
||||||
|
hash: {
|
||||||
|
name: "SHA-512",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
64 << 3
|
||||||
|
)
|
||||||
|
|
||||||
|
const cipher = splitPlainText(new Uint8Array(derivedKey))
|
||||||
|
|
||||||
|
// Derive master key and overview keys
|
||||||
|
this.#master = await this.decryptKeys(profile.masterKey, cipher)
|
||||||
|
this.#overview = await this.decryptKeys(profile.overviewKey, cipher)
|
||||||
|
this.#locked = false
|
||||||
|
}
|
||||||
|
|
||||||
|
get locked() {
|
||||||
|
return this.#locked
|
||||||
|
}
|
||||||
|
|
||||||
|
lock() {
|
||||||
|
this.#locked = true
|
||||||
|
this.#master = null!
|
||||||
|
this.#overview = null!
|
||||||
|
this.#disposables.forEach(fn => fn())
|
||||||
|
this.onLock()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertUnlocked() {
|
||||||
|
if (this.#locked) {
|
||||||
|
throw new OPVaultError("This vault is locked", "VAULT_LOCKED")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#createCache = <K, V, K2 = K>(
|
||||||
|
deriveArg: (value: K) => K2,
|
||||||
|
implementation: (value: K2) => V
|
||||||
|
) => {
|
||||||
|
const map = new Map<K2, V>()
|
||||||
|
this.#disposables.push(() => map.clear())
|
||||||
|
return (data: K) => setIfAbsent(map, deriveArg(data), implementation)
|
||||||
|
}
|
||||||
|
|
||||||
|
#createWeakCache = <K extends object, V>(implementation: (value: K) => V) => {
|
||||||
|
let map = new WeakMap<K, V>()
|
||||||
|
this.#disposables.push(() => {
|
||||||
|
map = new WeakMap()
|
||||||
|
})
|
||||||
|
return (data: K) => setIfAbsent(map, data, implementation)
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptItemDetails = this.#createWeakCache(async (item: EncryptedItem) => {
|
||||||
|
const cipher = await this.deriveConcreteKey(item)
|
||||||
|
const detail = await this.decryptOPData(fromBase64(item.d), cipher)
|
||||||
|
return JSON.parse(utf8Slice(detail)) as ItemDetails
|
||||||
|
})
|
||||||
|
|
||||||
|
decryptItemOverview = this.#createCache(
|
||||||
|
(item: EncryptedItem) => item.o,
|
||||||
|
async (o: string) => {
|
||||||
|
const overview = await this.decryptOPData(fromBase64(o), this.#overview)
|
||||||
|
return JSON.parse(utf8Slice(overview)) as Overview
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
deriveConcreteKey = this.#createCache(
|
||||||
|
(data: { k: string }) => data.k,
|
||||||
|
async ($k: string) => {
|
||||||
|
const k = fromBase64($k)
|
||||||
|
const data = k.slice(0, -32)
|
||||||
|
await this.assertHMac(data, this.#master.hmac, k.slice(-32))
|
||||||
|
const derivedKey = await this.decryptData(
|
||||||
|
this.#master.key,
|
||||||
|
data.slice(0, 16),
|
||||||
|
data.slice(16)
|
||||||
|
)
|
||||||
|
return splitPlainText(derivedKey)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async assertHMac(data: Uint8Array, key: Uint8Array, expected: Uint8Array) {
|
||||||
|
const cryptoKey = await this.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
name: "HMAC",
|
||||||
|
hash: {
|
||||||
|
name: "SHA-256",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
["verify"]
|
||||||
|
)
|
||||||
|
const verified = await this.subtle.verify("HMAC", cryptoKey, expected, data)
|
||||||
|
if (!verified) {
|
||||||
|
throw new HMACAssertionError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async decryptOPData(cipherText: Uint8Array, cipher: Cipher) {
|
||||||
|
const key = cipherText.slice(0, -32)
|
||||||
|
await this.assertHMac(key, cipher.hmac, cipherText.slice(-32))
|
||||||
|
|
||||||
|
const plaintext = await this.decryptData(cipher.key, key.slice(16, 32), key.slice(32))
|
||||||
|
const size = readUint16(key.slice(8, 16))
|
||||||
|
return plaintext.slice(-size)
|
||||||
|
}
|
||||||
|
|
||||||
|
async decryptData(key: Uint8Array, iv: Uint8Array, data: Uint8Array) {
|
||||||
|
// try {
|
||||||
|
// const algorithm = { name: "AES-CBC", length: 256, iv }
|
||||||
|
// const keyCrypto = await this.subtle.importKey("raw", key, algorithm, false, [
|
||||||
|
// "decrypt",
|
||||||
|
// ])
|
||||||
|
// console.log("hi", keyCrypto)
|
||||||
|
// const decrypted = await this.subtle.decrypt(algorithm, keyCrypto, data)
|
||||||
|
// console.log("decrypted")
|
||||||
|
// return Buffer.from(decrypted)
|
||||||
|
// // return createDecipheriv("aes-256-cbc", key, iv).setAutoPadding(false).update(data)
|
||||||
|
// } catch (e) {
|
||||||
|
// console.error(e)
|
||||||
|
return decryptData(key, iv, data)
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
async decryptKeys(encryptedKey: string, derived: Cipher) {
|
||||||
|
const buffer = fromBase64(encryptedKey)
|
||||||
|
const base = await this.decryptOPData(buffer, derived)
|
||||||
|
const digest = await this.subtle.digest("SHA-512", base)
|
||||||
|
return splitPlainText(new Uint8Array(digest))
|
||||||
|
}
|
||||||
|
|
||||||
|
get overview() {
|
||||||
|
return this.#overview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const splitPlainText = (derivedKey: Uint8Array): Cipher => ({
|
||||||
|
key: derivedKey.slice(0, 32),
|
||||||
|
hmac: derivedKey.slice(32, 64),
|
||||||
|
})
|
||||||
|
|
||||||
|
function readUint16({ buffer, byteOffset, length }: Uint8Array) {
|
||||||
|
return new DataView(buffer, byteOffset, length).getUint16(0, true)
|
||||||
|
}
|
300
packages/opvault.js/src/decipher.ts
Normal file
300
packages/opvault.js/src/decipher.ts
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
/**
|
||||||
|
* Extracted from the following sources:
|
||||||
|
*
|
||||||
|
* | License | Name | Copyright |
|
||||||
|
* |---------|----------------|--------------------------------------------|
|
||||||
|
* | MIT | sha.js | (c) 2013-2018 sha.js contributors |
|
||||||
|
* | MIT | Crypto-js | (c) 2009-2013 Jeff Mott. |
|
||||||
|
* | MIT | browserify-aes | (c) 2014-2017 browserify-aes contributors |
|
||||||
|
*/
|
||||||
|
import invariant from "tiny-invariant"
|
||||||
|
import { readUInt32BE, writeUInt32BE } from "./buffer"
|
||||||
|
|
||||||
|
function bufferXor(a: Uint8Array, b: Uint8Array) {
|
||||||
|
const length = Math.min(a.length, b.length)
|
||||||
|
const buffer = new Uint8Array(length)
|
||||||
|
|
||||||
|
for (let i = 0; i < length; ++i) {
|
||||||
|
buffer[i] = a[i] ^ b[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUInt32Array(buf: Uint8Array) {
|
||||||
|
const len = (buf.length / 4) | 0
|
||||||
|
const out: number[] = new Array(len)
|
||||||
|
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
out[i] = readUInt32BE(buf, i * 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function cryptBlock(
|
||||||
|
M: number[],
|
||||||
|
keySchedule: number[],
|
||||||
|
subMix: number[][],
|
||||||
|
sbox: number[],
|
||||||
|
nRounds: number
|
||||||
|
) {
|
||||||
|
const [subMix_0, subMix_1, subMix_2, subMix_3] = subMix
|
||||||
|
|
||||||
|
let s0 = M[0] ^ keySchedule[0]
|
||||||
|
let s1 = M[1] ^ keySchedule[1]
|
||||||
|
let s2 = M[2] ^ keySchedule[2]
|
||||||
|
let s3 = M[3] ^ keySchedule[3]
|
||||||
|
let ksRow = 4
|
||||||
|
|
||||||
|
for (let round = 1; round < nRounds; round++) {
|
||||||
|
const t0 =
|
||||||
|
subMix_0[s0 >>> 24] ^
|
||||||
|
subMix_1[(s1 >>> 16) & 0xff] ^
|
||||||
|
subMix_2[(s2 >>> 8) & 0xff] ^
|
||||||
|
subMix_3[s3 & 0xff] ^
|
||||||
|
keySchedule[ksRow++]
|
||||||
|
const t1 =
|
||||||
|
subMix_0[s1 >>> 24] ^
|
||||||
|
subMix_1[(s2 >>> 16) & 0xff] ^
|
||||||
|
subMix_2[(s3 >>> 8) & 0xff] ^
|
||||||
|
subMix_3[s0 & 0xff] ^
|
||||||
|
keySchedule[ksRow++]
|
||||||
|
const t2 =
|
||||||
|
subMix_0[s2 >>> 24] ^
|
||||||
|
subMix_1[(s3 >>> 16) & 0xff] ^
|
||||||
|
subMix_2[(s0 >>> 8) & 0xff] ^
|
||||||
|
subMix_3[s1 & 0xff] ^
|
||||||
|
keySchedule[ksRow++]
|
||||||
|
const t3 =
|
||||||
|
subMix_0[s3 >>> 24] ^
|
||||||
|
subMix_1[(s0 >>> 16) & 0xff] ^
|
||||||
|
subMix_2[(s1 >>> 8) & 0xff] ^
|
||||||
|
subMix_3[s2 & 0xff] ^
|
||||||
|
keySchedule[ksRow++]
|
||||||
|
s0 = t0
|
||||||
|
s1 = t1
|
||||||
|
s2 = t2
|
||||||
|
s3 = t3
|
||||||
|
}
|
||||||
|
|
||||||
|
let t0 =
|
||||||
|
((sbox[s0 >>> 24] << 24) |
|
||||||
|
(sbox[(s1 >>> 16) & 0xff] << 16) |
|
||||||
|
(sbox[(s2 >>> 8) & 0xff] << 8) |
|
||||||
|
sbox[s3 & 0xff]) ^
|
||||||
|
keySchedule[ksRow++]
|
||||||
|
let t1 =
|
||||||
|
((sbox[s1 >>> 24] << 24) |
|
||||||
|
(sbox[(s2 >>> 16) & 0xff] << 16) |
|
||||||
|
(sbox[(s3 >>> 8) & 0xff] << 8) |
|
||||||
|
sbox[s0 & 0xff]) ^
|
||||||
|
keySchedule[ksRow++]
|
||||||
|
let t2 =
|
||||||
|
((sbox[s2 >>> 24] << 24) |
|
||||||
|
(sbox[(s3 >>> 16) & 0xff] << 16) |
|
||||||
|
(sbox[(s0 >>> 8) & 0xff] << 8) |
|
||||||
|
sbox[s1 & 0xff]) ^
|
||||||
|
keySchedule[ksRow++]
|
||||||
|
let t3 =
|
||||||
|
((sbox[s3 >>> 24] << 24) |
|
||||||
|
(sbox[(s0 >>> 16) & 0xff] << 16) |
|
||||||
|
(sbox[(s1 >>> 8) & 0xff] << 8) |
|
||||||
|
sbox[s2 & 0xff]) ^
|
||||||
|
keySchedule[ksRow++]
|
||||||
|
t0 = t0 >>> 0
|
||||||
|
t1 = t1 >>> 0
|
||||||
|
t2 = t2 >>> 0
|
||||||
|
t3 = t3 >>> 0
|
||||||
|
|
||||||
|
return [t0, t1, t2, t3]
|
||||||
|
}
|
||||||
|
|
||||||
|
// AES constants
|
||||||
|
const RCON = [0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36]
|
||||||
|
const G = (() => {
|
||||||
|
// Compute double table
|
||||||
|
const d = new Array<number>(256)
|
||||||
|
for (let j = 0; j < 256; j++) {
|
||||||
|
if (j < 128) {
|
||||||
|
d[j] = j << 1
|
||||||
|
} else {
|
||||||
|
d[j] = (j << 1) ^ 0x11b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sbox: number[] = []
|
||||||
|
const invSBox: number[] = []
|
||||||
|
const subMix: number[][] = [[], [], [], []]
|
||||||
|
const invSubMix: number[][] = [[], [], [], []]
|
||||||
|
|
||||||
|
// Walk GF(2^8)
|
||||||
|
let x = 0
|
||||||
|
let xi = 0
|
||||||
|
for (let i = 0; i < 256; ++i) {
|
||||||
|
// Compute sbox
|
||||||
|
let sx = xi ^ (xi << 1) ^ (xi << 2) ^ (xi << 3) ^ (xi << 4)
|
||||||
|
sx = (sx >>> 8) ^ (sx & 0xff) ^ 0x63
|
||||||
|
sbox[x] = sx
|
||||||
|
invSBox[sx] = x
|
||||||
|
|
||||||
|
// Compute multiplication
|
||||||
|
const x2 = d[x]
|
||||||
|
const x4 = d[x2]
|
||||||
|
const x8 = d[x4]
|
||||||
|
|
||||||
|
// Compute sub bytes, mix columns tables
|
||||||
|
let t = (d[sx] * 0x101) ^ (sx * 0x1010100)
|
||||||
|
subMix[0][x] = (t << 24) | (t >>> 8)
|
||||||
|
subMix[1][x] = (t << 16) | (t >>> 16)
|
||||||
|
subMix[2][x] = (t << 8) | (t >>> 24)
|
||||||
|
subMix[3][x] = t
|
||||||
|
|
||||||
|
// Compute inv sub bytes, inv mix columns tables
|
||||||
|
t = (x8 * 0x1010101) ^ (x4 * 0x10001) ^ (x2 * 0x101) ^ (x * 0x1010100)
|
||||||
|
invSubMix[0][sx] = (t << 24) | (t >>> 8)
|
||||||
|
invSubMix[1][sx] = (t << 16) | (t >>> 16)
|
||||||
|
invSubMix[2][sx] = (t << 8) | (t >>> 24)
|
||||||
|
invSubMix[3][sx] = t
|
||||||
|
|
||||||
|
if (x === 0) {
|
||||||
|
x = xi = 1
|
||||||
|
} else {
|
||||||
|
x = x2 ^ d[d[d[x8 ^ x2]]]
|
||||||
|
xi ^= d[d[xi]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sbox,
|
||||||
|
invSBox,
|
||||||
|
subMix,
|
||||||
|
invSubMix,
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
class AES {
|
||||||
|
private key: number[]
|
||||||
|
private nRounds!: number
|
||||||
|
private invKeySchedule!: number[]
|
||||||
|
|
||||||
|
constructor(key: Uint8Array) {
|
||||||
|
this.key = toUInt32Array(key)
|
||||||
|
this.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
private reset() {
|
||||||
|
const keyWords = this.key
|
||||||
|
const keySize = keyWords.length
|
||||||
|
const nRounds = keySize + 6
|
||||||
|
const ksRows = (nRounds + 1) * 4
|
||||||
|
|
||||||
|
const keySchedule: number[] = []
|
||||||
|
for (let k = 0; k < keySize; k++) {
|
||||||
|
keySchedule[k] = keyWords[k]
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let k = keySize; k < ksRows; k++) {
|
||||||
|
let t = keySchedule[k - 1]
|
||||||
|
|
||||||
|
if (k % keySize === 0) {
|
||||||
|
t = (t << 8) | (t >>> 24)
|
||||||
|
t =
|
||||||
|
(G.sbox[t >>> 24] << 24) |
|
||||||
|
(G.sbox[(t >>> 16) & 0xff] << 16) |
|
||||||
|
(G.sbox[(t >>> 8) & 0xff] << 8) |
|
||||||
|
G.sbox[t & 0xff]
|
||||||
|
|
||||||
|
t ^= RCON[(k / keySize) | 0] << 24
|
||||||
|
} else if (keySize > 6 && k % keySize === 4) {
|
||||||
|
t =
|
||||||
|
(G.sbox[t >>> 24] << 24) |
|
||||||
|
(G.sbox[(t >>> 16) & 0xff] << 16) |
|
||||||
|
(G.sbox[(t >>> 8) & 0xff] << 8) |
|
||||||
|
G.sbox[t & 0xff]
|
||||||
|
}
|
||||||
|
|
||||||
|
keySchedule[k] = keySchedule[k - keySize] ^ t
|
||||||
|
}
|
||||||
|
|
||||||
|
const invKeySchedule = []
|
||||||
|
for (let ik = 0; ik < ksRows; ik++) {
|
||||||
|
const ksR = ksRows - ik
|
||||||
|
const tt = keySchedule[ksR - (ik % 4 ? 0 : 4)]
|
||||||
|
|
||||||
|
if (ik < 4 || ksR <= 4) {
|
||||||
|
invKeySchedule[ik] = tt
|
||||||
|
} else {
|
||||||
|
invKeySchedule[ik] =
|
||||||
|
G.invSubMix[0][G.sbox[tt >>> 24]] ^
|
||||||
|
G.invSubMix[1][G.sbox[(tt >>> 16) & 0xff]] ^
|
||||||
|
G.invSubMix[2][G.sbox[(tt >>> 8) & 0xff]] ^
|
||||||
|
G.invSubMix[3][G.sbox[tt & 0xff]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.nRounds = nRounds
|
||||||
|
this.invKeySchedule = invKeySchedule
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptBlock(buffer: Uint8Array) {
|
||||||
|
const M = toUInt32Array(buffer)
|
||||||
|
|
||||||
|
// swap
|
||||||
|
const m1 = M[1]
|
||||||
|
M[1] = M[3]
|
||||||
|
M[3] = m1
|
||||||
|
|
||||||
|
const out = cryptBlock(M, this.invKeySchedule, G.invSubMix, G.invSBox, this.nRounds)
|
||||||
|
|
||||||
|
const buf = new Uint8Array(16)
|
||||||
|
writeUInt32BE(buf, out[0], 0)
|
||||||
|
writeUInt32BE(buf, out[3], 4)
|
||||||
|
writeUInt32BE(buf, out[2], 8)
|
||||||
|
writeUInt32BE(buf, out[1], 12)
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
static blockSize = 4 * 4
|
||||||
|
static keySize = 256 / 8
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitter = (data: Uint8Array) => () => {
|
||||||
|
if (data.length >= 16) {
|
||||||
|
const out = data.slice(0, 16)
|
||||||
|
data = data.slice(16)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// AES-256-CBC
|
||||||
|
// == createDecipheriv("aes-256-cbc", key, iv).setAutoPadding(false).update(data)
|
||||||
|
export function decryptData(key: Uint8Array, iv: Uint8Array, data: Uint8Array) {
|
||||||
|
invariant(iv.length === 16, `invalid iv length ${iv.length}`)
|
||||||
|
invariant(key.length === 32, `invalid key length ${key.length}`)
|
||||||
|
|
||||||
|
const cipher = new AES(key)
|
||||||
|
let prev = iv
|
||||||
|
const readChunk = splitter(data)
|
||||||
|
let chunk: Uint8Array | null
|
||||||
|
const res: Uint8Array[] = []
|
||||||
|
let totalLength = 0
|
||||||
|
|
||||||
|
while ((chunk = readChunk())) {
|
||||||
|
const pad = prev
|
||||||
|
prev = chunk
|
||||||
|
const out = cipher.decryptBlock(chunk)
|
||||||
|
const array = bufferXor(out, pad)
|
||||||
|
res.push(array)
|
||||||
|
totalLength += array.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = new Uint8Array(totalLength)
|
||||||
|
let offset = 0
|
||||||
|
for (const array of res) {
|
||||||
|
result.set(array, offset)
|
||||||
|
offset += array.length
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
@ -1,19 +1,14 @@
|
|||||||
export function createEventEmitter<T = void>() {
|
export function createEventEmitter<T = void>() {
|
||||||
type EventListener = T extends void ? () => void : (value: T) => void
|
type EventListener = T extends void ? () => void : (value: T) => void
|
||||||
type Emitter = T extends void
|
type Emitter = T extends void
|
||||||
? { (): void; (listener: EventListener): IDisposable }
|
? { (): void; (listener: EventListener): () => void }
|
||||||
: { (value: T): void; (listener: EventListener): IDisposable }
|
: { (value: T): void; (listener: EventListener): () => void }
|
||||||
|
|
||||||
const listeners = new Set<EventListener>()
|
const listeners = new Set<EventListener>()
|
||||||
|
|
||||||
function emitter(value: T | EventListener) {
|
function emitter(value: T | EventListener) {
|
||||||
if (typeof value === "function") {
|
if (typeof value === "function") {
|
||||||
listeners.add(value as EventListener)
|
listeners.add(value as EventListener)
|
||||||
return {
|
|
||||||
dispose() {
|
|
||||||
listeners.delete(value as EventListener)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
listeners.forEach(fn => fn(value))
|
listeners.forEach(fn => fn(value))
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,15 @@
|
|||||||
export abstract class OPVaultError extends Error {}
|
export class OPVaultError extends Error {
|
||||||
|
constructor(message?: string, readonly code?: string) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class AssertionError extends OPVaultError {}
|
class AssertionError extends OPVaultError {}
|
||||||
|
|
||||||
export class HMACAssertionError extends AssertionError {}
|
export class HMACAssertionError extends AssertionError {}
|
||||||
|
|
||||||
|
export class NotUnlockedError extends AssertionError {}
|
||||||
|
|
||||||
export function invariant(condition: any, message?: string): asserts condition {
|
export function invariant(condition: any, message?: string): asserts condition {
|
||||||
if (!condition) {
|
if (!condition) {
|
||||||
throw new AssertionError(message)
|
throw new AssertionError(message)
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { resolve, extname, basename } from "path"
|
import { resolve, extname, basename } from "path"
|
||||||
import invariant from "tiny-invariant"
|
import invariant from "tiny-invariant"
|
||||||
import type { IFileSystem } from "./adapters"
|
import type { FileSystem } from "./adapter"
|
||||||
import { once } from "./util"
|
import { once } from "./util"
|
||||||
|
|
||||||
export type OnePasswordFileManager = ReturnType<typeof OnePasswordFileManager>
|
export type OnePasswordFileManager = ReturnType<typeof OnePasswordFileManager>
|
||||||
|
|
||||||
export async function OnePasswordFileManager(
|
export async function OnePasswordFileManager(
|
||||||
fs: IFileSystem,
|
fs: FileSystem,
|
||||||
path: string,
|
path: string,
|
||||||
profileName: string
|
profileName: string
|
||||||
) {
|
) {
|
||||||
@ -18,49 +18,48 @@ export async function OnePasswordFileManager(
|
|||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
getProfile() {
|
getProfile() {
|
||||||
return fs.readFile(abs("profile.js"))
|
return fs.readTextFile(abs("profile.js"))
|
||||||
},
|
},
|
||||||
|
|
||||||
getFolders() {
|
getFolders() {
|
||||||
return fs.readFile(abs("folders.js"))
|
return fs.readTextFile(abs("folders.js"))
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAttachments() {
|
async *getAttachments() {
|
||||||
const files = await fs.readdir(root)
|
for await (const { name } of fs.readDir(root)) {
|
||||||
return files
|
if (extname(name) !== ".attachment") continue
|
||||||
.filter(name => extname(name) === ".attachment")
|
|
||||||
.map(name => {
|
|
||||||
const sep = name.indexOf("_")
|
const sep = name.indexOf("_")
|
||||||
const path = resolve(root, name)
|
const path = resolve(root, name)
|
||||||
const [itemUUID, fileUUID] = [
|
const [itemUUID, fileUUID] = [
|
||||||
name.slice(0, sep),
|
name.slice(0, sep),
|
||||||
basename(name.slice(sep + 1), extname(name)),
|
basename(name.slice(sep + 1), extname(name)),
|
||||||
]
|
]
|
||||||
return {
|
yield {
|
||||||
itemUUID,
|
itemUUID,
|
||||||
fileUUID,
|
fileUUID,
|
||||||
getFile: once(() => fs.readBuffer(path)),
|
getFile: once(() => fs.readFile(path)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async getBand(name: string) {
|
async getBand(name: string) {
|
||||||
const path = abs(`band_${name}.js`)
|
const path = abs(`band_${name}.js`)
|
||||||
if (await fs.exists(path)) {
|
if (await fs.exists(path)) {
|
||||||
return await fs.readFile(path)
|
return await fs.readTextFile(path)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async setProfile(profile: string) {
|
async setProfile(profile: string) {
|
||||||
await fs.writeFile("profile.js", profile)
|
await fs.writeTextFile("profile.js", profile)
|
||||||
},
|
},
|
||||||
|
|
||||||
async setFolders(folders: string) {
|
async setFolders(folders: string) {
|
||||||
await fs.writeFile("folders.js", folders)
|
await fs.writeTextFile("folders.js", folders)
|
||||||
},
|
},
|
||||||
|
|
||||||
async setBand(name: string, band: string) {
|
async setBand(name: string, band: string) {
|
||||||
await fs.writeFile(`band_${name}.js`, band)
|
await fs.writeTextFile(`band_${name}.js`, band)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
6
packages/opvault.js/src/global.d.ts
vendored
6
packages/opvault.js/src/global.d.ts
vendored
@ -1,5 +1,7 @@
|
|||||||
type integer = number
|
type integer = number
|
||||||
|
|
||||||
interface IDisposable {
|
declare namespace NodeJS {
|
||||||
dispose(): void
|
interface Process {
|
||||||
|
browser?: boolean
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,26 +0,0 @@
|
|||||||
import json from "./res.json"
|
|
||||||
|
|
||||||
const locale =
|
|
||||||
process.env.LOCALE || Intl.DateTimeFormat().resolvedOptions().locale.split("-")[0]
|
|
||||||
|
|
||||||
const mapValue = <T, R>(
|
|
||||||
object: Record<string, T>,
|
|
||||||
fn: (value: T, key: string) => R
|
|
||||||
): Record<string, R> => {
|
|
||||||
const res = Object.create(null)
|
|
||||||
Object.entries(object).forEach(([key, value]) => {
|
|
||||||
res[key] = fn(value, key)
|
|
||||||
})
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
type json = typeof json
|
|
||||||
export type i18n = {
|
|
||||||
[K in keyof json]: {
|
|
||||||
[L in keyof json[K]]: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const i18n: i18n = mapValue(json, dict =>
|
|
||||||
mapValue(dict, (value: any) => value[locale] ?? value.en)
|
|
||||||
) as any
|
|
@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"error": {
|
|
||||||
"invalidPassword": {
|
|
||||||
"en": "Invalid password",
|
|
||||||
"fr": "Mot de passe invalide"
|
|
||||||
},
|
|
||||||
"vaultIsLocked": {
|
|
||||||
"en": "This vault is locked",
|
|
||||||
"fr": "Ce coffre est verrouillé."
|
|
||||||
},
|
|
||||||
"cannotDecryptOverviewItem": {
|
|
||||||
"en": "Failed to decrypt overview item",
|
|
||||||
"fr": "Impossible de déchiffrer cet aperçu"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,11 +1,15 @@
|
|||||||
import { resolve } from "path"
|
import { resolve } from "path"
|
||||||
import { Vault } from "./models/vault"
|
import { Vault } from "./models/vault"
|
||||||
import type { IAdapter } from "./adapters"
|
import type { Adapter } from "./adapter"
|
||||||
import { asyncMap } from "./util"
|
|
||||||
|
|
||||||
export type { Vault } from "./models/vault"
|
export type { Vault } from "./models/vault"
|
||||||
|
export type { Item } from "./models/item"
|
||||||
|
export type { Attachment, AttachmentMetadata } from "./models/attachment"
|
||||||
|
export type { ItemField, ItemSection } from "./types"
|
||||||
export { Category, FieldType } from "./models"
|
export { Category, FieldType } from "./models"
|
||||||
|
|
||||||
|
export type { Adapter as IAdapter } from "./adapter/index"
|
||||||
|
|
||||||
interface IOptions {
|
interface IOptions {
|
||||||
/**
|
/**
|
||||||
* Path to `.opvault` directory
|
* Path to `.opvault` directory
|
||||||
@ -15,37 +19,49 @@ interface IOptions {
|
|||||||
/**
|
/**
|
||||||
* Adapter used to interact with the file system and cryptography modules
|
* Adapter used to interact with the file system and cryptography modules
|
||||||
*/
|
*/
|
||||||
adapter?: IAdapter
|
adapter: Adapter | Promise<Adapter>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OnePassword instance
|
* OnePassword instance
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* import { OnePassword } from "opvault.js"
|
||||||
|
* import { adapter } from "opvault.js/src/adapter/node"
|
||||||
|
*
|
||||||
|
* const op = new OnePassword({
|
||||||
|
* path: "/path/to/1password/vault",
|
||||||
|
* adapter,
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* const profileNames = await op.getProfileNames()
|
||||||
|
* const vault = await op.getProfile(profileNames[0])
|
||||||
|
* const item = await vault.getItemByTitle("My Login")
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export class OnePassword {
|
export class OnePassword {
|
||||||
readonly #path: string
|
readonly #path: string
|
||||||
readonly #adapter: IAdapter
|
readonly #adapter: Adapter | Promise<Adapter>
|
||||||
|
|
||||||
constructor({ path, adapter = require("./adapters/node").adapter }: IOptions) {
|
constructor(options: IOptions) {
|
||||||
this.#adapter = adapter
|
this.#adapter = options.adapter
|
||||||
this.#path = path
|
this.#path = options.path
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns A list of names of profiles of the current vault.
|
* @returns A list of names of profiles of the current vault.
|
||||||
*/
|
*/
|
||||||
async getProfileNames() {
|
async getProfileNames() {
|
||||||
const [fs, path] = [this.#adapter.fs, this.#path]
|
const { fs } = await this.#adapter
|
||||||
const children = await fs.readdir(path)
|
|
||||||
const profiles: string[] = []
|
const profiles: string[] = []
|
||||||
await asyncMap(children, async child => {
|
|
||||||
const fullPath = resolve(path, child)
|
for await (const { name, isDirectory } of fs.readDir(this.#path)) {
|
||||||
if (
|
const fullPath = resolve(this.#path, name)
|
||||||
(await fs.isDirectory(fullPath)) &&
|
if (isDirectory && (await fs.exists(resolve(fullPath, "profile.js")))) {
|
||||||
(await fs.exists(resolve(fullPath, "profile.js")))
|
profiles.push(name)
|
||||||
) {
|
|
||||||
profiles.push(child)
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
return profiles
|
return profiles
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,6 +69,6 @@ export class OnePassword {
|
|||||||
* @returns A OnePassword Vault instance.
|
* @returns A OnePassword Vault instance.
|
||||||
*/
|
*/
|
||||||
async getProfile(profileName: string) {
|
async getProfile(profileName: string) {
|
||||||
return await Vault.of(this.#path, profileName, this.#adapter)
|
return await Vault.of(this.#path, profileName, await this.#adapter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,24 +1,22 @@
|
|||||||
import { invariant } from "./errors"
|
|
||||||
|
|
||||||
export enum Category {
|
export enum Category {
|
||||||
Login = 1,
|
Login = "001",
|
||||||
CreditCard = 2,
|
CreditCard = "002",
|
||||||
SecureNote = 3,
|
SecureNote = "003",
|
||||||
Identity = 4,
|
Identity = "004",
|
||||||
Password = 5,
|
Password = "005",
|
||||||
Tombstone = 99,
|
Tombstone = "099",
|
||||||
SoftwareLicense = 100,
|
SoftwareLicense = "100",
|
||||||
BankAccount = 101,
|
BankAccount = "101",
|
||||||
Database = 102,
|
Database = "102",
|
||||||
DriverLicense = 103,
|
DriverLicense = "103",
|
||||||
OutdoorLicense = 104,
|
OutdoorLicense = "104",
|
||||||
Membership = 105,
|
Membership = "105",
|
||||||
Passport = 106,
|
Passport = "106",
|
||||||
Rewards = 107,
|
Rewards = "107",
|
||||||
SSN = 108,
|
SSN = "108",
|
||||||
Router = 109,
|
Router = "109",
|
||||||
Server = 110,
|
Server = "110",
|
||||||
Email = 111,
|
Email = "111",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum FieldType {
|
export enum FieldType {
|
||||||
@ -31,9 +29,3 @@ export enum FieldType {
|
|||||||
Checkbox = "C",
|
Checkbox = "C",
|
||||||
URL = "U",
|
URL = "U",
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCategory(category: string) {
|
|
||||||
const int = parseInt(category)
|
|
||||||
invariant(int in Category, `Invalid category: ${category}`)
|
|
||||||
return int as Category
|
|
||||||
}
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Buffer } from "buffer"
|
import type { Crypto } from "../crypto"
|
||||||
import type { Crypto } from "./crypto"
|
|
||||||
import { invariant } from "../errors"
|
import { invariant } from "../errors"
|
||||||
|
import { fromBase64, readIntLE, utf8Slice } from "../buffer"
|
||||||
|
|
||||||
type integer = number
|
type integer = number
|
||||||
|
|
||||||
@ -21,22 +21,22 @@ export interface AttachmentMetadata {
|
|||||||
export class Attachment {
|
export class Attachment {
|
||||||
#k: string
|
#k: string
|
||||||
#crypto: Crypto
|
#crypto: Crypto
|
||||||
#buffer: Buffer
|
#buffer: Uint8Array
|
||||||
|
|
||||||
#icon?: Buffer // png buffer
|
#icon?: Uint8Array // png buffer
|
||||||
#file?: Buffer
|
#file?: Uint8Array
|
||||||
#metadata?: AttachmentMetadata
|
#metadata?: AttachmentMetadata
|
||||||
|
|
||||||
private metadataSize: number
|
private metadataSize: number
|
||||||
private iconSize: number
|
private iconSize: number
|
||||||
|
|
||||||
constructor(crypto: Crypto, k: string, buffer: Buffer) {
|
constructor(crypto: Crypto, k: string, buffer: Uint8Array) {
|
||||||
this.#buffer = buffer
|
this.#buffer = buffer
|
||||||
this.#validate()
|
this.#validate()
|
||||||
this.#crypto = crypto
|
this.#crypto = crypto
|
||||||
this.#k = k
|
this.#k = k
|
||||||
this.metadataSize = buffer.readIntLE(8, 2)
|
this.metadataSize = readIntLE(buffer, 8, 2)
|
||||||
this.iconSize = buffer.readIntLE(12, 3)
|
this.iconSize = readIntLE(buffer, 12, 3)
|
||||||
|
|
||||||
crypto.onLock(() => {
|
crypto.onLock(() => {
|
||||||
this.#lock()
|
this.#lock()
|
||||||
@ -49,52 +49,48 @@ export class Attachment {
|
|||||||
#validate() {
|
#validate() {
|
||||||
const file = this.#buffer
|
const file = this.#buffer
|
||||||
invariant(
|
invariant(
|
||||||
file.slice(0, 6).toString("utf-8") === "OPCLDA",
|
utf8Slice(file.slice(0, 6)) === "OPCLDA",
|
||||||
"Attachment must start with OPCLDA"
|
"Attachment must start with OPCLDA"
|
||||||
)
|
)
|
||||||
|
// @TODO: Re-enable this
|
||||||
|
false &&
|
||||||
invariant(
|
invariant(
|
||||||
file.readIntLE(7, 1) === 1,
|
readIntLE(file, 7, 1) === 1,
|
||||||
"The version for this attachment file format is not supported."
|
"The version for this attachment file format is not supported."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get icon() {
|
get icon() {
|
||||||
if (this.#icon == null) {
|
|
||||||
this.#decrypt()
|
|
||||||
}
|
|
||||||
return this.#icon!
|
return this.#icon!
|
||||||
}
|
}
|
||||||
|
|
||||||
get file() {
|
get file() {
|
||||||
if (this.#file == null) {
|
|
||||||
this.#decrypt()
|
|
||||||
}
|
|
||||||
return this.#file!
|
return this.#file!
|
||||||
}
|
}
|
||||||
|
|
||||||
get metadata() {
|
get metadata() {
|
||||||
if (this.#metadata == null) {
|
|
||||||
this.#decrypt()
|
|
||||||
}
|
|
||||||
return this.#metadata!
|
return this.#metadata!
|
||||||
}
|
}
|
||||||
|
|
||||||
#decrypt() {
|
async unlock() {
|
||||||
const crypto = this.#crypto
|
const crypto = this.#crypto
|
||||||
const cipher = crypto.deriveConcreteKey({ k: this.#k })
|
const cipher = await crypto.deriveConcreteKey({ k: this.#k })
|
||||||
const { metadataSize, iconSize } = this
|
const { metadataSize, iconSize } = this
|
||||||
const buffer = this.#buffer
|
const buffer = this.#buffer
|
||||||
this.#icon = crypto.decryptOPData(
|
this.#icon = await crypto.decryptOPData(
|
||||||
buffer.slice(16 + metadataSize, 16 + metadataSize + iconSize),
|
buffer.slice(16 + metadataSize, 16 + metadataSize + iconSize),
|
||||||
cipher
|
cipher
|
||||||
)
|
)
|
||||||
this.#file = crypto.decryptOPData(buffer.slice(16 + metadataSize + iconSize), cipher)
|
this.#file = await crypto.decryptOPData(
|
||||||
|
buffer.slice(16 + metadataSize + iconSize),
|
||||||
|
cipher
|
||||||
|
)
|
||||||
|
|
||||||
const metadata = JSON.parse(buffer.slice(16, 16 + metadataSize).toString("utf-8"))
|
const metadata = JSON.parse(utf8Slice(buffer.slice(16, 16 + metadataSize)))
|
||||||
metadata.overview = JSON.parse(
|
metadata.overview = JSON.parse(
|
||||||
crypto
|
utf8Slice(
|
||||||
.decryptOPData(Buffer.from(metadata.overview, "base64"), crypto.overview)
|
await crypto.decryptOPData(fromBase64(metadata.overview), crypto.overview)
|
||||||
.toString()
|
)
|
||||||
)
|
)
|
||||||
this.#metadata = metadata
|
this.#metadata = metadata
|
||||||
}
|
}
|
||||||
|
@ -1,173 +0,0 @@
|
|||||||
import { Buffer } from "buffer"
|
|
||||||
import { createDecipheriv } from "../adapters/decipher"
|
|
||||||
import type { IAdapter } from "../adapters"
|
|
||||||
import { createEventEmitter } from "../ee"
|
|
||||||
import { HMACAssertionError } from "../errors"
|
|
||||||
import type { i18n } from "../i18n"
|
|
||||||
import type { ItemDetails, Overview, Profile } from "../types"
|
|
||||||
import { setIfAbsent } from "../util"
|
|
||||||
import type { EncryptedItem } from "./item"
|
|
||||||
|
|
||||||
/** Encryption and MAC */
|
|
||||||
export interface Cipher {
|
|
||||||
/** Encryption key */
|
|
||||||
key: Buffer
|
|
||||||
/** HMAC key */
|
|
||||||
hmac: Buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Crypto implements IDisposable {
|
|
||||||
#disposables: IDisposable[] = []
|
|
||||||
#locked = true
|
|
||||||
|
|
||||||
#master!: Cipher
|
|
||||||
#overview!: Cipher
|
|
||||||
|
|
||||||
private subtle: SubtleCrypto
|
|
||||||
private hmacSHA256: IAdapter["hmacSHA256"]
|
|
||||||
|
|
||||||
readonly onLock = createEventEmitter<void>()
|
|
||||||
|
|
||||||
constructor(private readonly i18n: i18n, adapter: IAdapter) {
|
|
||||||
this.subtle = adapter.subtle
|
|
||||||
this.hmacSHA256 = adapter.hmacSHA256
|
|
||||||
}
|
|
||||||
|
|
||||||
async unlock(profile: Profile, masterPassword: string) {
|
|
||||||
const encoder = new TextEncoder()
|
|
||||||
const key = await this.subtle.importKey(
|
|
||||||
"raw",
|
|
||||||
encoder.encode(masterPassword),
|
|
||||||
{ name: "PBKDF2" },
|
|
||||||
false,
|
|
||||||
["deriveBits"]
|
|
||||||
)
|
|
||||||
const derivedKey = await this.subtle.deriveBits(
|
|
||||||
{
|
|
||||||
name: "PBKDF2",
|
|
||||||
salt: Buffer.from(profile.salt, "base64"),
|
|
||||||
iterations: profile.iterations,
|
|
||||||
hash: {
|
|
||||||
name: "SHA-512",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
key,
|
|
||||||
64 << 3
|
|
||||||
)
|
|
||||||
|
|
||||||
const cipher = splitPlainText(Buffer.from(derivedKey))
|
|
||||||
|
|
||||||
// Derive master key and overview keys
|
|
||||||
this.#master = await this.decryptKeys(profile.masterKey, cipher)
|
|
||||||
this.#overview = await this.decryptKeys(profile.overviewKey, cipher)
|
|
||||||
this.#locked = false
|
|
||||||
}
|
|
||||||
|
|
||||||
get locked() {
|
|
||||||
return this.#locked
|
|
||||||
}
|
|
||||||
|
|
||||||
lock() {
|
|
||||||
this.#locked = true
|
|
||||||
this.#master = null!
|
|
||||||
this.#overview = null!
|
|
||||||
this.#disposables.forEach(fn => fn.dispose())
|
|
||||||
this.onLock()
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
this.lock()
|
|
||||||
}
|
|
||||||
|
|
||||||
assertUnlocked() {
|
|
||||||
if (this.#locked) {
|
|
||||||
throw new Error(this.i18n.error.vaultIsLocked)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#createCache = <K, V, K2 = K>(
|
|
||||||
deriveArg: (value: K) => K2,
|
|
||||||
implementation: (value: K2) => V
|
|
||||||
) => {
|
|
||||||
const map = new Map<K2, V>()
|
|
||||||
this.#disposables.push({
|
|
||||||
dispose: () => map.clear(),
|
|
||||||
})
|
|
||||||
return (data: K) => setIfAbsent(map, deriveArg(data), implementation)
|
|
||||||
}
|
|
||||||
|
|
||||||
#createWeakCache = <K extends object, V>(implementation: (value: K) => V) => {
|
|
||||||
let map = new WeakMap<K, V>()
|
|
||||||
this.#disposables.push({
|
|
||||||
dispose() {
|
|
||||||
map = new WeakMap()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return (data: K) => setIfAbsent(map, data, implementation)
|
|
||||||
}
|
|
||||||
|
|
||||||
decryptItemDetails = this.#createWeakCache((item: EncryptedItem) => {
|
|
||||||
const cipher = this.deriveConcreteKey(item)
|
|
||||||
const detail = this.decryptOPData(Buffer.from(item.d, "base64"), cipher)
|
|
||||||
return JSON.parse(detail.toString("utf-8")) as ItemDetails
|
|
||||||
})
|
|
||||||
|
|
||||||
decryptItemOverview = this.#createCache(
|
|
||||||
(item: EncryptedItem) => item.o,
|
|
||||||
(o: string) => {
|
|
||||||
const overview = this.decryptOPData(Buffer.from(o, "base64"), this.#overview)
|
|
||||||
return JSON.parse(overview.toString("utf8")) as Overview
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
deriveConcreteKey = this.#createCache(
|
|
||||||
(data: { k: string }) => data.k,
|
|
||||||
($k: string) => {
|
|
||||||
const k = Buffer.from($k, "base64")
|
|
||||||
const data = k.slice(0, -32)
|
|
||||||
this.assertHMac(data, this.#master.hmac, k.slice(-32))
|
|
||||||
const derivedKey = decryptData(this.#master.key, data.slice(0, 16), data.slice(16))
|
|
||||||
return splitPlainText(derivedKey)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assertHMac(data: Buffer, key: Buffer, expected: Buffer) {
|
|
||||||
const actual = this.hmacSHA256(key, data)
|
|
||||||
if (!actual.equals(expected)) {
|
|
||||||
throw new HMACAssertionError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
decryptOPData(cipherText: Buffer, cipher: Cipher) {
|
|
||||||
const key = cipherText.slice(0, -32)
|
|
||||||
this.assertHMac(key, cipher.hmac, cipherText.slice(-32))
|
|
||||||
|
|
||||||
const plaintext = decryptData(cipher.key, key.slice(16, 32), key.slice(32))
|
|
||||||
const size = readUint16(key.slice(8, 16))
|
|
||||||
return plaintext.slice(-size)
|
|
||||||
}
|
|
||||||
|
|
||||||
async decryptKeys(encryptedKey: string, derived: Cipher) {
|
|
||||||
const buffer = Buffer.from(encryptedKey, "base64")
|
|
||||||
const base = this.decryptOPData(buffer, derived)
|
|
||||||
const digest = await this.subtle.digest("SHA-512", base)
|
|
||||||
return splitPlainText(Buffer.from(digest))
|
|
||||||
}
|
|
||||||
|
|
||||||
get overview() {
|
|
||||||
return this.#overview
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const splitPlainText = (derivedKey: Buffer): Cipher => ({
|
|
||||||
key: derivedKey.slice(0, 32),
|
|
||||||
hmac: derivedKey.slice(32, 64),
|
|
||||||
})
|
|
||||||
|
|
||||||
function decryptData(key: Buffer, iv: Buffer, data: Buffer) {
|
|
||||||
return createDecipheriv("aes-256-cbc", key, iv).setAutoPadding(false).update(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
function readUint16({ buffer, byteOffset, length }: Buffer) {
|
|
||||||
return new DataView(buffer, byteOffset, length).getUint16(0, true)
|
|
||||||
}
|
|
@ -1,12 +1,14 @@
|
|||||||
import type { ItemDetails, Overview } from "../types"
|
import type { ItemDetails, Overview } from "../types"
|
||||||
import type { Crypto } from "./crypto"
|
import type { Crypto } from "../crypto"
|
||||||
import { Attachment } from "./attachment"
|
import { Attachment } from "./attachment"
|
||||||
|
import { NotUnlockedError } from "../errors"
|
||||||
|
import type { Category } from "../models"
|
||||||
|
|
||||||
export interface EncryptedItem {
|
export interface EncryptedItem {
|
||||||
category: string // "001"
|
category: string // "001"
|
||||||
/** Unix seconds */
|
/** Unix seconds */
|
||||||
created: integer
|
created: integer
|
||||||
d: string // "b3BkYXRhMbt"
|
d: string // details, bass64
|
||||||
folder: string // 32 chars
|
folder: string // 32 chars
|
||||||
hmac: string // base64
|
hmac: string // base64
|
||||||
k: string // base64
|
k: string // base64
|
||||||
@ -14,31 +16,84 @@ export interface EncryptedItem {
|
|||||||
tx: integer // Unix seconds
|
tx: integer // Unix seconds
|
||||||
updated: integer // Unix seconds
|
updated: integer // Unix seconds
|
||||||
uuid: string // 32 chars
|
uuid: string // 32 chars
|
||||||
|
fave: number
|
||||||
|
trashed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Item {
|
export class Item {
|
||||||
#crypto: Crypto
|
#crypto: Crypto
|
||||||
#data: EncryptedItem
|
#data: EncryptedItem
|
||||||
|
#overview?: Overview
|
||||||
|
#details?: ItemDetails
|
||||||
|
|
||||||
attachments: Attachment[] = []
|
attachments: Attachment[] = []
|
||||||
|
|
||||||
get uuid() {
|
// Unix milliseconds
|
||||||
|
get createdAt(): number {
|
||||||
|
return this.#data.created * 1000
|
||||||
|
}
|
||||||
|
get updatedAt(): number {
|
||||||
|
return this.#data.updated * 1000
|
||||||
|
}
|
||||||
|
get lastUsed() {
|
||||||
|
return this.#data.tx
|
||||||
|
}
|
||||||
|
get isDeleted() {
|
||||||
|
return this.#data.trashed
|
||||||
|
}
|
||||||
|
get category() {
|
||||||
|
return this.#data.category as Category
|
||||||
|
}
|
||||||
|
get uuid(): string {
|
||||||
return this.#data.uuid
|
return this.#data.uuid
|
||||||
}
|
}
|
||||||
get overview(): Overview {
|
get overview(): Overview {
|
||||||
return this.#crypto.decryptItemOverview(this.#data)
|
if (!this.#overview) {
|
||||||
|
throw new NotUnlockedError()
|
||||||
}
|
}
|
||||||
get itemDetails(): ItemDetails {
|
return this.#overview!
|
||||||
return this.#crypto.decryptItemDetails(this.#data)
|
}
|
||||||
|
get details(): ItemDetails {
|
||||||
|
if (!this.#details) {
|
||||||
|
throw new NotUnlockedError()
|
||||||
|
}
|
||||||
|
return this.#details!
|
||||||
|
}
|
||||||
|
get fave() {
|
||||||
|
return this.#data.fave
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(crypto: Crypto, data: EncryptedItem) {
|
constructor(crypto: Crypto, data: EncryptedItem) {
|
||||||
this.#crypto = crypto
|
this.#crypto = crypto
|
||||||
this.#data = data
|
this.#data = data
|
||||||
|
crypto.onLock(() => {
|
||||||
|
this.#overview = undefined!
|
||||||
|
this.#details = undefined!
|
||||||
|
this.attachments.forEach(file => file.dispose())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
addAttachment(buffer: Buffer) {
|
async _unlockOverview() {
|
||||||
|
this.#overview = await this.#crypto.decryptItemOverview(this.#data)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
async _unlockDetails() {
|
||||||
|
this.#details = await this.#crypto.decryptItemDetails(this.#data)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
async _unlock() {
|
||||||
|
await this._unlockOverview()
|
||||||
|
await this._unlockDetails()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
addAttachment(buffer: Uint8Array) {
|
||||||
this.attachments.push(new Attachment(this.#crypto, this.#data.k, buffer))
|
this.attachments.push(new Attachment(this.#crypto, this.#data.k, buffer))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import type { IAdapter } from "../adapters"
|
import type { Adapter } from "../adapter"
|
||||||
import { HMACAssertionError, invariant } from "../errors"
|
import { HMACAssertionError, OPVaultError, invariant } from "../errors"
|
||||||
import { OnePasswordFileManager } from "../fs"
|
import { OnePasswordFileManager } from "../fs"
|
||||||
import { i18n } from "../i18n"
|
|
||||||
import type { EncryptedItem } from "./item"
|
import type { EncryptedItem } from "./item"
|
||||||
import { Crypto } from "./crypto"
|
import { Crypto } from "../crypto"
|
||||||
import { Item } from "./item"
|
import { Item } from "./item"
|
||||||
import type { Profile } from "../types"
|
import type { Profile } from "../types"
|
||||||
import { WeakValueMap } from "../weakMap"
|
import { WeakValueMap } from "../weakMap"
|
||||||
@ -21,7 +20,7 @@ export class Vault {
|
|||||||
#itemsMap = new WeakValueMap<string, Item>()
|
#itemsMap = new WeakValueMap<string, Item>()
|
||||||
#crypto: Crypto
|
#crypto: Crypto
|
||||||
|
|
||||||
readonly onLock = createEventEmitter<void>()
|
readonly #onLock = createEventEmitter<void>()
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
profile: Profile,
|
profile: Profile,
|
||||||
@ -41,8 +40,8 @@ export class Vault {
|
|||||||
* Create a new OnePassword Vault instance and read all bands.
|
* Create a new OnePassword Vault instance and read all bands.
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
static async of(path: string, profileName = "default", adapter: IAdapter) {
|
static async of(path: string, profileName = "default", adapter: Adapter) {
|
||||||
const crypto = new Crypto(i18n, adapter)
|
const crypto = new Crypto(adapter)
|
||||||
const files = await OnePasswordFileManager(adapter.fs, path, profileName)
|
const files = await OnePasswordFileManager(adapter.fs, path, profileName)
|
||||||
const profile = JSON.parse(
|
const profile = JSON.parse(
|
||||||
stripText(await files.getProfile(), /^var profile\s*=/, ";")
|
stripText(await files.getProfile(), /^var profile\s*=/, ";")
|
||||||
@ -66,8 +65,7 @@ export class Vault {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachments = await files.getAttachments()
|
for await (const att of files.getAttachments()) {
|
||||||
for (const att of attachments) {
|
|
||||||
const file = itemsMap.get(att.itemUUID)
|
const file = itemsMap.get(att.itemUUID)
|
||||||
invariant(file, `Item ${att.itemUUID} of attachment does not exist`)
|
invariant(file, `Item ${att.itemUUID} of attachment does not exist`)
|
||||||
file.addAttachment(await att.getFile())
|
file.addAttachment(await att.getFile())
|
||||||
@ -76,6 +74,9 @@ export class Vault {
|
|||||||
return new Vault(profile, bands, crypto, itemsMap)
|
return new Vault(profile, bands, crypto, itemsMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the overview of an item given the `uuid`.
|
||||||
|
*/
|
||||||
getOverview(uuid: string) {
|
getOverview(uuid: string) {
|
||||||
this.#crypto.assertUnlocked()
|
this.#crypto.assertUnlocked()
|
||||||
return this.#items.find(x => x.uuid === uuid)?.overview
|
return this.#items.find(x => x.uuid === uuid)?.overview
|
||||||
@ -85,6 +86,13 @@ export class Vault {
|
|||||||
return this.#items.map(x => x.uuid)
|
return this.#items.map(x => x.uuid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async *values() {
|
||||||
|
this.#crypto.assertUnlocked()
|
||||||
|
for (const item of this.#items) {
|
||||||
|
yield await item._unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unlock this OnePassword vault.
|
* Unlock this OnePassword vault.
|
||||||
* @param masterPassword User provided master password. Only the derived
|
* @param masterPassword User provided master password. Only the derived
|
||||||
@ -95,7 +103,7 @@ export class Vault {
|
|||||||
await this.#crypto.unlock(this.#profile, masterPassword)
|
await this.#crypto.unlock(this.#profile, masterPassword)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof HMACAssertionError) {
|
if (e instanceof HMACAssertionError) {
|
||||||
throw new Error(i18n.error.invalidPassword)
|
throw new OPVaultError("Invalid password", "INVALID_PASSWORD")
|
||||||
}
|
}
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
@ -107,7 +115,7 @@ export class Vault {
|
|||||||
*/
|
*/
|
||||||
lock() {
|
lock() {
|
||||||
this.#crypto.lock()
|
this.#crypto.lock()
|
||||||
this.onLock()
|
this.#onLock()
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,17 +123,25 @@ export class Vault {
|
|||||||
return this.#crypto.locked
|
return this.#crypto.locked
|
||||||
}
|
}
|
||||||
|
|
||||||
getItem(uuid: string): Item | undefined
|
/**
|
||||||
getItem(filter: { title: string }): Item | undefined
|
* Returns the item with the given `uuid`
|
||||||
|
*/
|
||||||
|
getItem(uuid: string): Promise<Item | undefined>
|
||||||
|
|
||||||
getItem(filter: any) {
|
/**
|
||||||
|
* Returns the first item with the given title
|
||||||
|
*/
|
||||||
|
getItem(filter: { title: string }): Promise<Item | undefined>
|
||||||
|
|
||||||
|
async getItem(filter: any) {
|
||||||
this.#crypto.assertUnlocked()
|
this.#crypto.assertUnlocked()
|
||||||
if (typeof filter === "string") {
|
if (typeof filter === "string") {
|
||||||
return this.#itemsMap.get(filter)
|
return this.#itemsMap.get(filter)?._unlock()
|
||||||
} else {
|
} else {
|
||||||
for (const value of this.#items) {
|
for (const _value of this.#items) {
|
||||||
|
const value = await _value._unlockOverview()
|
||||||
if (value.overview.title === filter.title) {
|
if (value.overview.title === filter.title) {
|
||||||
return value
|
return value._unlockDetails()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,31 +23,65 @@ export type TextField = {
|
|||||||
value: string
|
value: string
|
||||||
designation: string
|
designation: string
|
||||||
name: string
|
name: string
|
||||||
|
id?: undefined
|
||||||
}
|
}
|
||||||
export type BooleanField = {
|
export type BooleanField = {
|
||||||
type: FieldType.Checkbox
|
type: FieldType.Checkbox
|
||||||
name: string
|
name: string
|
||||||
value?: "✓" | string
|
value?: "✓" | string
|
||||||
|
designation?: undefined
|
||||||
|
id?: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ItemField =
|
export type ItemField =
|
||||||
| TextField
|
| TextField
|
||||||
| BooleanField
|
| BooleanField
|
||||||
|
| {
|
||||||
|
name: string
|
||||||
|
designation: "username"
|
||||||
|
value: string
|
||||||
|
id?: undefined
|
||||||
|
type?: undefined
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: string
|
||||||
|
designation: "password"
|
||||||
|
value: string
|
||||||
|
id?: undefined
|
||||||
|
type?: undefined
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
// @TODO: This currently catches all item fields.
|
// @TODO: This currently catches all item fields.
|
||||||
type: FieldType
|
type: FieldType
|
||||||
value: string
|
value: string
|
||||||
designation?: string
|
designation?: string
|
||||||
|
id?: undefined
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
declare namespace ItemSection {
|
export namespace ItemSection {
|
||||||
type A = {
|
export type A = {
|
||||||
guarded: "yes"
|
guarded: "yes"
|
||||||
clipboardFilter?: string
|
clipboardFilter?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type String = {
|
export type Address = {
|
||||||
|
k: "address"
|
||||||
|
v: {
|
||||||
|
city: string
|
||||||
|
zip: string
|
||||||
|
state: string
|
||||||
|
country: string
|
||||||
|
street: string
|
||||||
|
}
|
||||||
|
n: "address"
|
||||||
|
a: {
|
||||||
|
guarded: "yes"
|
||||||
|
}
|
||||||
|
t: "address"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type String = {
|
||||||
k: "string"
|
k: "string"
|
||||||
v: string
|
v: string
|
||||||
/** Unique name */
|
/** Unique name */
|
||||||
@ -56,13 +90,13 @@ declare namespace ItemSection {
|
|||||||
/** User-readable title */
|
/** User-readable title */
|
||||||
t: string // "first name" | "initial" | "address" | "license class" | "conditions / restrictions" | "expiry date"
|
t: string // "first name" | "initial" | "address" | "license class" | "conditions / restrictions" | "expiry date"
|
||||||
}
|
}
|
||||||
type Menu = {
|
export type Menu = {
|
||||||
k: "menu"
|
k: "menu"
|
||||||
v: string // "female"
|
v: string // "female"
|
||||||
a: A
|
a: A
|
||||||
t: string // "sex"
|
t: string // "sex"
|
||||||
}
|
}
|
||||||
type Date = {
|
export type Date = {
|
||||||
k: "date"
|
k: "date"
|
||||||
v: number // 359100000
|
v: number // 359100000
|
||||||
/** @example "birthdate" */
|
/** @example "birthdate" */
|
||||||
@ -71,21 +105,21 @@ declare namespace ItemSection {
|
|||||||
/** @example "birth date" | "date of birth" */
|
/** @example "birth date" | "date of birth" */
|
||||||
t: string
|
t: string
|
||||||
}
|
}
|
||||||
type Gender = {
|
export type Gender = {
|
||||||
k: "gender"
|
k: "gender"
|
||||||
n: "sex"
|
n: "sex"
|
||||||
v: string // "female"
|
v: string // "female"
|
||||||
t: "sex"
|
t: "sex"
|
||||||
}
|
}
|
||||||
type MonthYear = {
|
export type MonthYear = {
|
||||||
k: "monthYear"
|
k: "monthYear"
|
||||||
n: string // "expiry_date"
|
n: string // "expiry_date"
|
||||||
v: number // 2515
|
v: number // 2515
|
||||||
t: string // "expiry date"
|
t: string // "expiry date"
|
||||||
}
|
}
|
||||||
type Concealed = {
|
export type Concealed = {
|
||||||
k: "concealed"
|
k: "concealed"
|
||||||
n: "password"
|
n: "password" | `TOTP_${string}`
|
||||||
v: string
|
v: string
|
||||||
a?: {
|
a?: {
|
||||||
generate: "off"
|
generate: "off"
|
||||||
@ -94,7 +128,7 @@ declare namespace ItemSection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
type Any = String | Menu | Date | Gender | MonthYear | Concealed
|
export type Any = String | Menu | Date | Gender | MonthYear | Concealed | Address
|
||||||
}
|
}
|
||||||
|
|
||||||
// One of them is empty?, 0C4F27910A64488BB339AED63565D148
|
// One of them is empty?, 0C4F27910A64488BB339AED63565D148
|
||||||
@ -105,7 +139,7 @@ export interface ItemDetails {
|
|||||||
htmlMethod: "post" | "get"
|
htmlMethod: "post" | "get"
|
||||||
}
|
}
|
||||||
notesPlain?: string
|
notesPlain?: string
|
||||||
sections: {
|
sections?: {
|
||||||
/** @example "name" | "title" | "internet" */
|
/** @example "name" | "title" | "internet" */
|
||||||
name: string
|
name: string
|
||||||
/** @example "Identification" | "Address" | "Internet Details" */
|
/** @example "Identification" | "Address" | "Internet Details" */
|
||||||
@ -114,6 +148,9 @@ export interface ItemDetails {
|
|||||||
}[]
|
}[]
|
||||||
/** Web form fields */
|
/** Web form fields */
|
||||||
fields?: ItemField[]
|
fields?: ItemField[]
|
||||||
|
/** Plain password items */
|
||||||
|
backupKeys?: string[]
|
||||||
|
password?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Folder {
|
export interface Folder {
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import { invariant } from "./errors"
|
|
||||||
|
|
||||||
export function asyncMap<T, R>(
|
export function asyncMap<T, R>(
|
||||||
list: T[],
|
list: T[],
|
||||||
fn: (value: T, index: number, list: T[]) => Promise<R>
|
fn: (value: T, index: number, list: T[]) => Promise<R>
|
||||||
@ -35,19 +33,3 @@ export function once<T extends (...args: any[]) => any>(fn: T): T {
|
|||||||
}
|
}
|
||||||
return res as any
|
return res as any
|
||||||
}
|
}
|
||||||
|
|
||||||
const cache = (): MethodDecorator => (_, key, descriptor: any) => {
|
|
||||||
if (process.env.NODE_ENV !== "production") {
|
|
||||||
invariant(typeof key === "string")
|
|
||||||
invariant(descriptor.get != null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheMap = new WeakMap()
|
|
||||||
const fn = descriptor.get
|
|
||||||
descriptor.get = function () {
|
|
||||||
if (!cacheMap.has(this)) {
|
|
||||||
cacheMap.set(this, fn.call(this))
|
|
||||||
}
|
|
||||||
return cacheMap.get(this)!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
3
packages/opvault.js/tsconfig.json
Normal file
3
packages/opvault.js/tsconfig.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json"
|
||||||
|
}
|
7
packages/web/.gitignore
vendored
Normal file
7
packages/web/.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
src/third-party-licenses.json
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
bundle
|
||||||
|
*.local
|
||||||
|
*.yml.d.ts
|
692
packages/web/LICENSE
Normal file
692
packages/web/LICENSE
Normal file
@ -0,0 +1,692 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright © 2007
|
||||||
|
aet
|
||||||
|
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies of this license
|
||||||
|
document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public
|
||||||
|
License is a free, copyleft license for software and other kinds of works.
|
||||||
|
|
||||||
|
The
|
||||||
|
licenses for most software and other practical works are designed to take away
|
||||||
|
your freedom to share and change the works. By contrast, the GNU General Public
|
||||||
|
License is intended to guarantee your freedom to share and change all versions of
|
||||||
|
a program--to make sure it remains free software for all its users. We, the Free
|
||||||
|
Software Foundation, use the GNU General Public License for most of our software;
|
||||||
|
it applies also to any other work released this way by its authors. You can apply
|
||||||
|
it to your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to
|
||||||
|
freedom, not price. Our General Public Licenses are designed to make sure that
|
||||||
|
you have the freedom to distribute copies of free software (and charge for them
|
||||||
|
if you wish), that you receive source code or can get it if you want it, that you
|
||||||
|
can change the software or use pieces of it in new free programs, and that you
|
||||||
|
know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others
|
||||||
|
from denying you these rights or asking you to surrender the rights. Therefore,
|
||||||
|
you have certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example,
|
||||||
|
if you distribute copies of such a program, whether gratis or for a fee, you must
|
||||||
|
pass on to the recipients the same freedoms that you received. You must make sure
|
||||||
|
that they, too, receive or can get the source code. And you must show them these
|
||||||
|
terms so they know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your
|
||||||
|
rights with two steps: (1) assert copyright on the software, and (2) offer you
|
||||||
|
this License giving you legal permission to copy, distribute and/or modify
|
||||||
|
it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains that
|
||||||
|
there is no warranty for this free software. For both users' and authors' sake,
|
||||||
|
the GPL requires that modified versions be marked as changed, so that their
|
||||||
|
problems will not be attributed erroneously to authors of previous
|
||||||
|
versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer can do
|
||||||
|
so. This is fundamentally incompatible with the aim of protecting users' freedom
|
||||||
|
to change the software. The systematic pattern of such abuse occurs in the area
|
||||||
|
of products for individuals to use, which is precisely where it is most
|
||||||
|
unacceptable. Therefore, we have designed this version of the GPL to prohibit the
|
||||||
|
practice for those products. If such problems arise substantially in other
|
||||||
|
domains, we stand ready to extend this provision to those domains in future
|
||||||
|
versions of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every
|
||||||
|
program is threatened constantly by software patents. States should not allow
|
||||||
|
patents to restrict development and use of software on general-purpose computers,
|
||||||
|
but in those that do, we wish to avoid the special danger that patents applied to
|
||||||
|
a free program could make it effectively proprietary. To prevent this, the GPL
|
||||||
|
assures that patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise
|
||||||
|
terms and conditions for copying, distribution and modification follow.
|
||||||
|
|
||||||
|
TERMS
|
||||||
|
AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the
|
||||||
|
GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that
|
||||||
|
apply to other kinds of works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program"
|
||||||
|
refers to any copyrightable work licensed under this License. Each licensee is
|
||||||
|
addressed as "you". "Licensees" and "recipients" may be individuals or
|
||||||
|
organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of
|
||||||
|
the work in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the earlier work
|
||||||
|
or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the
|
||||||
|
unmodified Program or a work based on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work
|
||||||
|
means to do anything with it that, without permission, would make you directly or
|
||||||
|
secondarily liable for infringement under applicable copyright law, except
|
||||||
|
executing it on a computer or modifying a private copy. Propagation includes
|
||||||
|
copying, distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work
|
||||||
|
means any kind of propagation that enables other parties to make or receive
|
||||||
|
copies. Mere interaction with a user through a computer network, with no transfer
|
||||||
|
of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays
|
||||||
|
"Appropriate Legal Notices" to the extent that it includes a convenient and
|
||||||
|
prominently visible feature that (1) displays an appropriate copyright notice,
|
||||||
|
and (2) tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the work under
|
||||||
|
this License, and how to view a copy of this License. If the interface presents a
|
||||||
|
list of user commands or options, such as a menu, a prominent item in the list
|
||||||
|
meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means
|
||||||
|
the preferred form of the work for making modifications to it. "Object code"
|
||||||
|
means any non-source form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an
|
||||||
|
interface that either is an official standard defined by a recognized standards
|
||||||
|
body, or, in the case of interfaces specified for a particular programming
|
||||||
|
language, one that is widely used among developers working in that language.
|
||||||
|
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other than the
|
||||||
|
work as a whole, that (a) is included in the normal form of packaging a Major
|
||||||
|
Component, but which is not part of that Major Component, and (b) serves only to
|
||||||
|
enable use of the work with that Major Component, or to implement a Standard
|
||||||
|
Interface for which an implementation is available to the public in source code
|
||||||
|
form. A "Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system (if any) on
|
||||||
|
which the executable work runs, or a compiler used to produce the work, or an
|
||||||
|
object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work
|
||||||
|
in object code form means all the source code needed to generate, install, and
|
||||||
|
(for an executable work) run the object code and to modify the work, including
|
||||||
|
scripts to control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free programs
|
||||||
|
which are used unmodified in performing those activities but which are not part
|
||||||
|
of the work. For example, Corresponding Source includes interface definition
|
||||||
|
files associated with source files for the work, and the source code for shared
|
||||||
|
libraries and dynamically linked subprograms that the work is specifically
|
||||||
|
designed to require, such as by intimate data communication or control flow
|
||||||
|
between those subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding
|
||||||
|
Source need not include anything that users can regenerate automatically from
|
||||||
|
other parts of the Corresponding Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work
|
||||||
|
in source code form is that same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights
|
||||||
|
granted under this License are granted for the term of copyright on the Program,
|
||||||
|
and are irrevocable provided the stated conditions are met. This License
|
||||||
|
explicitly affirms your unlimited permission to run the unmodified Program. The
|
||||||
|
output from running a covered work is covered by this License only if the output,
|
||||||
|
given its content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may
|
||||||
|
make, run and propagate covered works that you do not convey, without conditions
|
||||||
|
so long as your license otherwise remains in force. You may convey covered works
|
||||||
|
to others for the sole purpose of having them make modifications exclusively for
|
||||||
|
you, or provide you with facilities for running those works, provided that you
|
||||||
|
comply with the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works for you
|
||||||
|
must do so exclusively on your behalf, under your direction and control, on terms
|
||||||
|
that prohibit them from making any copies of your copyrighted material outside
|
||||||
|
their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is
|
||||||
|
permitted solely under the conditions stated below. Sublicensing is not allowed;
|
||||||
|
section 10 makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From
|
||||||
|
Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective
|
||||||
|
technological measure under any applicable law fulfilling obligations under
|
||||||
|
article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar
|
||||||
|
laws prohibiting or restricting circumvention of such measures.
|
||||||
|
|
||||||
|
When you
|
||||||
|
convey a covered work, you waive any legal power to forbid circumvention of
|
||||||
|
technological measures to the extent such circumvention is effected by exercising
|
||||||
|
rights under this License with respect to the covered work, and you disclaim any
|
||||||
|
intention to limit operation or modification of the work as a means of enforcing,
|
||||||
|
against the work's users, your or third parties' legal rights to forbid
|
||||||
|
circumvention of technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you receive it, in
|
||||||
|
any medium, provided that you conspicuously and appropriately publish on each
|
||||||
|
copy an appropriate copyright notice; keep intact all notices stating that this
|
||||||
|
License and any non-permissive terms added in accord with section 7 apply to the
|
||||||
|
code; keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any
|
||||||
|
price or no price for each copy that you convey, and you may offer support or
|
||||||
|
warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You
|
||||||
|
may convey a work based on the Program, or the modifications to produce it from
|
||||||
|
the Program, in the form of source code under the terms of section 4, provided
|
||||||
|
that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry
|
||||||
|
prominent notices stating that you modified it, and giving a relevant date.
|
||||||
|
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is released under this
|
||||||
|
License and any conditions added under section 7. This requirement modifies the
|
||||||
|
requirement in section 4 to "keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license
|
||||||
|
the entire work, as a whole, under this License to anyone who comes into
|
||||||
|
possession of a copy. This License will therefore apply, along with any
|
||||||
|
applicable section 7 additional terms, to the whole of the work, and all its
|
||||||
|
parts, regardless of how they are packaged. This License gives no permission to
|
||||||
|
license the work in any other way, but it does not invalidate such permission if
|
||||||
|
you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user
|
||||||
|
interfaces, each must display Appropriate Legal Notices; however, if the Program
|
||||||
|
has interactive interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other
|
||||||
|
separate and independent works, which are not by their nature extensions of the
|
||||||
|
covered work, and which are not combined with it such as to form a larger
|
||||||
|
program, in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not used to limit
|
||||||
|
the access or legal rights of the compilation's users beyond what the individual
|
||||||
|
works permit. Inclusion of a covered work in an aggregate does not cause this
|
||||||
|
License to apply to the other parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source
|
||||||
|
Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms of
|
||||||
|
sections 4 and 5, provided that you also convey the machine-readable
|
||||||
|
Corresponding Source under the terms of this License, in one of these ways:
|
||||||
|
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product (including a
|
||||||
|
physical distribution medium), accompanied by the Corresponding Source fixed on a
|
||||||
|
durable physical medium customarily used for software interchange.
|
||||||
|
|
||||||
|
b)
|
||||||
|
Convey the object code in, or embodied in, a physical product (including a
|
||||||
|
physical distribution medium), accompanied by a written offer, valid for at least
|
||||||
|
three years and valid for as long as you offer spare parts or customer support
|
||||||
|
for that product model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the product that is
|
||||||
|
covered by this License, on a durable physical medium customarily used for
|
||||||
|
software interchange, for a price no more than your reasonable cost of physically
|
||||||
|
performing this conveying of source, or (2) access to copy the Corresponding
|
||||||
|
Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of
|
||||||
|
the object code with a copy of the written offer to provide the Corresponding
|
||||||
|
Source. This alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord with
|
||||||
|
subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a
|
||||||
|
designated place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no further charge.
|
||||||
|
You need not require recipients to copy the Corresponding Source along with the
|
||||||
|
object code. If the place to copy the object code is a network server, the
|
||||||
|
Corresponding Source may be on a different server (operated by you or a third
|
||||||
|
party) that supports equivalent copying facilities, provided you maintain clear
|
||||||
|
directions next to the object code saying where to find the Corresponding Source.
|
||||||
|
Regardless of what server hosts the Corresponding Source, you remain obligated to
|
||||||
|
ensure that it is available for as long as needed to satisfy these
|
||||||
|
requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission,
|
||||||
|
provided you inform other peers where the object code and Corresponding Source of
|
||||||
|
the work are being offered to the general public at no charge under subsection
|
||||||
|
6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be included in
|
||||||
|
conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer
|
||||||
|
product", which means any tangible personal property which is normally used for
|
||||||
|
personal, family, or household purposes, or (2) anything designed or sold for
|
||||||
|
incorporation into a dwelling. In determining whether a product is a consumer
|
||||||
|
product, doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a typical or
|
||||||
|
common use of that class of product, regardless of the status of the particular
|
||||||
|
user or of the way in which the particular user actually uses, or expects or is
|
||||||
|
expected to use, the product. A product is a consumer product regardless of
|
||||||
|
whether the product has substantial commercial, industrial or non-consumer uses,
|
||||||
|
unless such uses represent the only significant mode of use of the product.
|
||||||
|
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods, procedures,
|
||||||
|
authorization keys, or other information required to install and execute modified
|
||||||
|
versions of a covered work in that User Product from a modified version of its
|
||||||
|
Corresponding Source. The information must suffice to ensure that the continued
|
||||||
|
functioning of the modified object code is in no case prevented or interfered
|
||||||
|
with solely because modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code
|
||||||
|
work under this section in, or with, or specifically for use in, a User Product,
|
||||||
|
and the conveying occurs as part of a transaction in which the right of
|
||||||
|
possession and use of the User Product is transferred to the recipient in
|
||||||
|
perpetuity or for a fixed term (regardless of how the transaction is
|
||||||
|
characterized), the Corresponding Source conveyed under this section must be
|
||||||
|
accompanied by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install modified object
|
||||||
|
code on the User Product (for example, the work has been installed in ROM).
|
||||||
|
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates for a
|
||||||
|
work that has been modified or installed by the recipient, or for the User
|
||||||
|
Product in which it has been modified or installed. Access to a network may be
|
||||||
|
denied when the modification itself materially and adversely affects the
|
||||||
|
operation of the network or violates the rules and protocols for communication
|
||||||
|
across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation
|
||||||
|
Information provided, in accord with this section must be in a format that is
|
||||||
|
publicly documented (and with an implementation available to the public in source
|
||||||
|
code form), and must require no special password or key for unpacking, reading or
|
||||||
|
copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that
|
||||||
|
supplement the terms of this License by making exceptions from one or more of its
|
||||||
|
conditions. Additional permissions that are applicable to the entire Program
|
||||||
|
shall be treated as though they were included in this License, to the extent that
|
||||||
|
they are valid under applicable law. If additional permissions apply only to part
|
||||||
|
of the Program, that part may be used separately under those permissions, but the
|
||||||
|
entire Program remains governed by this License without regard to the additional
|
||||||
|
permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of it.
|
||||||
|
(Additional permissions may be written to require their own removal in certain
|
||||||
|
cases when you modify the work.) You may place additional permissions on
|
||||||
|
material, added by you to a covered work, for which you have or can give
|
||||||
|
appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this
|
||||||
|
License, for material you add to a covered work, you may (if authorized by the
|
||||||
|
copyright holders of that material) supplement the terms of this License with
|
||||||
|
terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation
|
||||||
|
of specified reasonable legal notices or author attributions in that material or
|
||||||
|
in the Appropriate Legal Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c)
|
||||||
|
Prohibiting misrepresentation of the origin of that material, or requiring that
|
||||||
|
modified versions of such material be marked in reasonable ways as different from
|
||||||
|
the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of
|
||||||
|
names of licensors or authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant
|
||||||
|
rights under trademark law for use of some trade names, trademarks, or service
|
||||||
|
marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of it) with
|
||||||
|
contractual assumptions of liability to the recipient, for any liability that
|
||||||
|
these contractual assumptions directly impose on those licensors and authors.
|
||||||
|
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further restrictions"
|
||||||
|
within the meaning of section 10. If the Program as you received it, or any part
|
||||||
|
of it, contains a notice stating that it is governed by this License along with a
|
||||||
|
term that is a further restriction, you may remove that term. If a license
|
||||||
|
document contains a further restriction but permits relicensing or conveying
|
||||||
|
under this License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does not survive
|
||||||
|
such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord
|
||||||
|
with this section, you must place, in the relevant source files, a statement of
|
||||||
|
the additional terms that apply to those files, or a notice indicating where to
|
||||||
|
find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive,
|
||||||
|
may be stated in the form of a separately written license, or stated as
|
||||||
|
exceptions; the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You
|
||||||
|
may not propagate or modify a covered work except as expressly provided under
|
||||||
|
this License. Any attempt otherwise to propagate or modify it is void, and will
|
||||||
|
automatically terminate your rights under this License (including any patent
|
||||||
|
licenses granted under the third paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you
|
||||||
|
cease all violation of this License, then your license from a particular
|
||||||
|
copyright holder is reinstated (a) provisionally, unless and until the copyright
|
||||||
|
holder explicitly and finally terminates your license, and (b) permanently, if
|
||||||
|
the copyright holder fails to notify you of the violation by some reasonable
|
||||||
|
means prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a
|
||||||
|
particular copyright holder is reinstated permanently if the copyright holder
|
||||||
|
notifies you of the violation by some reasonable means, this is the first time
|
||||||
|
you have received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after your receipt
|
||||||
|
of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not
|
||||||
|
terminate the licenses of parties who have received copies or rights from you
|
||||||
|
under this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same material
|
||||||
|
under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are
|
||||||
|
not required to accept this License in order to receive or run a copy of the
|
||||||
|
Program. Ancillary propagation of a covered work occurring solely as a
|
||||||
|
consequence of using peer-to-peer transmission to receive a copy likewise does
|
||||||
|
not require acceptance. However, nothing other than this License grants you
|
||||||
|
permission to propagate or modify any covered work. These actions infringe
|
||||||
|
copyright if you do not accept this License. Therefore, by modifying or
|
||||||
|
propagating a covered work, you indicate your acceptance of this License to do
|
||||||
|
so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you
|
||||||
|
convey a covered work, the recipient automatically receives a license from the
|
||||||
|
original licensors, to run, modify and propagate that work, subject to this
|
||||||
|
License. You are not responsible for enforcing compliance by third parties with
|
||||||
|
this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control
|
||||||
|
of an organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered work results
|
||||||
|
from an entity transaction, each party to that transaction who receives a copy of
|
||||||
|
the work also receives whatever licenses to the work the party's predecessor in
|
||||||
|
interest had or could give under the previous paragraph, plus a right to
|
||||||
|
possession of the Corresponding Source of the work from the predecessor in
|
||||||
|
interest, if the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the rights granted
|
||||||
|
or affirmed under this License. For example, you may not impose a license fee,
|
||||||
|
royalty, or other charge for exercise of rights granted under this License, and
|
||||||
|
you may not initiate litigation (including a cross-claim or counterclaim in a
|
||||||
|
lawsuit) alleging that any patent claim is infringed by making, using, selling,
|
||||||
|
offering for sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11.
|
||||||
|
Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The work thus
|
||||||
|
licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's
|
||||||
|
"essential patent claims" are all patent claims owned or controlled by the
|
||||||
|
contributor, whether already acquired or hereafter acquired, that would be
|
||||||
|
infringed by some manner, permitted by this License, of making, using, or selling
|
||||||
|
its contributor version, but do not include claims that would be infringed only
|
||||||
|
as a consequence of further modification of the contributor version. For purposes
|
||||||
|
of this definition, "control" includes the right to grant patent sublicenses in a
|
||||||
|
manner consistent with the requirements of this License.
|
||||||
|
|
||||||
|
Each contributor
|
||||||
|
grants you a non-exclusive, worldwide, royalty-free patent license under the
|
||||||
|
contributor's essential patent claims, to make, use, sell, offer for sale, import
|
||||||
|
and otherwise run, modify and propagate the contents of its contributor
|
||||||
|
version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent (such as an
|
||||||
|
express permission to practice a patent or covenant not to sue for patent
|
||||||
|
infringement). To "grant" such a patent license to a party means to make such an
|
||||||
|
agreement or commitment not to enforce a patent against the party.
|
||||||
|
|
||||||
|
If you
|
||||||
|
convey a covered work, knowingly relying on a patent license, and the
|
||||||
|
Corresponding Source of the work is not available for anyone to copy, free of
|
||||||
|
charge and under the terms of this License, through a publicly available network
|
||||||
|
server or other readily accessible means, then you must either (1) cause the
|
||||||
|
Corresponding Source to be so available, or (2) arrange to deprive yourself of
|
||||||
|
the benefit of the patent license for this particular work, or (3) arrange, in a
|
||||||
|
manner consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have actual
|
||||||
|
knowledge that, but for the patent license, your conveying the covered work in a
|
||||||
|
country, or your recipient's use of the covered work in a country, would infringe
|
||||||
|
one or more identifiable patents in that country that you have reason to believe
|
||||||
|
are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a covered work,
|
||||||
|
and grant a patent license to some of the parties receiving the covered work
|
||||||
|
authorizing them to use, propagate, modify or convey a specific copy of the
|
||||||
|
covered work, then the patent license you grant is automatically extended to all
|
||||||
|
recipients of the covered work and works based on it.
|
||||||
|
|
||||||
|
A patent license is
|
||||||
|
"discriminatory" if it does not include within the scope of its coverage,
|
||||||
|
prohibits the exercise of, or is conditioned on the non-exercise of one or more
|
||||||
|
of the rights that are specifically granted under this License. You may not
|
||||||
|
convey a covered work if you are a party to an arrangement with a third party
|
||||||
|
that is in the business of distributing software, under which you make payment to
|
||||||
|
the third party based on the extent of your activity of conveying the work, and
|
||||||
|
under which the third party grants, to any of the parties who would receive the
|
||||||
|
covered work from you, a discriminatory patent license (a) in connection with
|
||||||
|
copies of the covered work conveyed by you (or copies made from those copies), or
|
||||||
|
(b) primarily for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement, or that
|
||||||
|
patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License
|
||||||
|
shall be construed as excluding or limiting any implied license or other defenses
|
||||||
|
to infringement that may otherwise be available to you under applicable patent
|
||||||
|
law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on
|
||||||
|
you (whether by court order, agreement or otherwise) that contradict the
|
||||||
|
conditions of this License, they do not excuse you from the conditions of this
|
||||||
|
License. If you cannot convey a covered work so as to satisfy simultaneously your
|
||||||
|
obligations under this License and any other pertinent obligations, then as a
|
||||||
|
consequence you may not convey it at all. For example, if you agree to terms that
|
||||||
|
obligate you to collect a royalty for further conveying from those to whom you
|
||||||
|
convey the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with
|
||||||
|
the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of
|
||||||
|
this License, you have permission to link or combine any covered work with a work
|
||||||
|
licensed under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this License will
|
||||||
|
continue to apply to the part which is the covered work, but the special
|
||||||
|
requirements of the GNU Affero General Public License, section 13, concerning
|
||||||
|
interaction through a network will apply to the combination as such.
|
||||||
|
|
||||||
|
14.
|
||||||
|
Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish
|
||||||
|
revised and/or new versions of the GNU General Public License from time to time.
|
||||||
|
Such new versions will be similar in spirit to the present version, but may
|
||||||
|
differ in detail to address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a
|
||||||
|
distinguishing version number. If the Program specifies that a certain numbered
|
||||||
|
version of the GNU General Public License "or any later version" applies to it,
|
||||||
|
you have the option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software Foundation. If the
|
||||||
|
Program does not specify a version number of the GNU General Public License, you
|
||||||
|
may choose any version ever published by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the
|
||||||
|
Program specifies that a proxy can decide which future versions of the GNU
|
||||||
|
General Public License can be used, that proxy's public statement of acceptance
|
||||||
|
of a version permanently authorizes you to choose that version for the Program.
|
||||||
|
|
||||||
|
|
||||||
|
Later license versions may give you additional or different permissions. However,
|
||||||
|
no additional obligations are imposed on any author or copyright holder as a
|
||||||
|
result of your choosing to follow a later version.
|
||||||
|
|
||||||
|
15. Disclaimer of
|
||||||
|
Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS
|
||||||
|
AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||||
|
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE
|
||||||
|
RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||||
|
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR
|
||||||
|
OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED
|
||||||
|
BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER
|
||||||
|
PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO
|
||||||
|
YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL
|
||||||
|
DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT
|
||||||
|
LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||||
|
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||||
|
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY
|
||||||
|
OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the
|
||||||
|
disclaimer of warranty and limitation of liability provided above cannot be given
|
||||||
|
local legal effect according to their terms, reviewing courts shall apply local
|
||||||
|
law that most closely approximates an absolute waiver of all civil liability in
|
||||||
|
connection with the Program, unless a warranty or assumption of liability
|
||||||
|
accompanies a copy of the Program in return for a fee. END OF TERMS AND
|
||||||
|
CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new
|
||||||
|
program, and you want it to be of the greatest possible use to the public, the
|
||||||
|
best way to achieve this is to make it free software which everyone can
|
||||||
|
redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following
|
||||||
|
notices to the program. It is safest to attach them to the start of each source
|
||||||
|
file to most effectively state the exclusion of warranty; and each file should
|
||||||
|
have at least the "copyright" line and a pointer to where the full notice is
|
||||||
|
found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it
|
||||||
|
does.>
|
||||||
|
|
||||||
|
Copyright (C) 2022 <name of author>
|
||||||
|
|
||||||
|
This program is free software: you
|
||||||
|
can redistribute it and/or modify it under the terms of the GNU General Public
|
||||||
|
License as published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in
|
||||||
|
the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the
|
||||||
|
implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of
|
||||||
|
the GNU General Public License along with this program. If not, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by
|
||||||
|
electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it
|
||||||
|
output a short notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program>
|
||||||
|
Copyright (C) 2022 <name of author>
|
||||||
|
|
||||||
|
This program comes with ABSOLUTELY NO
|
||||||
|
WARRANTY; for details type `show w'.
|
||||||
|
|
||||||
|
This is free software, and you are welcome
|
||||||
|
to redistribute it under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The
|
||||||
|
hypothetical commands `show w' and `show c' should show the appropriate parts of
|
||||||
|
the General Public License. Of course, your program's commands might be
|
||||||
|
different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also
|
||||||
|
get your employer (if you work as a programmer) or school, if any, to sign a
|
||||||
|
"copyright disclaimer" for the program, if necessary. For more information on
|
||||||
|
this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit
|
||||||
|
incorporating your program into proprietary programs. If your program is a
|
||||||
|
subroutine library, you may consider it more useful to permit linking proprietary
|
||||||
|
applications with the library. If this is what you want to do, use the GNU Lesser
|
||||||
|
General Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/ licenses /why-not-lgpl.html>.
|
29
packages/web/electron-builder.yml
Normal file
29
packages/web/electron-builder.yml
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# yaml-language-server: $schema=https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json
|
||||||
|
|
||||||
|
appId: com.proteria.opvault
|
||||||
|
productName: OPVault Viewer
|
||||||
|
files:
|
||||||
|
- "**/*"
|
||||||
|
icon: dist/512x512.png
|
||||||
|
directories:
|
||||||
|
output: bundle
|
||||||
|
app: dist
|
||||||
|
buildResources: dist
|
||||||
|
mac:
|
||||||
|
category: public.app-category.productivity
|
||||||
|
target:
|
||||||
|
target: dir
|
||||||
|
arch:
|
||||||
|
- x64
|
||||||
|
- arm64
|
||||||
|
darkModeSupport: true
|
||||||
|
linux:
|
||||||
|
executableName: opvault
|
||||||
|
category: Utility
|
||||||
|
icon: 512x512.png
|
||||||
|
artifactName: ${productName}-${version}-${arch}.${ext}
|
||||||
|
target:
|
||||||
|
target: AppImage
|
||||||
|
arch:
|
||||||
|
- x64
|
||||||
|
- arm64
|
30
packages/web/esbuild.js
Executable file
30
packages/web/esbuild.js
Executable file
@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// @ts-check
|
||||||
|
const { builtinModules } = require("module")
|
||||||
|
const { build } = require("esbuild")
|
||||||
|
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
|
||||||
|
build({
|
||||||
|
bundle: true,
|
||||||
|
define: {},
|
||||||
|
entryPoints: ["./src/electron/index.ts", "./src/electron/preload.ts"],
|
||||||
|
outdir: "./dist/main",
|
||||||
|
external: builtinModules.concat("electron"),
|
||||||
|
target: ["chrome96"],
|
||||||
|
tsconfig: "./tsconfig.json",
|
||||||
|
sourcemap: "external",
|
||||||
|
minify: process.env.NODE_ENV === "production",
|
||||||
|
banner: {
|
||||||
|
js: "/* eslint-disable */",
|
||||||
|
},
|
||||||
|
loader: {
|
||||||
|
".png": "file",
|
||||||
|
".eot": "file",
|
||||||
|
".svg": "file",
|
||||||
|
".woff": "file",
|
||||||
|
".woff2": "file",
|
||||||
|
".ttf": "file",
|
||||||
|
},
|
||||||
|
watch: args.includes("-w") || args.includes("--watch"),
|
||||||
|
})
|
17
packages/web/index.html
Normal file
17
packages/web/index.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
|
||||||
|
/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>OPVault Viewer</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
263
packages/web/logo.svg
Normal file
263
packages/web/logo.svg
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
viewBox="0 0 128 128"
|
||||||
|
version="1.0"
|
||||||
|
id="svg11300"
|
||||||
|
height="128"
|
||||||
|
width="128"
|
||||||
|
>
|
||||||
|
<style>
|
||||||
|
.card-rect {
|
||||||
|
/* fill: #2ec27e; */
|
||||||
|
fill: #496ccf;
|
||||||
|
}
|
||||||
|
.barcode-1,
|
||||||
|
.barcode-2,
|
||||||
|
.barcode-3 {
|
||||||
|
/* stroke: #26a269; */
|
||||||
|
stroke: #7b95e1;z
|
||||||
|
}
|
||||||
|
#path1138 {
|
||||||
|
fill: #7b95e1;z
|
||||||
|
}
|
||||||
|
.barcode-1 {
|
||||||
|
stroke-width: 1.87082875;
|
||||||
|
}
|
||||||
|
.barcode-2 {
|
||||||
|
stroke-width: 3.7416575;
|
||||||
|
}
|
||||||
|
.barcode-3 {
|
||||||
|
stroke-width: 5.61248589;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<title id="title4162">Adwaita Icon Template</title>
|
||||||
|
<defs id="defs3">
|
||||||
|
<linearGradient id="linearGradient1296">
|
||||||
|
<stop id="stop1292" offset="0" style="stop-color: #77767b; stop-opacity: 1" />
|
||||||
|
<stop
|
||||||
|
style="stop-color: #c0bfbc; stop-opacity: 1"
|
||||||
|
offset="0.17589436"
|
||||||
|
id="stop1300"
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
id="stop1302"
|
||||||
|
offset="0.4092612"
|
||||||
|
style="stop-color: #77767b; stop-opacity: 1"
|
||||||
|
/>
|
||||||
|
<stop id="stop1294" offset="1" style="stop-color: #3d3846; stop-opacity: 1" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="linearGradient969">
|
||||||
|
<stop style="stop-color: #f6f5f4; stop-opacity: 1" offset="0" id="stop963" />
|
||||||
|
<stop
|
||||||
|
id="stop965"
|
||||||
|
offset="0.25731823"
|
||||||
|
style="stop-color: #ffffff; stop-opacity: 1"
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
style="stop-color: #c0bfbc; stop-opacity: 1"
|
||||||
|
offset="0.5999999"
|
||||||
|
id="stop1085"
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
id="stop1087"
|
||||||
|
offset="0.70312482"
|
||||||
|
style="stop-color: #f6f5f4; stop-opacity: 1"
|
||||||
|
/>
|
||||||
|
<stop style="stop-color: #f6f5f4; stop-opacity: 1" offset="1" id="stop967" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="linearGradient1040">
|
||||||
|
<stop style="stop-color: #c0bfbc; stop-opacity: 1" offset="0" id="stop1036" />
|
||||||
|
<stop style="stop-color: #f6f5f4; stop-opacity: 1" offset="1" id="stop1038" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
y2="249.87819"
|
||||||
|
x2="67.121834"
|
||||||
|
y1="238.30762"
|
||||||
|
x1="78.692398"
|
||||||
|
gradientTransform="translate(55.100502, 0.07106726)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="linearGradient1986"
|
||||||
|
xlink:href="#linearGradient969"
|
||||||
|
/>
|
||||||
|
<linearGradient
|
||||||
|
y2="70.300697"
|
||||||
|
x2="85.886963"
|
||||||
|
y1="67.679771"
|
||||||
|
x1="88.507896"
|
||||||
|
gradientTransform="translate(55.769701, 171.28412)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
id="linearGradient1988"
|
||||||
|
xlink:href="#linearGradient1040"
|
||||||
|
/>
|
||||||
|
<linearGradient
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
y2="268"
|
||||||
|
x2="198"
|
||||||
|
y1="268"
|
||||||
|
x1="142"
|
||||||
|
id="linearGradient1039"
|
||||||
|
xlink:href="#linearGradient1296"
|
||||||
|
/>
|
||||||
|
</defs>
|
||||||
|
<metadata id="metadata4">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
<dc:creator>
|
||||||
|
<cc:Agent>
|
||||||
|
<dc:title>GNOME Design Team</dc:title>
|
||||||
|
</cc:Agent>
|
||||||
|
</dc:creator>
|
||||||
|
<dc:source />
|
||||||
|
<cc:license rdf:resource="http://creativecommons.org/licenses/by-sa/4.0/" />
|
||||||
|
<dc:title>Adwaita Icon Template</dc:title>
|
||||||
|
<dc:subject>
|
||||||
|
<rdf:Bag />
|
||||||
|
</dc:subject>
|
||||||
|
<dc:date />
|
||||||
|
<dc:rights>
|
||||||
|
<cc:Agent>
|
||||||
|
<dc:title />
|
||||||
|
</cc:Agent>
|
||||||
|
</dc:rights>
|
||||||
|
<dc:publisher>
|
||||||
|
<cc:Agent>
|
||||||
|
<dc:title />
|
||||||
|
</cc:Agent>
|
||||||
|
</dc:publisher>
|
||||||
|
<dc:identifier />
|
||||||
|
<dc:relation />
|
||||||
|
<dc:language />
|
||||||
|
<dc:coverage />
|
||||||
|
<dc:description />
|
||||||
|
<dc:contributor>
|
||||||
|
<cc:Agent>
|
||||||
|
<dc:title />
|
||||||
|
</cc:Agent>
|
||||||
|
</dc:contributor>
|
||||||
|
</cc:Work>
|
||||||
|
<cc:License rdf:about="http://creativecommons.org/licenses/by-sa/4.0/">
|
||||||
|
<cc:permits rdf:resource="http://creativecommons.org/ns#Reproduction" />
|
||||||
|
<cc:permits rdf:resource="http://creativecommons.org/ns#Distribution" />
|
||||||
|
<cc:requires rdf:resource="http://creativecommons.org/ns#Notice" />
|
||||||
|
<cc:requires rdf:resource="http://creativecommons.org/ns#Attribution" />
|
||||||
|
<cc:permits rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
|
||||||
|
<cc:requires rdf:resource="http://creativecommons.org/ns#ShareAlike" />
|
||||||
|
</cc:License>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<g transform="translate(0, -172)" id="layer1">
|
||||||
|
<g id="layer9">
|
||||||
|
<rect
|
||||||
|
class="card-rect"
|
||||||
|
id="rect1027"
|
||||||
|
width="112"
|
||||||
|
height="63.999977"
|
||||||
|
x="8"
|
||||||
|
y="224"
|
||||||
|
rx="8"
|
||||||
|
ry="8"
|
||||||
|
/>
|
||||||
|
<g id="g1256" transform="matrix(1,0,0,1.1428571,-4.8522949e-8,-22.857143)">
|
||||||
|
<path class="barcode-1" d="m 27,230 v 14" id="path1164" />
|
||||||
|
<path class="barcode-2" d="m 32,230 v 14" id="path1166" />
|
||||||
|
<path class="barcode-1" d="m 37,230 v 14" id="path1168" />
|
||||||
|
<path class="barcode-1" d="m 41,230 v 14" id="path1170" />
|
||||||
|
<path class="barcode-2" d="m 46,230 v 14" id="path1172" />
|
||||||
|
<path class="barcode-2" d="m 56,230 v 14" id="path1174" />
|
||||||
|
<path class="barcode-1" d="m 51,230 v 14" id="path1176" />
|
||||||
|
<path class="barcode-3" d="m 63,230 v 14" id="path1178" />
|
||||||
|
<path class="barcode-1" d="m 69,230 v 14" id="path1180" />
|
||||||
|
<path class="barcode-1" d="m 73,230 v 14" id="path1182" />
|
||||||
|
<path class="barcode-1" d="m 77,230 v 14" id="path1184" />
|
||||||
|
<path class="barcode-2" d="m 82,230 v 14" id="path1186" />
|
||||||
|
<path class="barcode-1" d="m 87,230 v 14" id="path1188" />
|
||||||
|
<path class="barcode-1" d="m 99,230 v 14" id="path1190" />
|
||||||
|
<path class="barcode-3" d="m 93,230 v 14" id="path1192" />
|
||||||
|
<path class="barcode-1" d="m 103,230 v 14" id="path1194" />
|
||||||
|
<path class="barcode-1" d="m 107,230 v 14" id="path1196" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
style="display: inline; fill: #f8faff; enable-background: new"
|
||||||
|
id="g1130"
|
||||||
|
transform="translate(-4.8522949e-8,12)"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m 22.21582,254 1.197265,5.06836 -3.90039,-3.2793 -1.453125,2.5918 5.009765,1.6582 -5.042968,1.58985 1.433593,2.64062 3.955078,-3.30469 L 22.21582,266 h 3.410156 l -1.101562,-4.95898 3.833984,3.31445 1.476562,-2.61914 -4.978515,-1.68555 5.117187,-1.42578 -1.453125,-2.61914 -4.033203,3.08398 L 25.624023,254 Z"
|
||||||
|
id="path1940"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
id="path1056"
|
||||||
|
d="m 38.21582,254 1.197265,5.06836 -3.90039,-3.2793 -1.453125,2.5918 5.009765,1.6582 -5.042968,1.58985 1.433593,2.64062 3.955078,-3.30469 L 38.21582,266 h 3.410156 l -1.101562,-4.95898 3.833984,3.31445 1.476562,-2.61914 -4.978515,-1.68555 5.117187,-1.42578 -1.453125,-2.61914 -4.033203,3.08398 L 41.624023,254 Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m 54.21582,254 1.197265,5.06836 -3.90039,-3.2793 -1.453125,2.5918 5.009765,1.6582 -5.042968,1.58985 1.433593,2.64062 3.955078,-3.30469 L 54.21582,266 h 3.410156 l -1.101562,-4.95898 3.833984,3.31445 1.476562,-2.61914 -4.978515,-1.68555 5.117187,-1.42578 -1.453125,-2.61914 -4.033203,3.08398 L 57.624023,254 Z"
|
||||||
|
id="path1062"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
id="path1068"
|
||||||
|
d="m 70.21582,254 1.197265,5.06836 -3.90039,-3.2793 -1.453125,2.5918 5.009765,1.6582 -5.042968,1.58985 1.433593,2.64062 3.955078,-3.30469 L 70.21582,266 h 3.410156 l -1.101562,-4.95898 3.833984,3.31445 1.476562,-2.61914 -4.978515,-1.68555 5.117187,-1.42578 -1.453125,-2.61914 -4.033203,3.08398 L 73.624023,254 Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m 86.21582,254 1.197265,5.06836 -3.90039,-3.2793 -1.453125,2.5918 5.009765,1.6582 -5.042968,1.58985 1.433593,2.64062 3.955078,-3.30469 L 86.21582,266 h 3.410156 l -1.101562,-4.95898 3.833984,3.31445 1.476562,-2.61914 -4.978515,-1.68555 5.117187,-1.42578 -1.453125,-2.61914 -4.033203,3.08398 L 89.624023,254 Z"
|
||||||
|
id="path1074"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
id="path1080"
|
||||||
|
d="m 102.21582,254 1.19726,5.06836 -3.900385,-3.2793 -1.453125,2.5918 5.00976,1.6582 -5.042963,1.58985 1.433593,2.64062 3.95508,-3.30469 L 102.21582,266 h 3.41016 l -1.10157,-4.95898 3.83399,3.31445 1.47656,-2.61914 -4.97851,-1.68555 5.11718,-1.42578 -1.45312,-2.61914 -4.03321,3.08398 L 105.62402,254 Z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<rect
|
||||||
|
ry="7.9999995"
|
||||||
|
rx="8"
|
||||||
|
y="200"
|
||||||
|
x="8"
|
||||||
|
height="40"
|
||||||
|
width="112"
|
||||||
|
id="rect954"
|
||||||
|
class="card-rect"
|
||||||
|
/>
|
||||||
|
<rect y="214" x="8" height="18" width="112" id="rect961" style="fill: #241f31" />
|
||||||
|
<path id="path1138" d="m 22,242 -7.2,6 7.2,6 z" />
|
||||||
|
</g>
|
||||||
|
<g id="g959-3" transform="rotate(-180,107.5,242)">
|
||||||
|
<path
|
||||||
|
style="fill: url(#linearGradient1039)"
|
||||||
|
d="m 170,296 a 28,28 0 0 1 -28,-28 28,28 0 0 1 28,-28 28,28 0 0 1 28,28 28,28 0 0 1 -28,28 z m 0.0312,-12 a 6.0312505,6.0000005 0 0 0 6.03125,-6 6.0312505,6.0000005 0 0 0 -6.03125,-6 6.0312505,6.0000005 0 0 0 -6.03125,6 6.0312505,6.0000005 0 0 0 6.03125,6 z"
|
||||||
|
id="path947-0"
|
||||||
|
/>
|
||||||
|
<g
|
||||||
|
transform="matrix(0.70710678,-0.70710678,-0.70710678,-0.70710678,243.95332,484.3158)"
|
||||||
|
id="g955-3"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
id="path1990"
|
||||||
|
d="m 125.41422,214.44366 -16.97055,16.97056 8.36742,8.36743 3.65338,-3.41768 4.24264,4.24264 h 2.82843 v 2.82843 l 2.12132,2.12132 h 4.24264 v 4.24264 h 2.82843 v 2.82842 h 5.65685 v 5.65686 h 2.82843 v 2.82843 h 12.72792 l 4.94974,-4.94976 v -4.24264 z"
|
||||||
|
style="fill: #77767b"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
style="fill: url(#linearGradient1986)"
|
||||||
|
d="M 124,213.02944 107.02945,230 l 8.36742,8.36743 3.65338,-3.41768 4.24264,4.24264 h 2.82843 v 2.82843 l 2.12132,2.12132 h 4.24264 v 4.24264 h 2.82843 v 2.82842 h 5.65685 v 5.65686 h 2.82843 v 2.82843 h 12.72792 l 4.94974,-4.94976 v -4.24264 z"
|
||||||
|
id="path951-1"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
style="fill: url(#linearGradient1988)"
|
||||||
|
d="m 125.74823,221.26459 c -1.79388,0.002 -2.67811,2.18243 -1.39257,3.43359 l 33.58547,33.5861 2.82844,-2.82843 -33.58579,-33.58579 c -0.37702,-0.38755 -0.89487,-0.60597 -1.43555,-0.60547 z"
|
||||||
|
id="path953-2"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<path
|
||||||
|
id="path957-8"
|
||||||
|
d="m 170,298 a 28,28 0 0 0 28,-28 28,28 0 0 0 -28,-28 28,28 0 0 0 -28,28 28,28 0 0 0 28,28 z m -0.0312,-12 a 6.0312505,6.0000005 0 0 1 -6.03125,-6 6.0312505,6.0000005 0 0 1 6.03125,-6 6.0312505,6.0000005 0 0 1 6.03125,6 6.0312505,6.0000005 0 0 1 -6.03125,6 z"
|
||||||
|
style="fill: #f6f5f4"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 10 KiB |
47
packages/web/package.json
Normal file
47
packages/web/package.json
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "opvault-web",
|
||||||
|
"version": "1.0.220221",
|
||||||
|
"main": "dist/main/index.js",
|
||||||
|
"author": "proteria",
|
||||||
|
"license": "GPL-3.0-or-later",
|
||||||
|
"description": "OnePassword local vault viewer",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently vite npm:start",
|
||||||
|
"build": "vite build",
|
||||||
|
"serve": "vite preview",
|
||||||
|
"start": "./esbuild.js && NODE_ENV=development electron --enable-transparent-visuals --disable-gpu ./dist/main/index.js",
|
||||||
|
"bundle": "./scripts/build.sh"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/css": "^11.7.1",
|
||||||
|
"@emotion/react": "^11.8.1",
|
||||||
|
"@emotion/styled": "^11.8.1",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"path-browserify": "^1.0.1",
|
||||||
|
"react": "^17.0.2",
|
||||||
|
"react-dom": "^17.0.2",
|
||||||
|
"react-icons": "^4.3.1",
|
||||||
|
"react-idle-timer": "4.6.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.17.5",
|
||||||
|
"@emotion/babel-plugin": "^11.7.2",
|
||||||
|
"@rollup/plugin-yaml": "^3.1.0",
|
||||||
|
"@types/babel__core": "^7.1.18",
|
||||||
|
"@types/lodash-es": "^4.17.6",
|
||||||
|
"@types/react": "^17.0.39",
|
||||||
|
"@types/react-dom": "^17.0.11",
|
||||||
|
"@vitejs/plugin-react": "^1.2.0",
|
||||||
|
"concurrently": "^7.0.0",
|
||||||
|
"electron": "^17.0.1",
|
||||||
|
"electron-builder": "^22.14.13",
|
||||||
|
"esbuild": "^0.14.23",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"opvault.js": "*",
|
||||||
|
"sass": "^1.49.8",
|
||||||
|
"typescript": "^4.5.5",
|
||||||
|
"vite": "^2.8.4"
|
||||||
|
}
|
||||||
|
}
|
26
packages/web/scripts/build-i18n-yml-typedef.js
Executable file
26
packages/web/scripts/build-i18n-yml-typedef.js
Executable file
@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require("fs")
|
||||||
|
const { resolve } = require("path")
|
||||||
|
const { load } = require("js-yaml")
|
||||||
|
|
||||||
|
const ymlPath = resolve(__dirname, "../src/i18n/texts.yml")
|
||||||
|
const json = load(fs.readFileSync(ymlPath, "utf-8"))
|
||||||
|
|
||||||
|
const dtsPath = ymlPath + ".d.ts"
|
||||||
|
fs.writeFileSync(
|
||||||
|
dtsPath,
|
||||||
|
`type Translation = Record<string, string>;
|
||||||
|
declare const exportee: {
|
||||||
|
${Object.entries(json)
|
||||||
|
.map(
|
||||||
|
([category, value]) =>
|
||||||
|
`${category}: {\n${Object.keys(value)
|
||||||
|
.map(key => ` ${key}: Translation;`)
|
||||||
|
.join("\n")}\n };`
|
||||||
|
)
|
||||||
|
.join("\n ")}
|
||||||
|
};
|
||||||
|
export default exportee;
|
||||||
|
`
|
||||||
|
)
|
15
packages/web/scripts/build-package-json.js
Executable file
15
packages/web/scripts/build-package-json.js
Executable file
@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const fs = require("fs")
|
||||||
|
const { resolve } = require("path")
|
||||||
|
|
||||||
|
const json = require("../package.json")
|
||||||
|
json.name = "OPVault"
|
||||||
|
json.main = "main/index.js"
|
||||||
|
delete json.scripts
|
||||||
|
delete json.devDependencies
|
||||||
|
delete json.build
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
resolve(__dirname, "../dist/package.json"),
|
||||||
|
JSON.stringify(json, null, 2)
|
||||||
|
)
|
42
packages/web/scripts/build-third-party-license-info.js
Executable file
42
packages/web/scripts/build-third-party-license-info.js
Executable file
@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const fs = require("fs")
|
||||||
|
const { resolve } = require("path")
|
||||||
|
|
||||||
|
const root = resolve(__dirname, "../../..")
|
||||||
|
const packages = [
|
||||||
|
root,
|
||||||
|
resolve(root, "packages/web"),
|
||||||
|
resolve(root, "packages/opvault.js"),
|
||||||
|
]
|
||||||
|
|
||||||
|
const readJSON = path => JSON.parse(fs.readFileSync(path, "utf-8"))
|
||||||
|
const infoMap = Object.fromEntries(
|
||||||
|
packages.flatMap(dir => {
|
||||||
|
const rootPkg = readJSON(resolve(dir, "package.json"))
|
||||||
|
const dependencies = Object.keys(rootPkg.dependencies || {})
|
||||||
|
return dependencies.map(dependency => {
|
||||||
|
const pkgDir = resolve(dir, "node_modules", dependency)
|
||||||
|
const pkg = readJSON(resolve(pkgDir, "package.json"))
|
||||||
|
const licenseFile = fs
|
||||||
|
.readdirSync(pkgDir)
|
||||||
|
.filter(x => x.toLowerCase().startsWith("license"))
|
||||||
|
if (licenseFile.length !== 1) {
|
||||||
|
console.error(fs.readdirSync(pkgDir))
|
||||||
|
throw new Error(`Cannot determine license file for ${pkg.name}`)
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
pkg.name,
|
||||||
|
{
|
||||||
|
name: pkg.name,
|
||||||
|
author: pkg.author?.name ?? pkg.author,
|
||||||
|
license: fs.readFileSync(resolve(pkgDir, licenseFile[0]), "utf-8"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
resolve(__dirname, "../src/third-party-licenses.json"),
|
||||||
|
JSON.stringify(infoMap, null, 2)
|
||||||
|
)
|
8
packages/web/scripts/build.sh
Executable file
8
packages/web/scripts/build.sh
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
./scripts/update-version.js
|
||||||
|
./scripts/build-i18n-yml-typedef.js
|
||||||
|
./scripts/build-third-party-license-info.js
|
||||||
|
./scripts/build-package-json.js
|
||||||
|
npx vite build
|
||||||
|
NODE_ENV=production ./esbuild.js
|
||||||
|
./node_modules/.bin/electron-builder build
|
19
packages/web/scripts/update-version.js
Executable file
19
packages/web/scripts/update-version.js
Executable file
@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const fs = require("fs")
|
||||||
|
const { resolve } = require("path")
|
||||||
|
|
||||||
|
const json = require("../package.json")
|
||||||
|
const date = new Date()
|
||||||
|
json.version = json.version
|
||||||
|
.split(".")
|
||||||
|
.slice(0, 2)
|
||||||
|
.concat(
|
||||||
|
[
|
||||||
|
date.getUTCFullYear() - 2000,
|
||||||
|
(date.getUTCMonth() + 1).toString().padStart(2, "0"),
|
||||||
|
date.getUTCDate().toString().padStart(2, "0"),
|
||||||
|
].join("")
|
||||||
|
)
|
||||||
|
.join(".")
|
||||||
|
|
||||||
|
fs.writeFileSync(resolve(__dirname, "../package.json"), JSON.stringify(json, null, 2))
|
41
packages/web/scripts/vite-babel.ts
Normal file
41
packages/web/scripts/vite-babel.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import * as babel from "@babel/core"
|
||||||
|
import type { PluginOption, TransformResult } from "vite"
|
||||||
|
|
||||||
|
const sourceRegex = /\.(j|t)sx?$/
|
||||||
|
|
||||||
|
export default function macrosPlugin(): PluginOption {
|
||||||
|
return {
|
||||||
|
name: "babel-macros",
|
||||||
|
enforce: "pre",
|
||||||
|
transform(source: string, filename: string) {
|
||||||
|
if (filename.includes("node_modules")) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (!sourceRegex.test(filename)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const hasBabelMacro = source.includes('.macro"')
|
||||||
|
const hasEmotion = source.includes("@emotion")
|
||||||
|
if (!hasBabelMacro && !hasEmotion) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const result = babel.transformSync(source, {
|
||||||
|
filename,
|
||||||
|
parserOpts: {
|
||||||
|
plugins: ["jsx", "typescript", "decorators-legacy"],
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
hasBabelMacro && require.resolve("babel-plugin-macros"),
|
||||||
|
hasEmotion && require.resolve("@emotion/babel-plugin"),
|
||||||
|
].filter(Boolean),
|
||||||
|
generatorOpts: {
|
||||||
|
decoratorsBeforeExport: true,
|
||||||
|
},
|
||||||
|
babelrc: false,
|
||||||
|
configFile: false,
|
||||||
|
sourceMaps: true,
|
||||||
|
})
|
||||||
|
return result as TransformResult | null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
52
packages/web/src/App.tsx
Normal file
52
packages/web/src/App.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
/* eslint-disable import/no-unresolved */
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import type { Vault, OnePassword } from "opvault.js"
|
||||||
|
import { useIdleTimer } from "react-idle-timer/modern"
|
||||||
|
import { VaultView } from "./pages/Vault"
|
||||||
|
import { VaultPicker } from "./pages/VaultPicker"
|
||||||
|
import { Key, useStorage } from "./utils/localStorage"
|
||||||
|
|
||||||
|
export const App: React.FC = () => {
|
||||||
|
const [instance, setInstance] = useState<OnePassword>()
|
||||||
|
const [vault, setVault] = useState<Vault>()
|
||||||
|
|
||||||
|
const [enableAutoLock] = useStorage(Key.ENABLE_AUTO_LOCK)
|
||||||
|
const [autolockAfter] = useStorage(Key.AUTO_LOCK_AFTER)
|
||||||
|
|
||||||
|
const onLock = useCallback(() => {
|
||||||
|
vault?.lock()
|
||||||
|
setVault(undefined)
|
||||||
|
}, [vault])
|
||||||
|
|
||||||
|
const onAutoLock = useCallback(() => {
|
||||||
|
if (enableAutoLock) {
|
||||||
|
onLock()
|
||||||
|
}
|
||||||
|
}, [onLock, enableAutoLock])
|
||||||
|
|
||||||
|
const { reset, pause } = useIdleTimer({
|
||||||
|
timeout: autolockAfter * 1000,
|
||||||
|
onIdle: onAutoLock,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (vault) {
|
||||||
|
reset()
|
||||||
|
} else {
|
||||||
|
pause()
|
||||||
|
}
|
||||||
|
}, [vault])
|
||||||
|
|
||||||
|
if (!vault) {
|
||||||
|
return (
|
||||||
|
<VaultPicker
|
||||||
|
instance={instance}
|
||||||
|
setInstance={setInstance}
|
||||||
|
vault={vault}
|
||||||
|
setVault={setVault}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <VaultView onLock={onLock} vault={vault} />
|
||||||
|
}
|
31
packages/web/src/SideEffect.ts
Normal file
31
packages/web/src/SideEffect.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { useEffect, memo } from "react"
|
||||||
|
import { debounce } from "lodash-es"
|
||||||
|
import { useLocaleContext, useTranslate } from "./i18n"
|
||||||
|
import { Key, useStorage } from "./utils/localStorage"
|
||||||
|
|
||||||
|
const updateCSS = debounce((name: string, value: string) => {
|
||||||
|
document.body.style.setProperty(name, value || null)
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
export const SideEffect = memo(() => {
|
||||||
|
const { locale } = useLocaleContext()
|
||||||
|
const t = useTranslate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.lang = locale
|
||||||
|
document.title = t.label.app_name
|
||||||
|
}, [locale])
|
||||||
|
|
||||||
|
const [uiFont] = useStorage(Key.UI_FONT)
|
||||||
|
const [monoFont] = useStorage(Key.MONOSPACE_FONT)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateCSS("--sans-serif", uiFont)
|
||||||
|
}, [uiFont])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateCSS("--monospace", monoFont)
|
||||||
|
}, [monoFont])
|
||||||
|
|
||||||
|
return null
|
||||||
|
})
|
65
packages/web/src/about/LicenseViewer.tsx
Normal file
65
packages/web/src/about/LicenseViewer.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import styled from "@emotion/styled"
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { Container as ClickableContainer } from "../components/ItemFieldValue/Container"
|
||||||
|
import { scrollbar } from "../styles"
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
display: flex;
|
||||||
|
`
|
||||||
|
const ListContainer = styled.div`
|
||||||
|
min-width: 150px;
|
||||||
|
ul {
|
||||||
|
list-style-type: none;
|
||||||
|
margin-block-start: 0;
|
||||||
|
padding-inline-start: 0;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
line-height: 1.6em;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const LicenseText = styled.div`
|
||||||
|
flex-grow: 1;
|
||||||
|
font-family: var(--monospace);
|
||||||
|
max-height: 575px;
|
||||||
|
overflow-y: scroll;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const LicenseView = () => {
|
||||||
|
const [licenseInfo, setLicenseInfo] = useState<
|
||||||
|
typeof import("../third-party-licenses.json")
|
||||||
|
>(() => ({} as any))
|
||||||
|
const names = useMemo(() => Object.keys(licenseInfo), [licenseInfo])
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<string>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
import("../third-party-licenses.json").then(json => setLicenseInfo(json.default))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected(names[0])
|
||||||
|
}, [names])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<ListContainer>
|
||||||
|
<ul>
|
||||||
|
{names.map(name => (
|
||||||
|
<li
|
||||||
|
key={name}
|
||||||
|
style={name === selected ? { fontWeight: 600 } : undefined}
|
||||||
|
onClick={() => setSelected(name)}
|
||||||
|
>
|
||||||
|
<ClickableContainer>{name}</ClickableContainer>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</ListContainer>
|
||||||
|
|
||||||
|
<LicenseText className={scrollbar}>
|
||||||
|
{licenseInfo[selected as any]?.license}
|
||||||
|
</LicenseText>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
27
packages/web/src/about/index.tsx
Normal file
27
packages/web/src/about/index.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import styled from "@emotion/styled"
|
||||||
|
import { Modal } from "../components/Modal"
|
||||||
|
import { useTranslate } from "../i18n"
|
||||||
|
import { LicenseView } from "./LicenseViewer"
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
width: 800px;
|
||||||
|
min-height: 450px;
|
||||||
|
`
|
||||||
|
const LicenseSectionHeader = styled.h3`
|
||||||
|
margin-top: 0;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const About: React.FC<{
|
||||||
|
show: boolean
|
||||||
|
onHide(): void
|
||||||
|
}> = ({ show, onHide }) => {
|
||||||
|
const t = useTranslate()
|
||||||
|
return (
|
||||||
|
<Modal maxWidth={800} show={show} title={t.label.about_app} onClose={onHide}>
|
||||||
|
<Container>
|
||||||
|
<LicenseSectionHeader>Licenses</LicenseSectionHeader>
|
||||||
|
<LicenseView />
|
||||||
|
</Container>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
53
packages/web/src/components/BigTextView.tsx
Normal file
53
packages/web/src/components/BigTextView.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import styled from "@emotion/styled"
|
||||||
|
import { memo, useEffect } from "react"
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-family: var(--monospace);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 6em;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 25px;
|
||||||
|
word-break: break-word;
|
||||||
|
min-width: 75vw;
|
||||||
|
z-index: 2;
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const Letter = styled.span`
|
||||||
|
&:nth-of-type(even) {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
interface BigTextViewProps {
|
||||||
|
onClose(): void
|
||||||
|
children: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BigTextView = memo<BigTextViewProps>(({ onClose, children }) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const fn = (e: KeyboardEvent) => {
|
||||||
|
if (e.code === "Escape") {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", fn)
|
||||||
|
return () => document.removeEventListener("keydown", fn)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
{children.split("").map((letter, i) => (
|
||||||
|
<Letter key={i}>{letter}</Letter>
|
||||||
|
))}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
})
|
88
packages/web/src/components/CategoryIcon.tsx
Normal file
88
packages/web/src/components/CategoryIcon.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { memo } from "react"
|
||||||
|
import { Category } from "opvault.js"
|
||||||
|
import { cx, css } from "@emotion/css"
|
||||||
|
import { BsBank2, BsPeopleFill } from "react-icons/bs"
|
||||||
|
import { CgLogIn } from "react-icons/cg"
|
||||||
|
import { HiMail, HiIdentification } from "react-icons/hi"
|
||||||
|
import { RiGovernmentLine } from "react-icons/ri"
|
||||||
|
import {
|
||||||
|
FaArchive,
|
||||||
|
FaDatabase,
|
||||||
|
FaPassport,
|
||||||
|
FaServer,
|
||||||
|
FaFish,
|
||||||
|
FaGift,
|
||||||
|
FaCar,
|
||||||
|
FaWifi,
|
||||||
|
} from "react-icons/fa"
|
||||||
|
import { GrLicense, GrNotes, GrCreditCard } from "react-icons/gr"
|
||||||
|
import { MdPassword } from "react-icons/md"
|
||||||
|
|
||||||
|
function getComponent(category: Category) {
|
||||||
|
switch (category) {
|
||||||
|
case Category.BankAccount:
|
||||||
|
return BsBank2
|
||||||
|
case Category.CreditCard:
|
||||||
|
return GrCreditCard
|
||||||
|
case Category.Database:
|
||||||
|
return FaDatabase
|
||||||
|
case Category.DriverLicense:
|
||||||
|
return FaCar
|
||||||
|
case Category.Email:
|
||||||
|
return HiMail
|
||||||
|
case Category.Identity:
|
||||||
|
return HiIdentification
|
||||||
|
case Category.Login:
|
||||||
|
return CgLogIn
|
||||||
|
case Category.Membership:
|
||||||
|
return BsPeopleFill
|
||||||
|
case Category.OutdoorLicense:
|
||||||
|
return FaFish
|
||||||
|
case Category.Passport:
|
||||||
|
return FaPassport
|
||||||
|
case Category.Password:
|
||||||
|
return MdPassword
|
||||||
|
case Category.Rewards:
|
||||||
|
return FaGift
|
||||||
|
case Category.Router:
|
||||||
|
return FaWifi
|
||||||
|
case Category.SecureNote:
|
||||||
|
return GrNotes
|
||||||
|
case Category.Server:
|
||||||
|
return FaServer
|
||||||
|
case Category.SoftwareLicense:
|
||||||
|
return GrLicense
|
||||||
|
case Category.SSN:
|
||||||
|
return RiGovernmentLine
|
||||||
|
case Category.Tombstone:
|
||||||
|
return FaArchive
|
||||||
|
default:
|
||||||
|
category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reactIconClass = css`
|
||||||
|
fill: var(--color);
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
path:not([fill="none"]),
|
||||||
|
path[stroke] {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
interface CategoryIconProps {
|
||||||
|
className?: string
|
||||||
|
style?: React.CSSProperties
|
||||||
|
fill?: string
|
||||||
|
category: Category
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CategoryIcon = memo<CategoryIconProps>(
|
||||||
|
({ className, category, style, fill }) => {
|
||||||
|
const Component = getComponent(category)
|
||||||
|
return Component ? (
|
||||||
|
<Component className={cx(reactIconClass, className)} fill={fill} style={style} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
)
|
51
packages/web/src/components/ErrorBoundary.tsx
Normal file
51
packages/web/src/components/ErrorBoundary.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import type { ErrorInfo } from "react"
|
||||||
|
import React from "react"
|
||||||
|
import styled from "@emotion/styled"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @module ErrorBoundary
|
||||||
|
* React HOC to restrict an Error from blowing up the entire application.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type State = { error?: Error; info?: ErrorInfo }
|
||||||
|
|
||||||
|
const Div = styled.div`
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: rgba(0, 0, 0, 0.05) 0px 1px 1px;
|
||||||
|
margin: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
`
|
||||||
|
const Header = styled.h2`
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
`
|
||||||
|
const Pre = styled.pre`
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.3em;
|
||||||
|
`
|
||||||
|
|
||||||
|
export class ErrorBoundary extends React.Component<any, State> {
|
||||||
|
state: State = {}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
|
this.setState({ error, info })
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { error, info } = this.state
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error(error)
|
||||||
|
return (
|
||||||
|
<Div>
|
||||||
|
<Header>Error: {error.message}</Header>
|
||||||
|
<Pre>{info?.componentStack?.replace(/^\n/, "")}</Pre>
|
||||||
|
<Pre>{error.stack}</Pre>
|
||||||
|
</Div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{this.props.children}</>
|
||||||
|
}
|
||||||
|
}
|
204
packages/web/src/components/FilteredVaultView.tsx
Normal file
204
packages/web/src/components/FilteredVaultView.tsx
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import styled from "@emotion/styled"
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import type { Item } from "opvault.js"
|
||||||
|
import { Category } from "opvault.js"
|
||||||
|
import { IoSearch } from "react-icons/io5"
|
||||||
|
import { ItemList } from "../components/ItemList"
|
||||||
|
import { ItemView } from "../components/Item"
|
||||||
|
import { reactIconClass } from "../components/CategoryIcon"
|
||||||
|
import { useTranslate } from "../i18n/index"
|
||||||
|
import { scrollbar } from "../styles"
|
||||||
|
|
||||||
|
const ListContainer = styled.div`
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
width: 350px;
|
||||||
|
margin-right: 10px;
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-y: overlay;
|
||||||
|
overflow-x: hidden;
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
background: #202020;
|
||||||
|
border-right-color: transparent;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const ItemContainer = styled.div`
|
||||||
|
width: calc(100% - 300px);
|
||||||
|
overflow: hidden;
|
||||||
|
`
|
||||||
|
const SearchContainer = styled.div`
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
flex-grow: 1;
|
||||||
|
margin: 10px 0;
|
||||||
|
margin-right: 10px;
|
||||||
|
`
|
||||||
|
const SortContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
margin: 10px 0;
|
||||||
|
`
|
||||||
|
const CategorySelect = styled.select`
|
||||||
|
width: 50%;
|
||||||
|
margin-left: 10px;
|
||||||
|
margin-right: 5px;
|
||||||
|
`
|
||||||
|
const SortSelect = styled.select`
|
||||||
|
width: calc(50% - 25px);
|
||||||
|
`
|
||||||
|
|
||||||
|
const SearchInput = styled.input`
|
||||||
|
--margin: 10px;
|
||||||
|
width: calc(100% - var(--margin) * 2 + 9px);
|
||||||
|
margin: 0 var(--margin);
|
||||||
|
padding-left: 2em !important;
|
||||||
|
`
|
||||||
|
const SearchIcon = styled(IoSearch)`
|
||||||
|
position: absolute;
|
||||||
|
top: 9px;
|
||||||
|
left: 20px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const enum SortBy {
|
||||||
|
Name,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FilteredVaultView: React.FC<{ items: Item[] }> = ({ items }) => {
|
||||||
|
const t = useTranslate()
|
||||||
|
const [item, setItem] = useState<Item>()
|
||||||
|
const [category, setCategory] = useState<Category>()
|
||||||
|
const [sortBy, setSortBy] = useState(SortBy.Name)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setItem(undefined)
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
const compareFn = useMemo((): ((a: Item, b: Item) => number) => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case SortBy.Name:
|
||||||
|
return (a, b) => (a.overview.title ?? "").localeCompare(b?.overview.title ?? "")
|
||||||
|
case SortBy.CreatedAt:
|
||||||
|
return (a, b) => b.createdAt - a.createdAt
|
||||||
|
case SortBy.UpdatedAt:
|
||||||
|
return (a, b) => b.updatedAt - a.updatedAt
|
||||||
|
}
|
||||||
|
}, [sortBy])
|
||||||
|
|
||||||
|
const sortedItem = useMemo(() => items.slice().sort(compareFn), [items, compareFn])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
let items = sortedItem.filter(x => x.category !== Category.Tombstone)
|
||||||
|
if (category != null) {
|
||||||
|
items = items.filter(x => x.category === category)
|
||||||
|
}
|
||||||
|
|
||||||
|
let res: Item[] = items
|
||||||
|
if (search) {
|
||||||
|
res = []
|
||||||
|
for (const x of items) {
|
||||||
|
const compare = Math.max(
|
||||||
|
stringCompare(search, x.overview.title),
|
||||||
|
stringCompare(search, x.overview.ainfo)
|
||||||
|
) as CompareResult
|
||||||
|
switch (compare) {
|
||||||
|
case CompareResult.NoMatch:
|
||||||
|
continue
|
||||||
|
case CompareResult.Includes:
|
||||||
|
res.push(x)
|
||||||
|
break
|
||||||
|
case CompareResult.Equals:
|
||||||
|
res.unshift(x)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}, [sortedItem, search, category])
|
||||||
|
|
||||||
|
const categoryMap = useMemo(
|
||||||
|
(): [Category | undefined, string][] => [
|
||||||
|
[undefined, t.label.category_all],
|
||||||
|
[Category.Login, t.label.category_login],
|
||||||
|
[Category.SecureNote, t.label.category_secure_note],
|
||||||
|
[Category.CreditCard, t.label.category_credit_card],
|
||||||
|
[Category.Identity, t.label.category_identity],
|
||||||
|
[Category.Password, t.label.category_password],
|
||||||
|
[Category.Membership, t.label.category_membership],
|
||||||
|
[Category.Database, t.label.category_database],
|
||||||
|
[Category.BankAccount, t.label.category_bank_account],
|
||||||
|
[Category.Email, t.label.category_email],
|
||||||
|
[Category.SoftwareLicense, t.label.category_software_license],
|
||||||
|
[Category.SSN, t.label.category_ssn],
|
||||||
|
[Category.Passport, t.label.category_passport],
|
||||||
|
[Category.OutdoorLicense, t.label.category_outdoor_license],
|
||||||
|
[Category.DriverLicense, t.label.category_driver_license],
|
||||||
|
[Category.Rewards, t.label.category_rewards],
|
||||||
|
[Category.Router, t.label.category_router],
|
||||||
|
[Category.Server, t.label.category_server],
|
||||||
|
],
|
||||||
|
[t]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ListContainer className={scrollbar}>
|
||||||
|
<SearchContainer>
|
||||||
|
<SearchInput
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<SearchIcon className={reactIconClass} />
|
||||||
|
</SearchContainer>
|
||||||
|
|
||||||
|
<SortContainer>
|
||||||
|
<CategorySelect
|
||||||
|
value={category}
|
||||||
|
onChange={e => setCategory((e.currentTarget.value as Category) || undefined)}
|
||||||
|
>
|
||||||
|
{categoryMap.map(([value, name]) => (
|
||||||
|
<option value={value || ""} key={name}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</CategorySelect>
|
||||||
|
|
||||||
|
<SortSelect value={sortBy} onChange={e => setSortBy(+e.currentTarget.value)}>
|
||||||
|
<option key={1} value={SortBy.Name}>
|
||||||
|
{t.options.sort_by_name}
|
||||||
|
</option>
|
||||||
|
<option key={2} value={SortBy.CreatedAt}>
|
||||||
|
{t.options.sort_by_created_at}
|
||||||
|
</option>
|
||||||
|
<option key={3} value={SortBy.UpdatedAt}>
|
||||||
|
{t.options.sort_by_updated_at}
|
||||||
|
</option>
|
||||||
|
</SortSelect>
|
||||||
|
</SortContainer>
|
||||||
|
<ItemList items={filtered} onSelect={setItem} selected={item} />
|
||||||
|
</ListContainer>
|
||||||
|
<ItemContainer>
|
||||||
|
{item && <ItemView className={scrollbar} item={item} />}
|
||||||
|
</ItemContainer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CompareResult {
|
||||||
|
NoMatch,
|
||||||
|
Includes,
|
||||||
|
Equals,
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringCompare(search: string, source?: string) {
|
||||||
|
if (!search) return CompareResult.Includes
|
||||||
|
if (!source) return CompareResult.NoMatch
|
||||||
|
source = source.toLocaleLowerCase()
|
||||||
|
search = search.toLocaleUpperCase()
|
||||||
|
const includes = source.includes(search.toLocaleLowerCase())
|
||||||
|
if (includes) {
|
||||||
|
return source.length === search.length ? CompareResult.Equals : CompareResult.Includes
|
||||||
|
}
|
||||||
|
return CompareResult.NoMatch
|
||||||
|
}
|
183
packages/web/src/components/Item.tsx
Normal file
183
packages/web/src/components/Item.tsx
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import styled from "@emotion/styled"
|
||||||
|
import type { Attachment, AttachmentMetadata, Item, ItemField } from "opvault.js"
|
||||||
|
import type { ItemDetails } from "opvault.js/src/types"
|
||||||
|
import { memo, useEffect, useState } from "react"
|
||||||
|
import { useTranslate } from "../i18n"
|
||||||
|
import { ItemNoTitle } from "../styles"
|
||||||
|
import { CategoryIcon } from "./CategoryIcon"
|
||||||
|
import { ItemDates } from "./ItemDates"
|
||||||
|
import {
|
||||||
|
ItemFieldView,
|
||||||
|
FieldContainer,
|
||||||
|
FieldTitle,
|
||||||
|
ItemDetailsFieldView,
|
||||||
|
} from "./ItemField"
|
||||||
|
import { Password } from "./ItemFieldValue/Password"
|
||||||
|
import { ItemWarning } from "./ItemWarning"
|
||||||
|
|
||||||
|
interface ItemViewProps {
|
||||||
|
item: Item
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Header = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
`
|
||||||
|
const Icon = styled(CategoryIcon)`
|
||||||
|
font-size: 2em;
|
||||||
|
margin-right: 5px;
|
||||||
|
`
|
||||||
|
const SectionTitle = styled.div`
|
||||||
|
font-size: 85%;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: 20px 0 10px;
|
||||||
|
`
|
||||||
|
const Tag = styled.div`
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 2px;
|
||||||
|
margin-right: 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 7px;
|
||||||
|
background-color: var(--label-background);
|
||||||
|
`
|
||||||
|
const ExtraField = styled(FieldContainer)`
|
||||||
|
margin-bottom: 20px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ItemTitle = styled.h2``
|
||||||
|
const Container = styled.div`
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0 10px;
|
||||||
|
`
|
||||||
|
const Inner = styled.div`
|
||||||
|
padding: 10px 0;
|
||||||
|
`
|
||||||
|
const AttachmentContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
margin: 5px 0;
|
||||||
|
`
|
||||||
|
const PlainNotes = styled.p`
|
||||||
|
white-space: pre-wrap;
|
||||||
|
`
|
||||||
|
|
||||||
|
const SectionsView = memo<{ sections?: ItemDetails["sections"] }>(({ sections }) =>
|
||||||
|
sections?.length ? (
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
{sections
|
||||||
|
.filter(s => s.fields?.some(x => x.v != null))
|
||||||
|
.map((section, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
{section.title != null && <SectionTitle>{section.title}</SectionTitle>}
|
||||||
|
{section.fields?.map((field, j) => (
|
||||||
|
<ItemFieldView key={j} field={field} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
)
|
||||||
|
|
||||||
|
const FieldsView = memo<{ fields?: ItemField[] }>(({ fields }) =>
|
||||||
|
fields?.length ? (
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
{fields.map((field, i) => (
|
||||||
|
<ItemDetailsFieldView key={i} field={field} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
)
|
||||||
|
|
||||||
|
const TagsView = memo<{ tags?: string[] }>(({ tags }) => {
|
||||||
|
const t = useTranslate()
|
||||||
|
if (!tags?.length) return null
|
||||||
|
return (
|
||||||
|
<ExtraField>
|
||||||
|
<FieldTitle>{t.noun.tags}</FieldTitle>
|
||||||
|
<div>
|
||||||
|
{tags.map((tag, i) => (
|
||||||
|
<Tag key={i}>{tag}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ExtraField>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const JSONView = memo<{ item: Item }>(({ item }) => (
|
||||||
|
<details>
|
||||||
|
<summary>JSON</summary>
|
||||||
|
<pre>
|
||||||
|
{JSON.stringify({ overview: item.overview, details: item.details }, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
))
|
||||||
|
|
||||||
|
export const ItemView = memo<ItemViewProps>(({ className, item }) => {
|
||||||
|
const t = useTranslate()
|
||||||
|
return (
|
||||||
|
<Container className={className}>
|
||||||
|
<Inner>
|
||||||
|
<ItemWarning item={item} />
|
||||||
|
<Header>
|
||||||
|
{item.details.fields == null}
|
||||||
|
<Icon category={item.category} />
|
||||||
|
<ItemTitle>
|
||||||
|
{item.overview.title || <ItemNoTitle>{t.label.no_title}</ItemNoTitle>}
|
||||||
|
</ItemTitle>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<JSONView item={item} />
|
||||||
|
<div style={{ height: 10 }}></div>
|
||||||
|
|
||||||
|
<SectionsView sections={item.details.sections} />
|
||||||
|
<FieldsView fields={item.details.fields} />
|
||||||
|
|
||||||
|
{item.details.notesPlain != null && (
|
||||||
|
<ExtraField>
|
||||||
|
<FieldTitle>notes</FieldTitle>
|
||||||
|
<div>
|
||||||
|
<PlainNotes>{item.details.notesPlain}</PlainNotes>
|
||||||
|
</div>
|
||||||
|
</ExtraField>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.details.password != null && (
|
||||||
|
<ExtraField>
|
||||||
|
<FieldTitle>{t.label.password}</FieldTitle>
|
||||||
|
<Password field={{ v: item.details.password }} />
|
||||||
|
</ExtraField>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TagsView tags={item.overview.tags} />
|
||||||
|
|
||||||
|
{item.attachments.length > 0 && (
|
||||||
|
<ExtraField>
|
||||||
|
<FieldTitle>attachments</FieldTitle>
|
||||||
|
<div>
|
||||||
|
{item.attachments.map((file, i) => (
|
||||||
|
<AttachmentView key={i} file={file} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ExtraField>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ExtraField>
|
||||||
|
<ItemDates item={item} />
|
||||||
|
</ExtraField>
|
||||||
|
</Inner>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function AttachmentView({ file }: { file: Attachment }) {
|
||||||
|
const [metadata, setMetadata] = useState<AttachmentMetadata>()
|
||||||
|
useEffect(() => {
|
||||||
|
file.unlock().then(() => setMetadata(file.metadata))
|
||||||
|
}, [file])
|
||||||
|
|
||||||
|
if (!metadata) return null
|
||||||
|
|
||||||
|
return <AttachmentContainer>{metadata.overview.filename}</AttachmentContainer>
|
||||||
|
}
|
26
packages/web/src/components/ItemDates.tsx
Normal file
26
packages/web/src/components/ItemDates.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { memo } from "react"
|
||||||
|
import styled from "@emotion/styled"
|
||||||
|
import type { Item } from "opvault.js"
|
||||||
|
import { useTranslate } from "../i18n"
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
text-align: center;
|
||||||
|
font-size: 90%;
|
||||||
|
line-height: 1.5em;
|
||||||
|
opacity: 0.5;
|
||||||
|
user-select: none;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const ItemDates = memo<{ item: Item }>(({ item }) => {
|
||||||
|
const t = useTranslate()
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<div>
|
||||||
|
{t.label.last_updated}: {new Date(item.updatedAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t.label.created_at}: {new Date(item.createdAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
})
|
58
packages/web/src/components/ItemField.tsx
Normal file
58
packages/web/src/components/ItemField.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { memo, useMemo } from "react"
|
||||||
|
import styled from "@emotion/styled"
|
||||||
|
import type { ItemField, ItemSection } from "opvault.js"
|
||||||
|
import { ErrorBoundary } from "./ErrorBoundary"
|
||||||
|
import { ItemFieldValue, ItemDetailsFieldValue } from "./ItemFieldValue"
|
||||||
|
|
||||||
|
export { Container as FieldContainer }
|
||||||
|
const Container: React.FC = styled.div`
|
||||||
|
padding: 5px 0;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
`
|
||||||
|
export const FieldTitle: React.FC = styled.div`
|
||||||
|
font-size: 85%;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
user-select: none;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const ItemFieldView = memo<{
|
||||||
|
field: ItemSection.Any
|
||||||
|
}>(({ field }) => {
|
||||||
|
const title = useMemo(
|
||||||
|
() => ((field as ItemSection.Concealed).n?.startsWith("TOTP_") ? "TOTP" : field.t),
|
||||||
|
[field]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (field.v == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Container>
|
||||||
|
<FieldTitle>{title}</FieldTitle>
|
||||||
|
<ItemFieldValue field={field} />
|
||||||
|
</Container>
|
||||||
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const hideIds = new Set(["use_desktop", "use_mobile", "use_html"])
|
||||||
|
const hideNames = new Set(["remember"])
|
||||||
|
|
||||||
|
export const ItemDetailsFieldView = memo<{
|
||||||
|
field: ItemField
|
||||||
|
}>(({ field }) => {
|
||||||
|
if (field.value == null || hideIds.has(field.id!) || hideNames.has(field.name)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Container>
|
||||||
|
<FieldTitle>{field.name}</FieldTitle>
|
||||||
|
<ItemDetailsFieldValue field={field} />
|
||||||
|
</Container>
|
||||||
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
})
|
108
packages/web/src/components/ItemFieldContextMenu.tsx
Normal file
108
packages/web/src/components/ItemFieldContextMenu.tsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import styled from "@emotion/styled"
|
||||||
|
|
||||||
|
const Container = styled.menu`
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: rgb(15 15 15 / 5%) 0px 0px 0px 1px, rgb(15 15 15 / 10%) 0px 3px 6px,
|
||||||
|
rgb(15 15 15 / 20%) 0px 9px 24px;
|
||||||
|
left: 99%;
|
||||||
|
margin-block-start: 0;
|
||||||
|
min-width: 195px;
|
||||||
|
padding-inline-start: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
user-select: none;
|
||||||
|
z-index: 2;
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
background-color: #3c3c3c;
|
||||||
|
box-shadow: rgb(0 0 0) 0px 2px 4px;
|
||||||
|
color: #f0f0f0;
|
||||||
|
}
|
||||||
|
& & {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const Separator = styled.div`
|
||||||
|
border-bottom: 1px solid #777;
|
||||||
|
margin-top: 0.4em;
|
||||||
|
margin-bottom: 0.4em;
|
||||||
|
margin-left: 0.6em;
|
||||||
|
margin-right: 0.6em;
|
||||||
|
`
|
||||||
|
|
||||||
|
const Item = styled.div`
|
||||||
|
cursor: default;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
height: 2.3em;
|
||||||
|
align-items: center;
|
||||||
|
padding-left: 1em;
|
||||||
|
padding-right: 5px;
|
||||||
|
position: relative;
|
||||||
|
&:first-of-type {
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
|
}
|
||||||
|
&:last-of-type {
|
||||||
|
border-radius: 0 0 3px 3px;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background-color: #ddd;
|
||||||
|
|
||||||
|
.item-field-context-menu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
background-color: #094771;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
function useContextMenu() {
|
||||||
|
const [show, setShow] = useState(false)
|
||||||
|
const [pos, setPos] = useState({ x: 0, y: 0 })
|
||||||
|
const onRightClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
setShow(true)
|
||||||
|
e.preventDefault()
|
||||||
|
setPos({ x: e.pageX, y: e.pageY })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fn = () => setShow(false)
|
||||||
|
document.addEventListener("click", fn)
|
||||||
|
return () => document.removeEventListener("click", fn)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
show,
|
||||||
|
position: {
|
||||||
|
top: pos.y,
|
||||||
|
left: pos.x,
|
||||||
|
},
|
||||||
|
onRightClick,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useItemFieldContextMenu() {
|
||||||
|
const { onRightClick, position, show } = useContextMenu()
|
||||||
|
|
||||||
|
const ContextMenuContainer: React.FC = useCallback(
|
||||||
|
({ children }) => {
|
||||||
|
if (!show) return null
|
||||||
|
return (
|
||||||
|
<Container style={position} className="item-field-context-menu">
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[show, position]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
onRightClick,
|
||||||
|
Item,
|
||||||
|
ContextMenuContainer,
|
||||||
|
}
|
||||||
|
}
|
12
packages/web/src/components/ItemFieldValue/Address.tsx
Normal file
12
packages/web/src/components/ItemFieldValue/Address.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import type { ItemSection } from "opvault.js"
|
||||||
|
import { Container } from "./Container"
|
||||||
|
|
||||||
|
export const Address: React.FC<{ field: ItemSection.Address }> = ({ field }) => (
|
||||||
|
<Container style={{ whiteSpace: "pre" }}>
|
||||||
|
<div>{field.v.street}</div>
|
||||||
|
<div>
|
||||||
|
{field.v.city}, {field.v.state} ({field.v.zip})
|
||||||
|
</div>
|
||||||
|
<div>{field.v.country}</div>
|
||||||
|
</Container>
|
||||||
|
)
|
9
packages/web/src/components/ItemFieldValue/Container.ts
Normal file
9
packages/web/src/components/ItemFieldValue/Container.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import styled from "@emotion/styled"
|
||||||
|
|
||||||
|
export const Container = styled.div`
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover {
|
||||||
|
color: #6fa9ff;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
`
|
8
packages/web/src/components/ItemFieldValue/DateView.tsx
Normal file
8
packages/web/src/components/ItemFieldValue/DateView.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import type { ItemSection } from "opvault.js"
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import { Container } from "./Container"
|
||||||
|
|
||||||
|
export const DateView: React.FC<{ field: ItemSection.Date }> = ({ field }) => {
|
||||||
|
const date = useMemo(() => new Date(field.v * 1000), [field.v])
|
||||||
|
return <Container>{date.toLocaleDateString()}</Container>
|
||||||
|
}
|
12
packages/web/src/components/ItemFieldValue/MonthYear.tsx
Normal file
12
packages/web/src/components/ItemFieldValue/MonthYear.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import type { ItemSection } from "opvault.js"
|
||||||
|
import { parseMonthYear } from "../../utils"
|
||||||
|
import { Container } from "./Container"
|
||||||
|
|
||||||
|
export const MonthYear: React.FC<{ field: ItemSection.MonthYear }> = ({ field }) => {
|
||||||
|
const { year, month } = parseMonthYear(field.v)
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
{month.toString().padStart(2, "0")}/{year.toString().padStart(4, "0")}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
64
packages/web/src/components/ItemFieldValue/OTP.tsx
Normal file
64
packages/web/src/components/ItemFieldValue/OTP.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import styled from "@emotion/styled"
|
||||||
|
import type { ItemSection } from "opvault.js"
|
||||||
|
import { useCallback, useState } from "react"
|
||||||
|
import { useTranslate } from "../../i18n"
|
||||||
|
import { useItemFieldContextMenu } from "../ItemFieldContextMenu"
|
||||||
|
import { Container } from "./Container"
|
||||||
|
import { useCopy } from "./hooks"
|
||||||
|
|
||||||
|
const OTPItemContainer = styled(Container)`
|
||||||
|
margin: 5px 0;
|
||||||
|
`
|
||||||
|
|
||||||
|
const OTPItem = ({ children }: { children: string }) => {
|
||||||
|
const { onRightClick } = useItemFieldContextMenu()
|
||||||
|
const onCopy = useCopy(children)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OTPItemContainer onContextMenu={onRightClick} onClick={onCopy} style={{}}>
|
||||||
|
{children}
|
||||||
|
</OTPItemContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ItemCount = styled(Container)`
|
||||||
|
opacity: 0.5;
|
||||||
|
user-select: none;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const OTP: React.FC<{
|
||||||
|
field: Pick<ItemSection.Concealed, "v">
|
||||||
|
}> = ({ field }) => {
|
||||||
|
const t = useTranslate()
|
||||||
|
const [show, setShow] = useState(false)
|
||||||
|
|
||||||
|
const { onRightClick, ContextMenuContainer, Item } = useItemFieldContextMenu()
|
||||||
|
const onToggle = useCallback(() => setShow(x => !x), [])
|
||||||
|
const fields = field.v.split(" ")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{show ? (
|
||||||
|
<div
|
||||||
|
onContextMenu={onRightClick}
|
||||||
|
onDoubleClick={() => setShow(x => !x)}
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--monospace)",
|
||||||
|
paddingTop: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fields.map((item, i) => (
|
||||||
|
<OTPItem key={i}>{item}</OTPItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ItemCount onContextMenu={onRightClick} onClick={onToggle}>
|
||||||
|
{fields.length} {fields.length === 1 ? t.noun.item : t.noun.items}
|
||||||
|
</ItemCount>
|
||||||
|
)}
|
||||||
|
<ContextMenuContainer>
|
||||||
|
<Item onClick={onToggle}>{show ? t.action.hide : t.action.show}</Item>
|
||||||
|
</ContextMenuContainer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
49
packages/web/src/components/ItemFieldValue/Password.tsx
Normal file
49
packages/web/src/components/ItemFieldValue/Password.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import type { ItemSection } from "opvault.js"
|
||||||
|
import { useCallback, useState } from "react"
|
||||||
|
import { useTranslate } from "../../i18n"
|
||||||
|
import { BigTextView } from "../BigTextView"
|
||||||
|
import { useItemFieldContextMenu } from "../ItemFieldContextMenu"
|
||||||
|
import { Container } from "./Container"
|
||||||
|
import { useCopy } from "./hooks"
|
||||||
|
|
||||||
|
export const Password: React.FC<{
|
||||||
|
field: Pick<ItemSection.Concealed, "v">
|
||||||
|
}> = ({ field }) => {
|
||||||
|
const t = useTranslate()
|
||||||
|
const [show, setShow] = useState(false)
|
||||||
|
const [bigText, showBigText] = useState(false)
|
||||||
|
|
||||||
|
const { onRightClick, ContextMenuContainer, Item } = useItemFieldContextMenu()
|
||||||
|
const onToggle = useCallback(() => setShow(x => !x), [])
|
||||||
|
const onCopy = useCopy(field.v)
|
||||||
|
const onOpenBigText = useCallback(() => {
|
||||||
|
showBigText(true)
|
||||||
|
}, [])
|
||||||
|
const onCloseBigText = useCallback(() => {
|
||||||
|
showBigText(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Container
|
||||||
|
onContextMenu={onRightClick}
|
||||||
|
onDoubleClick={() => setShow(x => !x)}
|
||||||
|
onClick={onCopy}
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--monospace)",
|
||||||
|
...(!show && { userSelect: "none" }),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{show ? field.v : "·".repeat(10)}
|
||||||
|
</Container>
|
||||||
|
{bigText && <BigTextView onClose={onCloseBigText}>{field.v}</BigTextView>}
|
||||||
|
<ContextMenuContainer>
|
||||||
|
<Item onClick={onCopy}>{t.action.copy}</Item>
|
||||||
|
<Item onClick={onToggle}>{show ? t.action.hide : t.action.show}</Item>
|
||||||
|
{!bigText && (
|
||||||
|
<Item onClick={onOpenBigText}>{t.action.show_in_big_characters}</Item>
|
||||||
|
)}
|
||||||
|
</ContextMenuContainer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
19
packages/web/src/components/ItemFieldValue/Text.tsx
Normal file
19
packages/web/src/components/ItemFieldValue/Text.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { useItemFieldContextMenu } from "../ItemFieldContextMenu"
|
||||||
|
import { Container } from "./Container"
|
||||||
|
import { useCopy } from "./hooks"
|
||||||
|
|
||||||
|
export const TextView: React.FC<{ value: string }> = ({ value }) => {
|
||||||
|
const { onRightClick, ContextMenuContainer, Item } = useItemFieldContextMenu()
|
||||||
|
const onCopy = useCopy(value)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Container onContextMenu={onRightClick} onClick={onCopy}>
|
||||||
|
{value}
|
||||||
|
</Container>
|
||||||
|
<ContextMenuContainer>
|
||||||
|
<Item onClick={onCopy}>Copier</Item>
|
||||||
|
</ContextMenuContainer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
14
packages/web/src/components/ItemFieldValue/hooks.ts
Normal file
14
packages/web/src/components/ItemFieldValue/hooks.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { useCallback } from "react"
|
||||||
|
import { useTranslate } from "../../i18n"
|
||||||
|
import { toast, ToastType } from "../Toast"
|
||||||
|
|
||||||
|
export function useCopy(text: string) {
|
||||||
|
const t = useTranslate()
|
||||||
|
return useCallback(() => {
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
toast({
|
||||||
|
type: ToastType.Secondary,
|
||||||
|
message: t.tips.copied_to_clipboard,
|
||||||
|
})
|
||||||
|
}, [text, t])
|
||||||
|
}
|
52
packages/web/src/components/ItemFieldValue/index.tsx
Normal file
52
packages/web/src/components/ItemFieldValue/index.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import type { ItemSection, ItemField } from "opvault.js"
|
||||||
|
import { FieldType } from "opvault.js"
|
||||||
|
import { ErrorBoundary } from "../ErrorBoundary"
|
||||||
|
import { Password } from "./Password"
|
||||||
|
import { OTP } from "./OTP"
|
||||||
|
import { MonthYear } from "./MonthYear"
|
||||||
|
import { DateView } from "./DateView"
|
||||||
|
import { TextView } from "./Text"
|
||||||
|
import { Address } from "./Address"
|
||||||
|
|
||||||
|
export const ItemFieldValue: React.FC<{
|
||||||
|
field: ItemSection.Any
|
||||||
|
}> = ({ field }) => {
|
||||||
|
if (field.v == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (field.k) {
|
||||||
|
case "concealed":
|
||||||
|
return field.n.startsWith("TOTP_") ? (
|
||||||
|
<OTP field={field} />
|
||||||
|
) : (
|
||||||
|
<Password field={field} />
|
||||||
|
)
|
||||||
|
case "monthYear":
|
||||||
|
return <MonthYear field={field} />
|
||||||
|
case "date":
|
||||||
|
return <DateView field={field} />
|
||||||
|
case "address":
|
||||||
|
return <Address field={field} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<TextView value={field.v} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ItemDetailsFieldValue: React.FC<{
|
||||||
|
field: ItemField
|
||||||
|
}> = ({ field }) => {
|
||||||
|
if (field.type === FieldType.Password || field.designation === "password") {
|
||||||
|
return <Password field={{ v: field.value } as any} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<TextView value={field.value!} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
98
packages/web/src/components/ItemList.tsx
Normal file
98
packages/web/src/components/ItemList.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { memo } from "react"
|
||||||
|
import styled from "@emotion/styled"
|
||||||
|
import { cx } from "@emotion/css"
|
||||||
|
import type { Item } from "opvault.js"
|
||||||
|
import { AiFillStar } from "react-icons/ai"
|
||||||
|
import { CategoryIcon } from "./CategoryIcon"
|
||||||
|
import { useTranslate } from "../i18n"
|
||||||
|
import { ItemNoTitle } from "../styles"
|
||||||
|
|
||||||
|
interface ListProps {
|
||||||
|
items: Item[]
|
||||||
|
selected?: Item
|
||||||
|
onSelect(item: Item): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Container = styled.div``
|
||||||
|
const List = styled.ol`
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
`
|
||||||
|
const ItemView = styled.li`
|
||||||
|
align-items: center;
|
||||||
|
cursor: default;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 35px 1fr;
|
||||||
|
padding: 5px 15px;
|
||||||
|
position: relative;
|
||||||
|
transition: background-color 0.1s;
|
||||||
|
user-select: none;
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--hover-background);
|
||||||
|
}
|
||||||
|
&.selected {
|
||||||
|
background-color: var(--selected-background);
|
||||||
|
}
|
||||||
|
&.trashed {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const ItemTitle = styled.div`
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ItemDescription = styled.div`
|
||||||
|
font-size: 95%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 230px;
|
||||||
|
&.empty {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const Icon = styled(CategoryIcon)`
|
||||||
|
font-size: 1.5em;
|
||||||
|
`
|
||||||
|
const Favorite = styled(AiFillStar)`
|
||||||
|
bottom: 10px;
|
||||||
|
display: inline-block;
|
||||||
|
fill: #fdcc0d;
|
||||||
|
left: 10px;
|
||||||
|
opacity: 0.9;
|
||||||
|
position: absolute;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const ItemList = memo<ListProps>(({ items, onSelect, selected }) => {
|
||||||
|
const t = useTranslate()
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<List>
|
||||||
|
{items.map(item => (
|
||||||
|
<ItemView
|
||||||
|
key={item.uuid}
|
||||||
|
onClick={() => onSelect(item)}
|
||||||
|
className={cx({
|
||||||
|
selected: selected?.uuid === item.uuid,
|
||||||
|
trashed: item.isDeleted,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Icon fill="#FFF" category={item.category} />
|
||||||
|
{!!item.fave && <Favorite />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ItemTitle>
|
||||||
|
{item.overview.title || <ItemNoTitle>{t.label.no_title}</ItemNoTitle>}
|
||||||
|
</ItemTitle>
|
||||||
|
<ItemDescription className={cx(!item.overview.ainfo && "empty")}>
|
||||||
|
{item.overview.ainfo || "-"}
|
||||||
|
</ItemDescription>
|
||||||
|
</div>
|
||||||
|
</ItemView>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
})
|
41
packages/web/src/components/ItemWarning.tsx
Normal file
41
packages/web/src/components/ItemWarning.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import styled from "@emotion/styled"
|
||||||
|
import type { Item } from "opvault.js"
|
||||||
|
import { useMemo, memo } from "react"
|
||||||
|
import { parseMonthYear } from "../utils"
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
background: #cdc7b2;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 15px;
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
background: #575345;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const ItemWarning = memo<{ item: Item }>(({ item }) => {
|
||||||
|
const isExpired = useMemo(() => {
|
||||||
|
const fields = item.details.sections?.flatMap(x => x.fields ?? [])
|
||||||
|
if (!fields?.length) return false
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
if (field.k === "monthYear") {
|
||||||
|
const { year, month } = parseMonthYear(field.v)
|
||||||
|
const now = new Date()
|
||||||
|
const currentYear = now.getFullYear()
|
||||||
|
return currentYear > year || (currentYear === year && now.getMonth() + 1 > month)
|
||||||
|
} else if (field.k === "date") {
|
||||||
|
const now = Date.now()
|
||||||
|
const fieldDate = new Date(field.v * 1000).valueOf()
|
||||||
|
return now > fieldDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}, [item])
|
||||||
|
|
||||||
|
if (isExpired) {
|
||||||
|
return <Container>Expired</Container>
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
})
|
98
packages/web/src/components/Modal.tsx
Normal file
98
packages/web/src/components/Modal.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import styled from "@emotion/styled"
|
||||||
|
import { useCallback, useEffect, useRef } from "react"
|
||||||
|
import { useEventListener } from "../utils/useEvent"
|
||||||
|
|
||||||
|
const ModalBackground = styled.div`
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(1px);
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 1;
|
||||||
|
`
|
||||||
|
const ModalBackground2 = styled.div`
|
||||||
|
z-index: 2;
|
||||||
|
width: 100%;
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
`
|
||||||
|
const ModalContainer = styled.dialog`
|
||||||
|
background: var(--page-background);
|
||||||
|
border-radius: 5px;
|
||||||
|
border: inherit;
|
||||||
|
box-shadow: rgba(0, 0, 0, 0.25) 0px 14px 28px, rgba(0, 0, 0, 0.22) 0px 10px 10px;
|
||||||
|
color: inherit;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&::backdrop {
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(1px);
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const ModalTitle = styled.div`
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
`
|
||||||
|
const ModalContent = styled.div`
|
||||||
|
padding: 15px 20px;
|
||||||
|
`
|
||||||
|
|
||||||
|
document.createElement("dialog")
|
||||||
|
|
||||||
|
export const Modal: React.FC<{
|
||||||
|
show: boolean
|
||||||
|
title: string
|
||||||
|
maxWidth?: number
|
||||||
|
onClose(): void
|
||||||
|
}> = ({ show, children, title, maxWidth = 700, onClose }) => {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null)
|
||||||
|
const onBackgroundClick = useCallback(
|
||||||
|
e => {
|
||||||
|
if (e.currentTarget === e.target) {
|
||||||
|
e.stopPropagation()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEventListener(document.body, "keyup").on(
|
||||||
|
e => {
|
||||||
|
if (show && e.key === "Escape") {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[show]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!show) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ModalBackground />
|
||||||
|
<ModalBackground2 onClick={onBackgroundClick}>
|
||||||
|
<ModalContainer open ref={dialogRef} style={{ maxWidth }}>
|
||||||
|
<ModalTitle>{title}</ModalTitle>
|
||||||
|
<ModalContent>{children}</ModalContent>
|
||||||
|
</ModalContainer>
|
||||||
|
</ModalBackground2>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
24
packages/web/src/components/TitleBar.tsx
Normal file
24
packages/web/src/components/TitleBar.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { memo } from "react"
|
||||||
|
import styled from "@emotion/styled"
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
background: linear-gradient(to bottom, #292929, #202020);
|
||||||
|
border-bottom: 1px solid #070707;
|
||||||
|
border-radius: 5px 5px 0 0;
|
||||||
|
height: var(--titlebar-height);
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
`
|
||||||
|
const Title = styled.div`
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-grow: 1;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const TitleBar = memo(() => (
|
||||||
|
<Container>
|
||||||
|
<Title>OPVault Viewer</Title>
|
||||||
|
</Container>
|
||||||
|
))
|
69
packages/web/src/components/Toast.tsx
Normal file
69
packages/web/src/components/Toast.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import styled from "@emotion/styled"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
export enum ToastType {
|
||||||
|
Regular = "regular",
|
||||||
|
Primary = "primary",
|
||||||
|
Secondary = "secondary",
|
||||||
|
Success = "success",
|
||||||
|
Danger = "danger",
|
||||||
|
Warning = "warning",
|
||||||
|
Info = "info",
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
message: string
|
||||||
|
type: ToastType
|
||||||
|
}
|
||||||
|
interface InternalMessage extends Message {
|
||||||
|
opacity: number
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export let toast: (message: Message) => void
|
||||||
|
|
||||||
|
const Container = styled.div``
|
||||||
|
const ToastContainer = styled.div`
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
right: 20px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0 0.5rem 1rem rgb(0 0 0 / 15%);
|
||||||
|
transition: opacity 1s ease-in-out, bottom 0.3s linear;
|
||||||
|
`
|
||||||
|
|
||||||
|
let lastId = 0
|
||||||
|
|
||||||
|
export const Toast: React.FC = () => {
|
||||||
|
const [list, setList] = useState<InternalMessage[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
toast = message => {
|
||||||
|
const newId = ++lastId
|
||||||
|
setList(list => list.concat({ ...message, id: newId, opacity: 1 }))
|
||||||
|
setTimeout(() => {
|
||||||
|
setList(list => list.map(x => (x.id === newId ? { ...x, opacity: 0 } : x)))
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
{list.map((message, i, { length }) => (
|
||||||
|
<ToastContainer
|
||||||
|
onTransitionEnd={e => {
|
||||||
|
if (e.propertyName === "opacity") {
|
||||||
|
setList(list => list.filter(x => x.id !== message.id))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
key={message.id}
|
||||||
|
style={{ opacity: message.opacity, bottom: 40 * (length - i) - 10 }}
|
||||||
|
className={`color-${message.type}`}
|
||||||
|
>
|
||||||
|
{message.message}
|
||||||
|
</ToastContainer>
|
||||||
|
))}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
@ -1,25 +1,40 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
// Modules to control application life and create native browser window
|
// Modules to control application life and create native browser window
|
||||||
// const path = require("path")
|
import { join } from "path"
|
||||||
const { app, BrowserWindow } = require("electron")
|
import { app, BrowserWindow, Menu } from "electron"
|
||||||
|
import "./ipc"
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
// Create the browser window.
|
// Create the browser window.
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 800,
|
width: 800,
|
||||||
height: 600,
|
height: 650,
|
||||||
|
// frame: false,
|
||||||
|
// transparent: true,
|
||||||
|
icon: join(__dirname, "../512x512.png"),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: true,
|
contextIsolation: true,
|
||||||
contextIsolation: false,
|
preload: join(__dirname, "preload.js"),
|
||||||
// preload: path.join(__dirname, "preload.js"),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// and load the index.html of the app.
|
mainWindow.webContents.session.enableNetworkEmulation({
|
||||||
mainWindow.loadFile("index.html")
|
offline: true,
|
||||||
|
})
|
||||||
|
|
||||||
// Open the DevTools.
|
Menu.setApplicationMenu(null)
|
||||||
// mainWindow.webContents.openDevTools()
|
|
||||||
|
// and load the index.html of the app.
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
mainWindow.loadURL("http://localhost:3000")
|
||||||
|
mainWindow.webContents.openDevTools()
|
||||||
|
} else {
|
||||||
|
mainWindow.loadFile("./web/index.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.DEBUG) {
|
||||||
|
mainWindow.webContents.openDevTools()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// This method will be called when Electron has finished
|
// This method will be called when Electron has finished
|
10
packages/web/src/electron/ipc-types.d.ts
vendored
Normal file
10
packages/web/src/electron/ipc-types.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import type { DirEntry } from "opvault.js/src/adapter"
|
||||||
|
|
||||||
|
export interface IPC {
|
||||||
|
showDirectoryPicker(): Promise<string | undefined>
|
||||||
|
pathExists(path: string): Promise<boolean>
|
||||||
|
readDir(path: string): Promise<DirEntry[]>
|
||||||
|
readFile(path: string): Promise<Uint8Array>
|
||||||
|
readTextFile(path: string): Promise<string>
|
||||||
|
writeFile(path: string, data: string): Promise<void>
|
||||||
|
}
|
54
packages/web/src/electron/ipc.ts
Normal file
54
packages/web/src/electron/ipc.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import fs, { promises } from "fs"
|
||||||
|
import { ipcMain, dialog } from "electron"
|
||||||
|
import { adapter } from "opvault.js/src/adapter/node"
|
||||||
|
import type { DirEntry } from "opvault.js/src/adapter"
|
||||||
|
import type { IPC } from "./ipc-types"
|
||||||
|
|
||||||
|
registerService({
|
||||||
|
async showDirectoryPicker() {
|
||||||
|
const result = await dialog.showOpenDialog({
|
||||||
|
properties: ["openDirectory", "treatPackageAsDirectory"],
|
||||||
|
})
|
||||||
|
if (result.canceled || !result.filePaths.length) return
|
||||||
|
return result.filePaths[0]
|
||||||
|
},
|
||||||
|
|
||||||
|
async pathExists(_, path) {
|
||||||
|
return fs.existsSync(path)
|
||||||
|
},
|
||||||
|
|
||||||
|
async readFile(_, path) {
|
||||||
|
return promises.readFile(path)
|
||||||
|
},
|
||||||
|
|
||||||
|
async readTextFile(_, path) {
|
||||||
|
return promises.readFile(path, "utf-8")
|
||||||
|
},
|
||||||
|
|
||||||
|
async writeFile(_, path, content) {
|
||||||
|
await promises.writeFile(path, content)
|
||||||
|
},
|
||||||
|
|
||||||
|
async readDir(_, path) {
|
||||||
|
const entries: DirEntry[] = []
|
||||||
|
for await (const dirent of adapter.fs.readDir(path)) {
|
||||||
|
entries.push(dirent)
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens to `channel`, when a new message arrives `listener` would be called
|
||||||
|
* with `listener(event, ...args)`
|
||||||
|
*/
|
||||||
|
function registerService(listeners: {
|
||||||
|
[K in keyof IPC]: (
|
||||||
|
event: Electron.IpcMainEvent,
|
||||||
|
...args: Parameters<IPC[K]>
|
||||||
|
) => ReturnType<IPC[K]>
|
||||||
|
}) {
|
||||||
|
for (const [key, value] of Object.entries(listeners)) {
|
||||||
|
ipcMain.handle(`service-${key}`, value as any)
|
||||||
|
}
|
||||||
|
}
|
5
packages/web/src/electron/preload.ts
Normal file
5
packages/web/src/electron/preload.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from "electron"
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld("ipcRenderer", {
|
||||||
|
invoke: ipcRenderer.invoke,
|
||||||
|
})
|
87
packages/web/src/i18n/index.tsx
Normal file
87
packages/web/src/i18n/index.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
import texts from "./texts.yml"
|
||||||
|
import { get, set, Key } from "../utils/localStorage"
|
||||||
|
|
||||||
|
const categories = Object.keys(texts)
|
||||||
|
|
||||||
|
const ALLOWED = new Set(["en", "fr", "ja"])
|
||||||
|
const SKIP_ITALIC = new Set(["zh_CN", "zh_TW", "ko", "ja"])
|
||||||
|
|
||||||
|
function getLocaleFromStorage() {
|
||||||
|
const key = get(Key.PREFERRED_LOCALE)
|
||||||
|
if (key && ALLOWED.has(key)) {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNavigatorLocale() {
|
||||||
|
if (typeof navigator !== "undefined") {
|
||||||
|
for (const lang of navigator.languages) {
|
||||||
|
if (ALLOWED.has(lang)) {
|
||||||
|
return lang
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEnvLocale() {
|
||||||
|
return getLocaleFromStorage() ?? getNavigatorLocale() ?? "en"
|
||||||
|
}
|
||||||
|
|
||||||
|
const LocaleContext = createContext<{
|
||||||
|
locale: string
|
||||||
|
setLocale(locale: string): void
|
||||||
|
}>(undefined!)
|
||||||
|
|
||||||
|
export const useLocaleContext = () => useContext(LocaleContext)
|
||||||
|
|
||||||
|
export function useTranslate() {
|
||||||
|
const { locale } = useContext(LocaleContext)
|
||||||
|
const getter = useCallback(
|
||||||
|
(category: string, key: string) => {
|
||||||
|
const obj = (texts as any)[category]
|
||||||
|
if (
|
||||||
|
process.env.NODE_ENV === "development" &&
|
||||||
|
!Object.prototype.hasOwnProperty.call(obj, key)
|
||||||
|
) {
|
||||||
|
throw new Error(`t.${key} does not exist.`)
|
||||||
|
}
|
||||||
|
return obj[key][locale]
|
||||||
|
},
|
||||||
|
[locale]
|
||||||
|
)
|
||||||
|
|
||||||
|
const t: {
|
||||||
|
[category in keyof typeof texts]: {
|
||||||
|
[key in keyof typeof texts[category]]: string
|
||||||
|
}
|
||||||
|
} = useMemo(
|
||||||
|
(): any =>
|
||||||
|
Object.fromEntries(
|
||||||
|
categories.map(category => [
|
||||||
|
category,
|
||||||
|
new Proxy({}, { get: (_, p: string) => getter(category, p) }),
|
||||||
|
])
|
||||||
|
),
|
||||||
|
[getter]
|
||||||
|
)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LocaleContextProvider = memo(({ children }) => {
|
||||||
|
const [locale, setLocale] = useState(getEnvLocale)
|
||||||
|
useEffect(() => {
|
||||||
|
set(Key.PREFERRED_LOCALE, locale)
|
||||||
|
document.documentElement.lang = locale
|
||||||
|
}, [locale])
|
||||||
|
const value = useMemo(() => ({ locale, setLocale }), [locale])
|
||||||
|
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
|
||||||
|
})
|
270
packages/web/src/i18n/texts.yml
Normal file
270
packages/web/src/i18n/texts.yml
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
# /* spellchecker: disable */
|
||||||
|
label:
|
||||||
|
app_name:
|
||||||
|
en: OPVault Viewer
|
||||||
|
fr: Lecteur de coffre OPVault
|
||||||
|
ja: OPVault ビューワー
|
||||||
|
|
||||||
|
choose_a_vault:
|
||||||
|
en: Pick a vault
|
||||||
|
fr: Choisir un coffre
|
||||||
|
ja: 保管庫を選ぶ
|
||||||
|
|
||||||
|
no_vault_selected:
|
||||||
|
en: No vault is selected.
|
||||||
|
fr: Aucun coffre n’est sélectionné.
|
||||||
|
ja: 選択した保管庫はありません。
|
||||||
|
|
||||||
|
last_updated:
|
||||||
|
en: Last Updated
|
||||||
|
fr: Dernière modification
|
||||||
|
ja: 更新日時
|
||||||
|
|
||||||
|
created_at:
|
||||||
|
en: Created At
|
||||||
|
fr: Créé
|
||||||
|
ja: 作成日時
|
||||||
|
|
||||||
|
password_placeholder:
|
||||||
|
en: Master Password
|
||||||
|
fr: Mot de passe principal
|
||||||
|
ja: マスターパスワード
|
||||||
|
|
||||||
|
username:
|
||||||
|
en: Username
|
||||||
|
fr: Nom d’utilisateur
|
||||||
|
ja: ユーザー名
|
||||||
|
|
||||||
|
password:
|
||||||
|
en: Password
|
||||||
|
fr: Mot de passe
|
||||||
|
ja: パスワード
|
||||||
|
|
||||||
|
no_title:
|
||||||
|
en: Untitled
|
||||||
|
fr: Sans titre
|
||||||
|
ja: 無題
|
||||||
|
|
||||||
|
settings:
|
||||||
|
en: Settings
|
||||||
|
fr: Préférences
|
||||||
|
ja: 設定
|
||||||
|
|
||||||
|
language:
|
||||||
|
en: Language
|
||||||
|
fr: Langue
|
||||||
|
ja: 言語
|
||||||
|
|
||||||
|
about_app:
|
||||||
|
en: About
|
||||||
|
fr: À propos
|
||||||
|
ja: バーション情報
|
||||||
|
|
||||||
|
category_all:
|
||||||
|
en: All
|
||||||
|
fr: Tous
|
||||||
|
ja: すべて
|
||||||
|
|
||||||
|
category_login:
|
||||||
|
en: Login
|
||||||
|
fr: Connexion
|
||||||
|
ja: ログイン
|
||||||
|
|
||||||
|
category_credit_card:
|
||||||
|
en: Credit Card
|
||||||
|
fr: Carte de crédit
|
||||||
|
ja: クレジットカード
|
||||||
|
|
||||||
|
category_secure_note:
|
||||||
|
en: Secure Note
|
||||||
|
fr: Note sécurisée
|
||||||
|
ja: セキュアノート
|
||||||
|
|
||||||
|
category_identity:
|
||||||
|
en: Identity
|
||||||
|
fr: Identité
|
||||||
|
ja: 個人情報
|
||||||
|
|
||||||
|
category_password:
|
||||||
|
en: Password
|
||||||
|
fr: Mot de passe
|
||||||
|
ja: パスワード
|
||||||
|
|
||||||
|
category_tombstone:
|
||||||
|
en: Tombstone
|
||||||
|
fr: Corbeille
|
||||||
|
ja: ゴミ箱
|
||||||
|
|
||||||
|
category_software_license:
|
||||||
|
en: Software License
|
||||||
|
fr: Licence de logiciel
|
||||||
|
ja: ソフトウェアライセンス
|
||||||
|
|
||||||
|
category_bank_account:
|
||||||
|
en: BankAccount
|
||||||
|
fr: Compte bancaire
|
||||||
|
ja: 銀行口座
|
||||||
|
|
||||||
|
category_database:
|
||||||
|
en: Database
|
||||||
|
fr: Base de données
|
||||||
|
ja: データベース
|
||||||
|
|
||||||
|
category_driver_license:
|
||||||
|
en: Driver License
|
||||||
|
fr: Permis de conduire
|
||||||
|
ja: 運転免許
|
||||||
|
|
||||||
|
category_outdoor_license:
|
||||||
|
en: Outdoor License
|
||||||
|
fr: Permis de chasse ou pêche
|
||||||
|
ja: 遊漁券及び狩猟免許
|
||||||
|
|
||||||
|
category_membership:
|
||||||
|
en: Membership
|
||||||
|
fr: Adhésion
|
||||||
|
ja: 会員資格
|
||||||
|
|
||||||
|
category_passport:
|
||||||
|
en: Passport
|
||||||
|
fr: Passeport
|
||||||
|
ja: 旅券
|
||||||
|
|
||||||
|
category_rewards:
|
||||||
|
en: Rewards
|
||||||
|
fr: Programme de fidélité
|
||||||
|
ja: ポイントサービス
|
||||||
|
|
||||||
|
category_ssn:
|
||||||
|
en: Social Security Numbers
|
||||||
|
fr: N° de sécurité sociale
|
||||||
|
ja: 社会保障番号
|
||||||
|
|
||||||
|
category_router:
|
||||||
|
en: Router
|
||||||
|
fr: Routeur sans fil
|
||||||
|
ja: Wi-Fiルーター
|
||||||
|
|
||||||
|
category_server:
|
||||||
|
en: Server
|
||||||
|
fr: Serveur
|
||||||
|
ja: サーバー
|
||||||
|
|
||||||
|
category_email:
|
||||||
|
en: Email
|
||||||
|
fr: Courriel
|
||||||
|
ja: メール
|
||||||
|
|
||||||
|
options:
|
||||||
|
sort_by_name:
|
||||||
|
en: Sort by Name
|
||||||
|
fr: Trier par nom
|
||||||
|
ja: 名前順
|
||||||
|
|
||||||
|
sort_by_created_at:
|
||||||
|
en: Sort by date created
|
||||||
|
fr: Trier par date de création
|
||||||
|
ja: 作成日時順
|
||||||
|
|
||||||
|
sort_by_updated_at:
|
||||||
|
en: Sort by date modified
|
||||||
|
fr: Trier par date de modification
|
||||||
|
ja: 更新日時順
|
||||||
|
|
||||||
|
enable_autolock:
|
||||||
|
en: Auto Lock
|
||||||
|
fr: Verrouillage automatique
|
||||||
|
ja: 自動ロック
|
||||||
|
|
||||||
|
ui_font:
|
||||||
|
en: Interface font
|
||||||
|
fr: Police de l’interface
|
||||||
|
ja: フォント
|
||||||
|
|
||||||
|
monospace:
|
||||||
|
en: Monospace font
|
||||||
|
fr: Police monospace
|
||||||
|
ja: 等幅フォント
|
||||||
|
|
||||||
|
noun:
|
||||||
|
vault:
|
||||||
|
en: vault
|
||||||
|
fr: coffre
|
||||||
|
ja: 保管庫
|
||||||
|
|
||||||
|
tags:
|
||||||
|
en: tags
|
||||||
|
fr: mots-clés
|
||||||
|
ja: キーワード
|
||||||
|
|
||||||
|
seconds:
|
||||||
|
en: seconds
|
||||||
|
fr: secondes
|
||||||
|
ja: 秒
|
||||||
|
|
||||||
|
item:
|
||||||
|
en: item
|
||||||
|
fr: élément
|
||||||
|
ja: アイテム
|
||||||
|
|
||||||
|
items:
|
||||||
|
en: items
|
||||||
|
fr: éléments
|
||||||
|
ja: アイテム
|
||||||
|
|
||||||
|
action:
|
||||||
|
lock:
|
||||||
|
en: Lock
|
||||||
|
fr: Vérouiller
|
||||||
|
ja: ロック
|
||||||
|
|
||||||
|
unlock:
|
||||||
|
en: Unlock
|
||||||
|
fr: Déverouiller
|
||||||
|
ja: ロック解除
|
||||||
|
|
||||||
|
copy:
|
||||||
|
en: Copy
|
||||||
|
fr: Copier
|
||||||
|
ja: コピー
|
||||||
|
|
||||||
|
hide:
|
||||||
|
en: Hide
|
||||||
|
fr: Cacher
|
||||||
|
ja: 非表示
|
||||||
|
|
||||||
|
show:
|
||||||
|
en: Show
|
||||||
|
fr: Afficher
|
||||||
|
ja: 表示
|
||||||
|
|
||||||
|
show_in_big_characters:
|
||||||
|
en: Show in large characters
|
||||||
|
fr: Afficher en gros caractères
|
||||||
|
ja: 大きく表示
|
||||||
|
|
||||||
|
go_back:
|
||||||
|
en: Back
|
||||||
|
fr: Revenir
|
||||||
|
ja: 前に戻る
|
||||||
|
|
||||||
|
go_forward:
|
||||||
|
en: Forward
|
||||||
|
fr: Avancer
|
||||||
|
ja: 次に進む
|
||||||
|
|
||||||
|
clear_history:
|
||||||
|
en: Clear history
|
||||||
|
fr: Effacer l’historique
|
||||||
|
ja: 閲覧履歴を消す
|
||||||
|
|
||||||
|
tips:
|
||||||
|
automatically_lock_after_inactivity:
|
||||||
|
en: Automatically lock after inactivity
|
||||||
|
fr: Verouiller automatiquement après un temps d’inactivité
|
||||||
|
ja: 一定時間使わないときに自動的にロックする
|
||||||
|
|
||||||
|
copied_to_clipboard:
|
||||||
|
en: Copied to clipboard
|
||||||
|
fr: Copié dans le presse-papier
|
||||||
|
ja: クリップボードへコピーしました
|
172
packages/web/src/index.scss
Normal file
172
packages/web/src/index.scss
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
@mixin scheme($property, $light-value, $dark-value) {
|
||||||
|
#{$property}: $light-value;
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
#{$property}: $dark-value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: transparent;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 15px;
|
||||||
|
font-family: var(--sans-serif);
|
||||||
|
}
|
||||||
|
:root {
|
||||||
|
--border-color: #e3e3e3;
|
||||||
|
--color: #000;
|
||||||
|
--hover-background: #ddd;
|
||||||
|
--label-background: #ddd;
|
||||||
|
--monospace: D2Coding, "source-code-pro", Menlo, Monaco, Consolas, "Courier New",
|
||||||
|
monospace;
|
||||||
|
--page-background: #fff;
|
||||||
|
--sans-serif: -apple-system, BlinkMacSystemFont, system-ui, "Roboto", "Oxygen",
|
||||||
|
"Cantarell", "Droid Sans", "Helvetica Neue", "Noto Sans CJK JP", sans-serif;
|
||||||
|
--selected-background: #d5d5d5;
|
||||||
|
--red-border: #fa144d;
|
||||||
|
--titlebar-height: 0px;
|
||||||
|
--titlebar-height: 46px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root.mac {
|
||||||
|
--page-background: #f7f7f7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--color: #fff;
|
||||||
|
--label-background: #353535;
|
||||||
|
--selected-background: #353535;
|
||||||
|
--border-color: #333;
|
||||||
|
--hover-background: #222;
|
||||||
|
--page-background: #292929;
|
||||||
|
--red-border: #9e1641;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
background-color: var(--page-background);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre,
|
||||||
|
code {
|
||||||
|
font-family: var(--monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
@mixin input {
|
||||||
|
@include scheme(background-color, #fff, #2d2d2d);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #fff;
|
||||||
|
@include scheme(border-color, #cdc7c2, #1b1b1b);
|
||||||
|
transition: 0.1s;
|
||||||
|
&:focus {
|
||||||
|
@include scheme(border-color, #3584e480, #15539e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="search"],
|
||||||
|
input[type="text"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="password"] {
|
||||||
|
@include input;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: inherit;
|
||||||
|
outline: none;
|
||||||
|
padding: 7px 8px;
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input[type="checkbox" i] {
|
||||||
|
@include input;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05),
|
||||||
|
inset 0px -15px 10px -12px rgba(0, 0, 0, 0.05);
|
||||||
|
padding: 9px;
|
||||||
|
border-radius: 3px;
|
||||||
|
appearance: none;
|
||||||
|
position: relative;
|
||||||
|
&:checked:after {
|
||||||
|
content: "\2714";
|
||||||
|
font-size: 1rem;
|
||||||
|
position: absolute;
|
||||||
|
top: 0px;
|
||||||
|
left: 3px;
|
||||||
|
color: var(--color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
select,
|
||||||
|
.button {
|
||||||
|
@include scheme(background-color, #f6f5f4, #333);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid;
|
||||||
|
@include scheme(border-color, #cdc7c2, #1b1b1b);
|
||||||
|
color: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
&:hover {
|
||||||
|
@include scheme(background-color, #f9f9f8, #363636);
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
@include scheme(background-color, #d6d1cd, #292929);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 8px 15px;
|
||||||
|
box-shadow: rgb(0 0 0 / 7%) 0px 1px 2px;
|
||||||
|
transition: 0.1s;
|
||||||
|
}
|
||||||
|
button[type="submit"] {
|
||||||
|
background-color: #15539e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #region color
|
||||||
|
.color-primary,
|
||||||
|
.color-secondary,
|
||||||
|
.color-info,
|
||||||
|
.color-danger {
|
||||||
|
@include scheme(color, #fff, #fafafa);
|
||||||
|
}
|
||||||
|
.color-success,
|
||||||
|
.color-warning {
|
||||||
|
@include scheme(color, #000, #111);
|
||||||
|
}
|
||||||
|
.color-primary {
|
||||||
|
@include scheme(background-color, #0b5ed7, #375a7f);
|
||||||
|
}
|
||||||
|
.color-secondary {
|
||||||
|
@include scheme(background-color, #6c757d, #626262);
|
||||||
|
}
|
||||||
|
.color-success {
|
||||||
|
@include scheme(background-color, #198754, #00bc8c);
|
||||||
|
}
|
||||||
|
.color-info {
|
||||||
|
@include scheme(background-color, #0dcaf0, #17a2b8);
|
||||||
|
}
|
||||||
|
.color-warning {
|
||||||
|
@include scheme(background-color, #ffc107, #f39c12);
|
||||||
|
}
|
||||||
|
.color-danger {
|
||||||
|
@include scheme(background-color, #dc3545, #e74c3c);
|
||||||
|
}
|
||||||
|
// #endregion
|
24
packages/web/src/main.tsx
Normal file
24
packages/web/src/main.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { render } from "react-dom"
|
||||||
|
import { App } from "./App"
|
||||||
|
import { LocaleContextProvider } from "./i18n"
|
||||||
|
import { SideEffect } from "./SideEffect"
|
||||||
|
import { Toast } from "./components/Toast"
|
||||||
|
import "./index.scss"
|
||||||
|
|
||||||
|
if (navigator.platform === "MacIntel") {
|
||||||
|
document.documentElement.classList.add("mac")
|
||||||
|
}
|
||||||
|
|
||||||
|
const Root: React.FC = () => (
|
||||||
|
<React.StrictMode>
|
||||||
|
{/* <TitleBar /> */}
|
||||||
|
<LocaleContextProvider>
|
||||||
|
<SideEffect />
|
||||||
|
<App />
|
||||||
|
<Toast />
|
||||||
|
</LocaleContextProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
|
|
||||||
|
render(<Root />, document.getElementById("app"))
|
3
packages/web/src/modules.d.ts
vendored
Normal file
3
packages/web/src/modules.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
declare module "react-idle-timer/modern" {
|
||||||
|
export * from "react-idle-timer/dist/modern"
|
||||||
|
}
|
112
packages/web/src/pages/Vault.tsx
Normal file
112
packages/web/src/pages/Vault.tsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import styled from "@emotion/styled"
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import type { Vault, Item } from "opvault.js"
|
||||||
|
import { AiOutlineStar } from "react-icons/ai"
|
||||||
|
import { FiLock } from "react-icons/fi"
|
||||||
|
import { Si1Password } from "react-icons/si"
|
||||||
|
import { BsGear } from "react-icons/bs"
|
||||||
|
import { useTranslate } from "../i18n/index"
|
||||||
|
import { Settings } from "../settings"
|
||||||
|
import { FilteredVaultView } from "../components/FilteredVaultView"
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
display: flex;
|
||||||
|
height: calc(100vh - var(--titlebar-height));
|
||||||
|
`
|
||||||
|
const TabContainer = styled.div`
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
width: 54px;
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
background: #222;
|
||||||
|
border-right-color: transparent;
|
||||||
|
}
|
||||||
|
&&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const TabButton = styled.button<{ active?: boolean }>`
|
||||||
|
align-items: center;
|
||||||
|
background: ${p => (p.active ? "var(--selected-background)" : "transparent")};
|
||||||
|
border-radius: ${p => (p.active ? 0 : 3)}px;
|
||||||
|
border: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
display: inline-flex;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 1.4666em;
|
||||||
|
padding: 10px 14px;
|
||||||
|
${p => p.active && "&:hover { background: var(--selected-background); }"}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
--selected-background: #1c1c1c;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const TabContainerMain = styled.div`
|
||||||
|
flex-grow: 1;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const VaultView: React.FC<{ vault: Vault; onLock(): void }> = ({
|
||||||
|
vault,
|
||||||
|
onLock,
|
||||||
|
}) => {
|
||||||
|
const [tab, setTab] = useState(Tab.All)
|
||||||
|
const [items, setItems] = useState<Item[]>(() => [])
|
||||||
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
const t = useTranslate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
arrayFrom(vault.values()).then(setItems)
|
||||||
|
}, [vault])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<TabContainer>
|
||||||
|
<TabContainerMain>
|
||||||
|
<TabButton active={tab === Tab.All} onClick={() => setTab(Tab.All)}>
|
||||||
|
<Si1Password />
|
||||||
|
</TabButton>
|
||||||
|
<TabButton active={tab === Tab.Favorites} onClick={() => setTab(Tab.Favorites)}>
|
||||||
|
<AiOutlineStar />
|
||||||
|
</TabButton>
|
||||||
|
</TabContainerMain>
|
||||||
|
<TabButton onClick={onLock} title={t.action.lock}>
|
||||||
|
<FiLock />
|
||||||
|
</TabButton>
|
||||||
|
<TabButton onClick={() => setShowSettings(true)} title={t.label.settings}>
|
||||||
|
<BsGear />
|
||||||
|
</TabButton>
|
||||||
|
</TabContainer>
|
||||||
|
|
||||||
|
{tab === Tab.All ? (
|
||||||
|
<FilteredVaultView items={items} />
|
||||||
|
) : tab === Tab.Favorites ? (
|
||||||
|
<FavoriteItemsView items={items} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Settings show={showSettings} onHide={() => setShowSettings(false)} />
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FavoriteItemsView: React.FC<{ items: Item[] }> = ({ items }) => {
|
||||||
|
const favorites = useMemo(
|
||||||
|
() => items.filter(x => x.fave).sort((a, b) => a.fave - b.fave),
|
||||||
|
[items]
|
||||||
|
)
|
||||||
|
return <FilteredVaultView items={favorites} />
|
||||||
|
}
|
||||||
|
|
||||||
|
async function arrayFrom<T>(generator: AsyncGenerator<T, void, unknown>) {
|
||||||
|
const list: T[] = []
|
||||||
|
for await (const value of generator) {
|
||||||
|
list.push(value)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Tab {
|
||||||
|
All,
|
||||||
|
Favorites,
|
||||||
|
}
|
148
packages/web/src/pages/VaultPicker/Picker.tsx
Normal file
148
packages/web/src/pages/VaultPicker/Picker.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import styled from "@emotion/styled"
|
||||||
|
import { css } from "@emotion/css"
|
||||||
|
import { useCallback, useMemo, memo, useState } from "react"
|
||||||
|
import { Si1Password } from "react-icons/si"
|
||||||
|
import { FaFolderOpen } from "react-icons/fa"
|
||||||
|
import { ImCross } from "react-icons/im"
|
||||||
|
import { MdClearAll } from "react-icons/md"
|
||||||
|
import { BsGear, BsInfoCircle } from "react-icons/bs"
|
||||||
|
import { openDirectory } from "../../utils/ipc-adapter"
|
||||||
|
import { useTranslate } from "../../i18n"
|
||||||
|
import { Key, useStorage } from "../../utils/localStorage"
|
||||||
|
import { Settings } from "../../settings"
|
||||||
|
import { About } from "../../about"
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
padding: 100px;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
`
|
||||||
|
const List = styled.ul`
|
||||||
|
list-style-type: none;
|
||||||
|
padding-inline-start: 0;
|
||||||
|
`
|
||||||
|
const Item = styled.li`
|
||||||
|
align-items: center;
|
||||||
|
cursor: default;
|
||||||
|
display: flex;
|
||||||
|
padding: 8px 10px;
|
||||||
|
user-select: none;
|
||||||
|
&:not(:hover):not(:active) {
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const icon = css`
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin-right: 10px;
|
||||||
|
`
|
||||||
|
const Text = styled.div`
|
||||||
|
flex-grow: 1;
|
||||||
|
`
|
||||||
|
const Hr = styled.hr`
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
`
|
||||||
|
const DeleteItem = styled(ImCross)`
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.7em;
|
||||||
|
opacity: 0;
|
||||||
|
${Item}:hover & {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const NonCriticalPath = styled.span`
|
||||||
|
opacity: 0.4;
|
||||||
|
`
|
||||||
|
|
||||||
|
const Path = memo(({ children }: { children: string }) => {
|
||||||
|
const segments = useMemo(() => children.split("/"), [children])
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{segments.map((seg, i, { length }) =>
|
||||||
|
i < length - 1 ? (
|
||||||
|
<NonCriticalPath key={i}>{seg}/</NonCriticalPath>
|
||||||
|
) : (
|
||||||
|
<span key={i}>{seg}</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const enum Modal {
|
||||||
|
None,
|
||||||
|
Settings,
|
||||||
|
About,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PickOPVault: React.FC<{
|
||||||
|
setPath(path: string): void
|
||||||
|
}> = ({ setPath }) => {
|
||||||
|
const t = useTranslate()
|
||||||
|
const [modal, setModal] = useState(Modal.None)
|
||||||
|
const [list, $setList] = useStorage(Key.RECENTLY_OPENED_VAULTS)
|
||||||
|
|
||||||
|
const clearHistory = useCallback(() => {
|
||||||
|
$setList([])
|
||||||
|
}, [$setList])
|
||||||
|
|
||||||
|
const setList = useCallback(
|
||||||
|
(fn: (value: Set<string>) => void) => {
|
||||||
|
$setList(list => {
|
||||||
|
const set = new Set(list)
|
||||||
|
fn(set)
|
||||||
|
return Array.from(set)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[$setList]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onClick = useCallback(async () => {
|
||||||
|
const path = await openDirectory()
|
||||||
|
if (path) {
|
||||||
|
setPath(path)
|
||||||
|
setList(set => set.add(path))
|
||||||
|
}
|
||||||
|
}, [setPath, setList])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<List>
|
||||||
|
<Item className="button" onClick={onClick}>
|
||||||
|
<FaFolderOpen className={icon} />
|
||||||
|
{t.label.choose_a_vault}…
|
||||||
|
</Item>
|
||||||
|
{list.map((item, i) => (
|
||||||
|
<Item className="button" onClick={() => setPath(item)} key={i}>
|
||||||
|
<Si1Password className={icon} />
|
||||||
|
<Text>
|
||||||
|
<Path>{item}</Path>
|
||||||
|
</Text>
|
||||||
|
<DeleteItem onClick={() => setList(list => list.delete(item))} />
|
||||||
|
</Item>
|
||||||
|
))}
|
||||||
|
{list.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Hr />
|
||||||
|
<Item className="button" onClick={() => clearHistory()}>
|
||||||
|
<MdClearAll className={icon} />
|
||||||
|
{t.action.clear_history}
|
||||||
|
</Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Item className="button" onClick={() => setModal(Modal.Settings)}>
|
||||||
|
<BsGear className={icon} />
|
||||||
|
{t.label.settings}
|
||||||
|
</Item>
|
||||||
|
<Item className="button" onClick={() => setModal(Modal.About)}>
|
||||||
|
<BsInfoCircle className={icon} />
|
||||||
|
{t.label.about_app}
|
||||||
|
</Item>
|
||||||
|
</List>
|
||||||
|
|
||||||
|
<Settings show={modal === Modal.Settings} onHide={() => setModal(Modal.None)} />
|
||||||
|
<About show={modal === Modal.About} onHide={() => setModal(Modal.None)} />
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
153
packages/web/src/pages/VaultPicker/Unlock.tsx
Normal file
153
packages/web/src/pages/VaultPicker/Unlock.tsx
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import type { OnePassword } from "opvault.js"
|
||||||
|
import styled from "@emotion/styled"
|
||||||
|
import React, { useCallback, useEffect, useState } from "react"
|
||||||
|
import { IoMdArrowRoundBack } from "react-icons/io"
|
||||||
|
import { FaUnlock } from "react-icons/fa"
|
||||||
|
import { useTranslate } from "../../i18n"
|
||||||
|
import { useWrongPasswordFeedback } from "../../utils/useWrongPasswordFeedback"
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
padding: 20px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 500px;
|
||||||
|
`
|
||||||
|
const BackButton = styled.button`
|
||||||
|
&& {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
font-size: 2em;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
&:hover {
|
||||||
|
svg path {
|
||||||
|
fill: var(--selected-background);
|
||||||
|
transition: 0.2s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const Input = styled.input`
|
||||||
|
box-shadow: inset 0 2px 2px rgb(0 0 0 / 8%);
|
||||||
|
font-size: 1.5em;
|
||||||
|
width: calc(95.5% - 60px);
|
||||||
|
&& {
|
||||||
|
border-radius: 10px;
|
||||||
|
border-width: 1px;
|
||||||
|
padding: 15px 20px;
|
||||||
|
padding-right: 60px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const Select = styled.select`
|
||||||
|
float: right;
|
||||||
|
`
|
||||||
|
const Submit = styled.button`
|
||||||
|
font-size: 1.8em;
|
||||||
|
&& {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
svg path {
|
||||||
|
fill: var(--color);
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
transition: 0.2s;
|
||||||
|
}
|
||||||
|
text-align: center;
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 5px;
|
||||||
|
`
|
||||||
|
const VaultPath = styled.div`
|
||||||
|
margin-top: 15px;
|
||||||
|
opacity: 0.7;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const Unlock: React.FC<{
|
||||||
|
instance: OnePassword
|
||||||
|
vaultPath: string
|
||||||
|
onUnlock(profile: string, password: string): Promise<boolean>
|
||||||
|
onReturn(): void
|
||||||
|
}> = ({ onUnlock, onReturn, instance, vaultPath }) => {
|
||||||
|
const t = useTranslate()
|
||||||
|
const [profiles, setProfiles] = useState<string[]>(() => [])
|
||||||
|
const [profile, setProfile] = useState<string>()
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
|
||||||
|
const [{ animation, error }, setFeedback] = useWrongPasswordFeedback()
|
||||||
|
|
||||||
|
const unlock = useCallback(
|
||||||
|
(e?: React.FormEvent) => {
|
||||||
|
e?.preventDefault()
|
||||||
|
|
||||||
|
if (!profile) return
|
||||||
|
onUnlock(profile, password).then(setFeedback)
|
||||||
|
setPassword("")
|
||||||
|
},
|
||||||
|
[onUnlock, profile, password]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onKeyUp = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
unlock()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[unlock]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
instance.getProfileNames().then(profiles => {
|
||||||
|
setProfiles(profiles)
|
||||||
|
setProfile(profiles[0])
|
||||||
|
})
|
||||||
|
}, [instance])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<div style={{ marginBottom: 10 }}>
|
||||||
|
<BackButton onClick={onReturn} title={t.action.go_back}>
|
||||||
|
<IoMdArrowRoundBack />
|
||||||
|
</BackButton>
|
||||||
|
<Select
|
||||||
|
title={t.noun.vault}
|
||||||
|
value={profile}
|
||||||
|
onChange={e => setProfile(e.currentTarget.value)}
|
||||||
|
>
|
||||||
|
{profiles.map(p => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{t.noun.vault}: {p}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div style={{ margin: "10px 0", position: "relative" }}>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.currentTarget.value)}
|
||||||
|
placeholder={t.label.password_placeholder}
|
||||||
|
onKeyUp={onKeyUp}
|
||||||
|
style={{ animation, borderColor: error ? "var(--red-border)" : undefined }}
|
||||||
|
/>
|
||||||
|
<Submit
|
||||||
|
type="submit"
|
||||||
|
disabled={!profile || !password}
|
||||||
|
onClick={unlock}
|
||||||
|
title={t.action.unlock}
|
||||||
|
style={{ animation }}
|
||||||
|
>
|
||||||
|
<FaUnlock />
|
||||||
|
</Submit>
|
||||||
|
</div>
|
||||||
|
<VaultPath>{vaultPath}</VaultPath>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
78
packages/web/src/pages/VaultPicker/index.tsx
Normal file
78
packages/web/src/pages/VaultPicker/index.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import type { Vault } from "opvault.js"
|
||||||
|
import { OnePassword } from "opvault.js"
|
||||||
|
import { Unlock } from "./Unlock"
|
||||||
|
import { electronAdapter } from "../../utils/ipc-adapter"
|
||||||
|
import { get, remove, set, Key } from "../../utils/localStorage"
|
||||||
|
import { PickOPVault } from "./Picker"
|
||||||
|
|
||||||
|
interface VaultPickerProps {
|
||||||
|
instance: OnePassword | undefined
|
||||||
|
setInstance(value?: OnePassword): void
|
||||||
|
vault: Vault | undefined
|
||||||
|
setVault(vault?: Vault): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VaultPicker: React.FC<VaultPickerProps> = ({
|
||||||
|
instance,
|
||||||
|
setInstance,
|
||||||
|
vault,
|
||||||
|
setVault,
|
||||||
|
}) => {
|
||||||
|
const [vaultPath, setVaultPath] = useState("")
|
||||||
|
|
||||||
|
const unlock = useCallback(
|
||||||
|
async (profile: string, password: string) => {
|
||||||
|
const vault = await instance!.getProfile(profile!)
|
||||||
|
try {
|
||||||
|
await vault.unlock(password)
|
||||||
|
setVault(vault)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[instance, setVault]
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearInstance = useCallback(() => {
|
||||||
|
setVaultPath("")
|
||||||
|
setInstance(undefined)
|
||||||
|
}, [setInstance])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const existingPath = get(Key.LAST_VAULT_PATH)
|
||||||
|
if (existingPath != null) {
|
||||||
|
setVaultPath(existingPath)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (vaultPath) {
|
||||||
|
const instance = new OnePassword({
|
||||||
|
path: vaultPath,
|
||||||
|
adapter: electronAdapter,
|
||||||
|
})
|
||||||
|
setInstance(instance)
|
||||||
|
set(Key.LAST_VAULT_PATH, vaultPath)
|
||||||
|
} else {
|
||||||
|
setInstance(undefined)
|
||||||
|
remove(Key.LAST_VAULT_PATH)
|
||||||
|
}
|
||||||
|
}, [vaultPath, setInstance])
|
||||||
|
|
||||||
|
if (!instance) {
|
||||||
|
return <PickOPVault setPath={setVaultPath} />
|
||||||
|
}
|
||||||
|
if (!vault) {
|
||||||
|
return (
|
||||||
|
<Unlock
|
||||||
|
vaultPath={vaultPath}
|
||||||
|
onReturn={clearInstance}
|
||||||
|
instance={instance}
|
||||||
|
onUnlock={unlock}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
117
packages/web/src/settings/index.tsx
Normal file
117
packages/web/src/settings/index.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import styled from "@emotion/styled"
|
||||||
|
import { Modal } from "../components/Modal"
|
||||||
|
import { useLocaleContext, useTranslate } from "../i18n"
|
||||||
|
import { Key, useStorage } from "../utils/localStorage"
|
||||||
|
|
||||||
|
const FormItem = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 13px;
|
||||||
|
`
|
||||||
|
const FormLabel = styled.div`
|
||||||
|
width: 120px;
|
||||||
|
`
|
||||||
|
const FormValue = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
position: relative;
|
||||||
|
min-width: 200px;
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const Checkbox = styled.input`
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 8px;
|
||||||
|
`
|
||||||
|
const Input = styled.input`
|
||||||
|
width: 100%;
|
||||||
|
`
|
||||||
|
|
||||||
|
const GhostLabel = styled.div`
|
||||||
|
opacity: 0.5;
|
||||||
|
position: absolute;
|
||||||
|
left: 37px;
|
||||||
|
pointer-events: none;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const Settings: React.FC<{
|
||||||
|
show: boolean
|
||||||
|
onHide(): void
|
||||||
|
}> = ({ show, onHide }) => {
|
||||||
|
const { locale, setLocale } = useLocaleContext()
|
||||||
|
const t = useTranslate()
|
||||||
|
|
||||||
|
const [enableAutoLock, setEnableAutoLock] = useStorage(Key.ENABLE_AUTO_LOCK)
|
||||||
|
const [autolockAfter, setAutolockAfter] = useStorage(Key.AUTO_LOCK_AFTER)
|
||||||
|
const [uiFont, setUIFont] = useStorage(Key.UI_FONT)
|
||||||
|
const [monoFont, setMonoFont] = useStorage(Key.MONOSPACE_FONT)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={show} title={t.label.settings} onClose={onHide}>
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t.label.language}</FormLabel>
|
||||||
|
<FormValue>
|
||||||
|
<select
|
||||||
|
title={t.label.language}
|
||||||
|
value={locale}
|
||||||
|
onChange={e => setLocale(e.currentTarget.value)}
|
||||||
|
>
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="fr">Français</option>
|
||||||
|
<option value="ja">日本語</option>
|
||||||
|
</select>
|
||||||
|
</FormValue>
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel title={t.tips.automatically_lock_after_inactivity}>
|
||||||
|
{t.options.enable_autolock}
|
||||||
|
</FormLabel>
|
||||||
|
<FormValue>
|
||||||
|
<Checkbox
|
||||||
|
type="checkbox"
|
||||||
|
checked={enableAutoLock}
|
||||||
|
onChange={e => setEnableAutoLock(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={autolockAfter}
|
||||||
|
onChange={e => setAutolockAfter(e.target.valueAsNumber)}
|
||||||
|
disabled={!enableAutoLock}
|
||||||
|
min={5}
|
||||||
|
/>
|
||||||
|
<GhostLabel>
|
||||||
|
<span style={{ opacity: 0 }}>{autolockAfter} </span>
|
||||||
|
{t.noun.seconds}
|
||||||
|
</GhostLabel>
|
||||||
|
</FormValue>
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t.options.ui_font}</FormLabel>
|
||||||
|
<FormValue>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={uiFont}
|
||||||
|
onChange={e => setUIFont(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</FormValue>
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t.options.monospace}</FormLabel>
|
||||||
|
<FormValue>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={monoFont}
|
||||||
|
onChange={e => setMonoFont(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</FormValue>
|
||||||
|
</FormItem>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
39
packages/web/src/styles.ts
Normal file
39
packages/web/src/styles.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { css } from "@emotion/css"
|
||||||
|
import styled from "@emotion/styled"
|
||||||
|
|
||||||
|
export const ItemNoTitle = styled.span`
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: italic;
|
||||||
|
[lang^="zh"],
|
||||||
|
[lang="ko"],
|
||||||
|
[lang="ja"] & {
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const scrollbar = css`
|
||||||
|
&&::-webkit-scrollbar {
|
||||||
|
width: 7px;
|
||||||
|
}
|
||||||
|
&&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
&&:hover,
|
||||||
|
&&:active {
|
||||||
|
&&::-webkit-scrollbar-thumb {
|
||||||
|
background: #8883;
|
||||||
|
transition: 0.1s;
|
||||||
|
border-radius: 6px;
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
background: #6663;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #ddd;
|
||||||
|
transition: 0.1s;
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
5
packages/web/src/utils/index.ts
Normal file
5
packages/web/src/utils/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export function parseMonthYear(v: number) {
|
||||||
|
const year = Math.floor(v / 100)
|
||||||
|
const month = v % 100
|
||||||
|
return { year, month }
|
||||||
|
}
|
31
packages/web/src/utils/ipc-adapter.ts
Normal file
31
packages/web/src/utils/ipc-adapter.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import type { Adapter } from "opvault.js/src/adapter"
|
||||||
|
import type { IPC } from "../electron/ipc-types"
|
||||||
|
import { memoize } from "./memoize"
|
||||||
|
|
||||||
|
declare const ipcRenderer: Electron.IpcRenderer
|
||||||
|
|
||||||
|
export async function openDirectory() {
|
||||||
|
return ipc.showDirectoryPicker()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const electronAdapter: Adapter = {
|
||||||
|
fs: {
|
||||||
|
exists: path => ipc.pathExists(path),
|
||||||
|
readFile: path => ipc.readFile(path),
|
||||||
|
readTextFile: path => ipc.readTextFile(path),
|
||||||
|
async *readDir(path) {
|
||||||
|
yield* await ipc.readDir(path)
|
||||||
|
},
|
||||||
|
writeTextFile: (path, data) => ipc.writeFile(path, data),
|
||||||
|
},
|
||||||
|
subtle: crypto.subtle,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipc = new Proxy<IPC>({} as any, {
|
||||||
|
get: memoize(
|
||||||
|
(_, channel: string) =>
|
||||||
|
(...args: any[]) =>
|
||||||
|
ipcRenderer.invoke(`service-${channel}`, ...args),
|
||||||
|
(_, name) => name
|
||||||
|
),
|
||||||
|
})
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user