Compare commits
8 Commits
1.0.0-beta
...
master
Author | SHA1 | Date | |
---|---|---|---|
|
904b11b7b7 | ||
|
43bfb6715c | ||
|
6dfbe2abda | ||
|
a06d5189de | ||
|
16575b6739 | ||
|
ac8745dbdc | ||
|
4ff19130b9 | ||
|
2720f5c041 |
@ -1,10 +1,10 @@
|
||||
# opvault.js
|
||||
|
||||

|
||||

|
||||
|
||||
## Lecteur de coffres OnePassword libre
|
||||
|
||||
Lire 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.
|
||||
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
|
||||
|
||||
@ -25,8 +25,11 @@ 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
|
||||
mv onepassword_data freddy-2013-12-04.opvault
|
||||
|
||||
# Run tests
|
||||
pnpm run test
|
||||
```
|
||||
|
||||
|
64
package.json
64
package.json
@ -6,45 +6,47 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"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",
|
||||
"dev": "cd packages/web && yarn dev",
|
||||
"bundle": "cd packages/web && yarn bundle"
|
||||
"bundle": "cd packages/web && yarn bundle",
|
||||
"i18n": "node packages/web/scripts/build-i18n-yml-typedef.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chai": "^4.3.0",
|
||||
"@types/chai-as-promised": "^7.1.4",
|
||||
"@types/fs-extra": "^9.0.13",
|
||||
"@types/chai": "^4.3.4",
|
||||
"@types/chai-as-promised": "^7.1.5",
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/mocha": "github:whitecolor/mocha-types#da22474cf43f48a56c86f8c23a5a0ea36e295768",
|
||||
"@types/node": "^17.0.6",
|
||||
"@types/sinon": "^10.0.6",
|
||||
"@types/sinon-chai": "^3.2.8",
|
||||
"@types/wicg-file-system-access": "^2020.9.4",
|
||||
"@typescript-eslint/eslint-plugin": "5.8.1",
|
||||
"@typescript-eslint/parser": "5.8.1",
|
||||
"chai": "^4.3.4",
|
||||
"@types/node": "^18.16.2",
|
||||
"@types/sinon": "^10.0.14",
|
||||
"@types/sinon-chai": "^3.2.9",
|
||||
"@types/wicg-file-system-access": "^2020.9.5",
|
||||
"@typescript-eslint/eslint-plugin": "5.59.1",
|
||||
"@typescript-eslint/parser": "5.59.1",
|
||||
"c8": "^7.13.0",
|
||||
"chai": "^4.3.7",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
"chalk": "^4.1.2",
|
||||
"eslint": "8.6.0",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"eslint-import-resolver-typescript": "2.5.0",
|
||||
"eslint-plugin-import": "2.25.3",
|
||||
"eslint-plugin-react": "7.28.0",
|
||||
"eslint-plugin-react-hooks": "4.3.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"marked": "^4.0.8",
|
||||
"mocha": "^9.1.3",
|
||||
"mochawesome": "^7.0.1",
|
||||
"prettier": "^2.5.1",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"sass": "^1.45.2",
|
||||
"sinon": "^12.0.1",
|
||||
"eslint": "8.39.0",
|
||||
"eslint-config-prettier": "8.8.0",
|
||||
"eslint-import-resolver-typescript": "3.5.5",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"eslint-plugin-react": "7.32.2",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"fs-extra": "^11.1.1",
|
||||
"marked": "^4.3.0",
|
||||
"mocha": "^10.2.0",
|
||||
"mochawesome": "^7.1.3",
|
||||
"prettier": "^2.8.8",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"sass": "^1.62.1",
|
||||
"sinon": "^15.0.4",
|
||||
"sinon-chai": "^3.7.0",
|
||||
"tslib": "^2.3.1",
|
||||
"ts-node": "^10.4.0",
|
||||
"tsconfig-paths": "^3.12.0",
|
||||
"typescript": "^4.5.4"
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tslib": "^2.5.0",
|
||||
"typescript": "^5.0.4"
|
||||
},
|
||||
"prettier": {
|
||||
"arrowParens": "avoid",
|
||||
|
@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "opvault-adapters",
|
||||
"dependencies": {
|
||||
"opvault.js": "*"
|
||||
}
|
||||
}
|
@ -1,23 +1,31 @@
|
||||
{
|
||||
"name": "opvault.js",
|
||||
"main": "src/index.ts",
|
||||
"version": "0.0.1",
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"scripts": {
|
||||
"build": "rollup -c; prettier --write lib >/dev/null",
|
||||
"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.2.0",
|
||||
"tslib": "2.3.1"
|
||||
"tiny-invariant": "1.3.1",
|
||||
"tslib": "2.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-json": "^4.1.0",
|
||||
"@rollup/plugin-replace": "^3.0.0",
|
||||
"prettier": "^2.5.1",
|
||||
"rollup": "^2.61.1",
|
||||
"rollup-plugin-ts": "^2.0.4",
|
||||
"typedoc": "^0.22.10"
|
||||
"@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,6 +8,9 @@ import { dependencies } from "./package.json"
|
||||
export default () => ({
|
||||
input: {
|
||||
index: "./src/index.ts",
|
||||
node: "./src/adapter/node.ts",
|
||||
filePicker: "./src/adapter/showDirectoryPicker.ts",
|
||||
webkit: "./src/adapter/webkitdirectory.ts",
|
||||
},
|
||||
external: builtinModules.concat(Object.keys(dependencies)),
|
||||
output: {
|
||||
@ -21,8 +24,6 @@ export default () => ({
|
||||
preventAssignment: true,
|
||||
values: {
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
'require("./adapter").nodeAdapter':
|
||||
'import("./adapter").then(x => x.nodeAdapter)',
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
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,10 +1,7 @@
|
||||
import { promises as fs, existsSync } from "fs"
|
||||
import { webcrypto } from "crypto"
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param path A path to a file or directory.
|
||||
@ -15,39 +12,45 @@ export interface IFileSystem {
|
||||
* Asynchronously reads the entire contents of 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.
|
||||
* @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.
|
||||
* @param path A path to a file.
|
||||
* @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.
|
||||
* @param path A path to a directory.
|
||||
* Reads the directory given by path and returns an async iterable of `DirEntry`.
|
||||
*/
|
||||
readdir(path: string): Promise<string[]>
|
||||
|
||||
/**
|
||||
* Returns true if the path points to a directory.
|
||||
*/
|
||||
isDirectory(path: string): Promise<boolean>
|
||||
readDir(path: string): AsyncIterable<DirEntry>
|
||||
}
|
||||
|
||||
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
|
||||
* `memfs` or any object that implements `IFileSystem`.
|
||||
*/
|
||||
fs: IFileSystem
|
||||
fs: FileSystem
|
||||
|
||||
/**
|
||||
* `SubtleCrypto` implementation. On Node.js this is
|
||||
@ -56,19 +59,3 @@ export interface IAdapter {
|
||||
*/
|
||||
subtle: SubtleCrypto
|
||||
}
|
||||
|
||||
/**
|
||||
* Default Node.js adapter. This can be used while using `opvault.js`
|
||||
* in a Node.js environment.
|
||||
*/
|
||||
export const nodeAdapter: 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,
|
||||
}
|
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,5 +1,4 @@
|
||||
import { Buffer } from "buffer"
|
||||
import type { IAdapter, IFileSystem } from "opvault.js/src/adapter"
|
||||
import type { Adapter, DirEntry, FileSystem } from "./index"
|
||||
|
||||
function normalize(path: string) {
|
||||
return path.replace(/^\//, "")
|
||||
@ -11,7 +10,7 @@ function splitPath(path: string) {
|
||||
return [segments, filename] as const
|
||||
}
|
||||
|
||||
export class FileSystem implements IFileSystem {
|
||||
class FS implements FileSystem {
|
||||
constructor(private handle: FileSystemDirectoryHandle) {}
|
||||
|
||||
private async getDirectoryHandle(segments: string[]) {
|
||||
@ -33,7 +32,7 @@ export class FileSystem implements IFileSystem {
|
||||
return fileHandle
|
||||
}
|
||||
|
||||
async readFile(path: string) {
|
||||
async readTextFile(path: string) {
|
||||
const handle = await this.getFileHandle(path)
|
||||
const file = await handle.getFile()
|
||||
return file.text()
|
||||
@ -60,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 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 writable = await handle.createWritable()
|
||||
await writable.write(data)
|
||||
await writable.close()
|
||||
}
|
||||
|
||||
async readdir(path: string): Promise<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) {
|
||||
private async isDirectory(path: string) {
|
||||
const [segments, filename] = splitPath(path)
|
||||
const dirHandle = await this.getDirectoryHandle(segments)
|
||||
for await (const [key, handle] of dirHandle.entries()) {
|
||||
@ -97,6 +86,19 @@ export class FileSystem implements IFileSystem {
|
||||
}
|
||||
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>) {
|
||||
@ -111,7 +113,7 @@ async function success(fn: () => Promise<any>) {
|
||||
/**
|
||||
* Default Browser adapter.
|
||||
*/
|
||||
export const getBrowserAdapter = (handle: FileSystemDirectoryHandle): IAdapter => ({
|
||||
fs: new FileSystem(handle),
|
||||
export const getBrowserAdapter = (handle: FileSystemDirectoryHandle): Adapter => ({
|
||||
fs: new FS(handle),
|
||||
subtle: crypto.subtle,
|
||||
})
|
@ -1,7 +1,7 @@
|
||||
import { Buffer } from "buffer"
|
||||
import type { IAdapter, IFileSystem } from "opvault.js/src/adapter"
|
||||
import type { Adapter, DirEntry, FileSystem } from "./index"
|
||||
|
||||
export class FileSystem implements IFileSystem {
|
||||
class FS implements FileSystem {
|
||||
private paths = new Set<string>()
|
||||
private pathMap = new Map<string, File>()
|
||||
|
||||
@ -12,7 +12,7 @@ export class FileSystem implements IFileSystem {
|
||||
}
|
||||
}
|
||||
|
||||
async readFile(path: string) {
|
||||
async readTextFile(path: string) {
|
||||
return this.pathMap.get(path)!.text()
|
||||
}
|
||||
|
||||
@ -20,34 +20,40 @@ export class FileSystem implements IFileSystem {
|
||||
return this.pathMap.has(path)
|
||||
}
|
||||
|
||||
async readBuffer(path: string): Promise<Buffer> {
|
||||
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 writeFile(path: string, data: string): Promise<void> {
|
||||
async writeTextFile(): Promise<void> {
|
||||
throw new Error("fs.writeFile is not supported with webkitdirectory")
|
||||
}
|
||||
|
||||
async readdir(path: string): Promise<string[]> {
|
||||
const paths = [...this.paths]
|
||||
return paths
|
||||
.filter(_ => _.startsWith(`${path}/`))
|
||||
.map(_ => _.slice(path.length + 1))
|
||||
.map(_ => _.split("/")[0])
|
||||
}
|
||||
|
||||
async isDirectory(path: string) {
|
||||
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): IAdapter => ({
|
||||
fs: new FileSystem(list),
|
||||
export const getBrowserAdapter = (list: FileList): Adapter => ({
|
||||
fs: new FS(list),
|
||||
subtle: crypto.subtle,
|
||||
})
|
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
|
||||
}
|
@ -1,19 +1,18 @@
|
||||
import { Buffer } from "buffer"
|
||||
import { decryptData } from "./decipher"
|
||||
import type { IAdapter } from "./adapter"
|
||||
import type { Adapter } from "./adapter"
|
||||
import { createEventEmitter } from "./ee"
|
||||
import { HMACAssertionError } from "./errors"
|
||||
import type { i18n } from "./i18n"
|
||||
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: Buffer
|
||||
key: Uint8Array
|
||||
/** HMAC key */
|
||||
hmac: Buffer
|
||||
hmac: Uint8Array
|
||||
}
|
||||
|
||||
export class Crypto {
|
||||
@ -27,7 +26,7 @@ export class Crypto {
|
||||
|
||||
readonly onLock = createEventEmitter<void>()
|
||||
|
||||
constructor(private readonly i18n: i18n, adapter: IAdapter) {
|
||||
constructor(adapter: Adapter) {
|
||||
this.subtle = adapter.subtle
|
||||
}
|
||||
|
||||
@ -43,7 +42,7 @@ export class Crypto {
|
||||
const derivedKey = await this.subtle.deriveBits(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt: Buffer.from(profile.salt, "base64"),
|
||||
salt: fromBase64(profile.salt),
|
||||
iterations: profile.iterations,
|
||||
hash: {
|
||||
name: "SHA-512",
|
||||
@ -53,7 +52,7 @@ export class Crypto {
|
||||
64 << 3
|
||||
)
|
||||
|
||||
const cipher = splitPlainText(Buffer.from(derivedKey))
|
||||
const cipher = splitPlainText(new Uint8Array(derivedKey))
|
||||
|
||||
// Derive master key and overview keys
|
||||
this.#master = await this.decryptKeys(profile.masterKey, cipher)
|
||||
@ -73,13 +72,9 @@ export class Crypto {
|
||||
this.onLock()
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.lock()
|
||||
}
|
||||
|
||||
assertUnlocked() {
|
||||
if (this.#locked) {
|
||||
throw new Error(this.i18n.error.vaultIsLocked)
|
||||
throw new OPVaultError("This vault is locked", "VAULT_LOCKED")
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,22 +97,22 @@ export class Crypto {
|
||||
|
||||
decryptItemDetails = this.#createWeakCache(async (item: EncryptedItem) => {
|
||||
const cipher = await this.deriveConcreteKey(item)
|
||||
const detail = await this.decryptOPData(Buffer.from(item.d, "base64"), cipher)
|
||||
return JSON.parse(detail.toString("utf-8")) as ItemDetails
|
||||
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(Buffer.from(o, "base64"), this.#overview)
|
||||
return JSON.parse(overview.toString("utf8")) as Overview
|
||||
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 = Buffer.from($k, "base64")
|
||||
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(
|
||||
@ -129,7 +124,7 @@ export class Crypto {
|
||||
}
|
||||
)
|
||||
|
||||
async assertHMac(data: Buffer, key: Buffer, expected: Buffer) {
|
||||
async assertHMac(data: Uint8Array, key: Uint8Array, expected: Uint8Array) {
|
||||
const cryptoKey = await this.subtle.importKey(
|
||||
"raw",
|
||||
key,
|
||||
@ -148,7 +143,7 @@ export class Crypto {
|
||||
}
|
||||
}
|
||||
|
||||
async decryptOPData(cipherText: Buffer, cipher: Cipher) {
|
||||
async decryptOPData(cipherText: Uint8Array, cipher: Cipher) {
|
||||
const key = cipherText.slice(0, -32)
|
||||
await this.assertHMac(key, cipher.hmac, cipherText.slice(-32))
|
||||
|
||||
@ -157,17 +152,28 @@ export class Crypto {
|
||||
return plaintext.slice(-size)
|
||||
}
|
||||
|
||||
async decryptData(key: Buffer, iv: Buffer, data: Buffer) {
|
||||
this.subtle
|
||||
// return createDecipheriv("aes-256-cbc", key, iv).setAutoPadding(false).update(data)
|
||||
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 = Buffer.from(encryptedKey, "base64")
|
||||
const buffer = fromBase64(encryptedKey)
|
||||
const base = await this.decryptOPData(buffer, derived)
|
||||
const digest = await this.subtle.digest("SHA-512", base)
|
||||
return splitPlainText(Buffer.from(digest))
|
||||
return splitPlainText(new Uint8Array(digest))
|
||||
}
|
||||
|
||||
get overview() {
|
||||
@ -175,11 +181,11 @@ export class Crypto {
|
||||
}
|
||||
}
|
||||
|
||||
export const splitPlainText = (derivedKey: Buffer): Cipher => ({
|
||||
export const splitPlainText = (derivedKey: Uint8Array): Cipher => ({
|
||||
key: derivedKey.slice(0, 32),
|
||||
hmac: derivedKey.slice(32, 64),
|
||||
})
|
||||
|
||||
function readUint16({ buffer, byteOffset, length }: Buffer) {
|
||||
function readUint16({ buffer, byteOffset, length }: Uint8Array) {
|
||||
return new DataView(buffer, byteOffset, length).getUint16(0, true)
|
||||
}
|
||||
|
@ -7,11 +7,12 @@
|
||||
* | MIT | Crypto-js | (c) 2009-2013 Jeff Mott. |
|
||||
* | MIT | browserify-aes | (c) 2014-2017 browserify-aes contributors |
|
||||
*/
|
||||
import { Buffer } from "buffer"
|
||||
import invariant from "tiny-invariant"
|
||||
import { readUInt32BE, writeUInt32BE } from "./buffer"
|
||||
|
||||
function bufferXor(a: Buffer, b: Buffer) {
|
||||
function bufferXor(a: Uint8Array, b: Uint8Array) {
|
||||
const length = Math.min(a.length, b.length)
|
||||
const buffer = Buffer.alloc(length)
|
||||
const buffer = new Uint8Array(length)
|
||||
|
||||
for (let i = 0; i < length; ++i) {
|
||||
buffer[i] = a[i] ^ b[i]
|
||||
@ -20,12 +21,12 @@ function bufferXor(a: Buffer, b: Buffer) {
|
||||
return buffer
|
||||
}
|
||||
|
||||
function toUInt32Array(buf: 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] = buf.readUInt32BE(i * 4)
|
||||
out[i] = readUInt32BE(buf, i * 4)
|
||||
}
|
||||
|
||||
return out
|
||||
@ -177,7 +178,7 @@ class AES {
|
||||
private nRounds!: number
|
||||
private invKeySchedule!: number[]
|
||||
|
||||
constructor(key: Buffer) {
|
||||
constructor(key: Uint8Array) {
|
||||
this.key = toUInt32Array(key)
|
||||
this.reset()
|
||||
}
|
||||
@ -236,7 +237,7 @@ class AES {
|
||||
this.invKeySchedule = invKeySchedule
|
||||
}
|
||||
|
||||
decryptBlock(buffer: Buffer) {
|
||||
decryptBlock(buffer: Uint8Array) {
|
||||
const M = toUInt32Array(buffer)
|
||||
|
||||
// swap
|
||||
@ -246,11 +247,11 @@ class AES {
|
||||
|
||||
const out = cryptBlock(M, this.invKeySchedule, G.invSubMix, G.invSBox, 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)
|
||||
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
|
||||
}
|
||||
|
||||
@ -258,44 +259,42 @@ class AES {
|
||||
static keySize = 256 / 8
|
||||
}
|
||||
|
||||
function splitter() {
|
||||
let cache = Buffer.allocUnsafe(0)
|
||||
return {
|
||||
add(data: Buffer) {
|
||||
cache = Buffer.concat([cache, data])
|
||||
return this
|
||||
},
|
||||
get() {
|
||||
if (cache.length >= 16) {
|
||||
const out = cache.slice(0, 16)
|
||||
cache = cache.slice(16)
|
||||
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: Buffer, iv: Buffer, data: Buffer) {
|
||||
if (iv.length !== 16) {
|
||||
throw new TypeError(`invalid iv length ${iv.length}`)
|
||||
}
|
||||
if (key.length !== 32) {
|
||||
throw new TypeError(`invalid key length ${key.length}`)
|
||||
}
|
||||
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 = Buffer.from(iv)
|
||||
const cache = splitter().add(data)
|
||||
let chunk: Buffer | null
|
||||
const res: Buffer[] = []
|
||||
while ((chunk = cache.get())) {
|
||||
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)
|
||||
res.push(bufferXor(out, pad))
|
||||
const array = bufferXor(out, pad)
|
||||
res.push(array)
|
||||
totalLength += array.length
|
||||
}
|
||||
return Buffer.concat(res)
|
||||
|
||||
const result = new Uint8Array(totalLength)
|
||||
let offset = 0
|
||||
for (const array of res) {
|
||||
result.set(array, offset)
|
||||
offset += array.length
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
@ -9,9 +9,6 @@ export function createEventEmitter<T = void>() {
|
||||
function emitter(value: T | EventListener) {
|
||||
if (typeof value === "function") {
|
||||
listeners.add(value as EventListener)
|
||||
return () => {
|
||||
listeners.delete(value as EventListener)
|
||||
}
|
||||
} else {
|
||||
listeners.forEach(fn => fn(value))
|
||||
}
|
||||
|
@ -1,6 +1,10 @@
|
||||
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 {}
|
||||
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { resolve, extname, basename } from "path"
|
||||
import invariant from "tiny-invariant"
|
||||
import type { IFileSystem } from "./adapter"
|
||||
import type { FileSystem } from "./adapter"
|
||||
import { once } from "./util"
|
||||
|
||||
export type OnePasswordFileManager = ReturnType<typeof OnePasswordFileManager>
|
||||
|
||||
export async function OnePasswordFileManager(
|
||||
fs: IFileSystem,
|
||||
fs: FileSystem,
|
||||
path: string,
|
||||
profileName: string
|
||||
) {
|
||||
@ -18,49 +18,48 @@ export async function OnePasswordFileManager(
|
||||
|
||||
const result = {
|
||||
getProfile() {
|
||||
return fs.readFile(abs("profile.js"))
|
||||
return fs.readTextFile(abs("profile.js"))
|
||||
},
|
||||
|
||||
getFolders() {
|
||||
return fs.readFile(abs("folders.js"))
|
||||
return fs.readTextFile(abs("folders.js"))
|
||||
},
|
||||
|
||||
async getAttachments() {
|
||||
const files = await fs.readdir(root)
|
||||
return files
|
||||
.filter(name => extname(name) === ".attachment")
|
||||
.map(name => {
|
||||
async *getAttachments() {
|
||||
for await (const { name } of fs.readDir(root)) {
|
||||
if (extname(name) !== ".attachment") continue
|
||||
|
||||
const sep = name.indexOf("_")
|
||||
const path = resolve(root, name)
|
||||
const [itemUUID, fileUUID] = [
|
||||
name.slice(0, sep),
|
||||
basename(name.slice(sep + 1), extname(name)),
|
||||
]
|
||||
return {
|
||||
yield {
|
||||
itemUUID,
|
||||
fileUUID,
|
||||
getFile: once(() => fs.readBuffer(path)),
|
||||
getFile: once(() => fs.readFile(path)),
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async getBand(name: string) {
|
||||
const path = abs(`band_${name}.js`)
|
||||
if (await fs.exists(path)) {
|
||||
return await fs.readFile(path)
|
||||
return await fs.readTextFile(path)
|
||||
}
|
||||
},
|
||||
|
||||
async setProfile(profile: string) {
|
||||
await fs.writeFile("profile.js", profile)
|
||||
await fs.writeTextFile("profile.js", profile)
|
||||
},
|
||||
|
||||
async setFolders(folders: string) {
|
||||
await fs.writeFile("folders.js", folders)
|
||||
await fs.writeTextFile("folders.js", folders)
|
||||
},
|
||||
|
||||
async setBand(name: string, band: string) {
|
||||
await fs.writeFile(`band_${name}.js`, band)
|
||||
await fs.writeTextFile(`band_${name}.js`, band)
|
||||
},
|
||||
}
|
||||
return result
|
||||
|
@ -1,25 +0,0 @@
|
||||
import json from "./res.json"
|
||||
|
||||
const [locale] = Intl.DateTimeFormat().resolvedOptions().locale.split("-")
|
||||
|
||||
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,7 +1,6 @@
|
||||
import { resolve } from "path"
|
||||
import { Vault } from "./models/vault"
|
||||
import type { IAdapter } from "./adapter"
|
||||
import { asyncMap } from "./util"
|
||||
import type { Adapter } from "./adapter"
|
||||
|
||||
export type { Vault } from "./models/vault"
|
||||
export type { Item } from "./models/item"
|
||||
@ -9,6 +8,8 @@ export type { Attachment, AttachmentMetadata } from "./models/attachment"
|
||||
export type { ItemField, ItemSection } from "./types"
|
||||
export { Category, FieldType } from "./models"
|
||||
|
||||
export type { Adapter as IAdapter } from "./adapter/index"
|
||||
|
||||
interface IOptions {
|
||||
/**
|
||||
* Path to `.opvault` directory
|
||||
@ -18,22 +19,33 @@ interface IOptions {
|
||||
/**
|
||||
* Adapter used to interact with the file system and cryptography modules
|
||||
*/
|
||||
adapter?: IAdapter | Promise<IAdapter>
|
||||
adapter: Adapter | Promise<Adapter>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
readonly #path: string
|
||||
readonly #adapter: IAdapter | Promise<IAdapter>
|
||||
readonly #adapter: Adapter | Promise<Adapter>
|
||||
|
||||
constructor({
|
||||
path,
|
||||
adapter = process.browser ? null! : require("./adapter").nodeAdapter,
|
||||
}: IOptions) {
|
||||
this.#adapter = adapter
|
||||
this.#path = path
|
||||
constructor(options: IOptions) {
|
||||
this.#adapter = options.adapter
|
||||
this.#path = options.path
|
||||
}
|
||||
|
||||
/**
|
||||
@ -41,17 +53,15 @@ export class OnePassword {
|
||||
*/
|
||||
async getProfileNames() {
|
||||
const { fs } = await this.#adapter
|
||||
const children = await fs.readdir(this.#path)
|
||||
const profiles: string[] = []
|
||||
await asyncMap(children, async child => {
|
||||
const fullPath = resolve(this.#path, child)
|
||||
if (
|
||||
(await fs.isDirectory(fullPath)) &&
|
||||
(await fs.exists(resolve(fullPath, "profile.js")))
|
||||
) {
|
||||
profiles.push(child)
|
||||
|
||||
for await (const { name, isDirectory } of fs.readDir(this.#path)) {
|
||||
const fullPath = resolve(this.#path, name)
|
||||
if (isDirectory && (await fs.exists(resolve(fullPath, "profile.js")))) {
|
||||
profiles.push(name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return profiles
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Buffer } from "buffer"
|
||||
import type { Crypto } from "../crypto"
|
||||
import { invariant } from "../errors"
|
||||
import { fromBase64, readIntLE, utf8Slice } from "../buffer"
|
||||
|
||||
type integer = number
|
||||
|
||||
@ -21,22 +21,22 @@ export interface AttachmentMetadata {
|
||||
export class Attachment {
|
||||
#k: string
|
||||
#crypto: Crypto
|
||||
#buffer: Buffer
|
||||
#buffer: Uint8Array
|
||||
|
||||
#icon?: Buffer // png buffer
|
||||
#file?: Buffer
|
||||
#icon?: Uint8Array // png buffer
|
||||
#file?: Uint8Array
|
||||
#metadata?: AttachmentMetadata
|
||||
|
||||
private metadataSize: number
|
||||
private iconSize: number
|
||||
|
||||
constructor(crypto: Crypto, k: string, buffer: Buffer) {
|
||||
constructor(crypto: Crypto, k: string, buffer: Uint8Array) {
|
||||
this.#buffer = buffer
|
||||
this.#validate()
|
||||
this.#crypto = crypto
|
||||
this.#k = k
|
||||
this.metadataSize = buffer.readIntLE(8, 2)
|
||||
this.iconSize = buffer.readIntLE(12, 3)
|
||||
this.metadataSize = readIntLE(buffer, 8, 2)
|
||||
this.iconSize = readIntLE(buffer, 12, 3)
|
||||
|
||||
crypto.onLock(() => {
|
||||
this.#lock()
|
||||
@ -49,13 +49,13 @@ export class Attachment {
|
||||
#validate() {
|
||||
const file = this.#buffer
|
||||
invariant(
|
||||
file.slice(0, 6).toString("utf-8") === "OPCLDA",
|
||||
utf8Slice(file.slice(0, 6)) === "OPCLDA",
|
||||
"Attachment must start with OPCLDA"
|
||||
)
|
||||
// @TODO: Re-enable this
|
||||
false &&
|
||||
invariant(
|
||||
file.readIntLE(7, 1) === 1,
|
||||
readIntLE(file, 7, 1) === 1,
|
||||
"The version for this attachment file format is not supported."
|
||||
)
|
||||
}
|
||||
@ -86,14 +86,11 @@ export class Attachment {
|
||||
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(
|
||||
(
|
||||
await crypto.decryptOPData(
|
||||
Buffer.from(metadata.overview, "base64"),
|
||||
crypto.overview
|
||||
utf8Slice(
|
||||
await crypto.decryptOPData(fromBase64(metadata.overview), crypto.overview)
|
||||
)
|
||||
).toString()
|
||||
)
|
||||
this.#metadata = metadata
|
||||
}
|
||||
|
@ -93,7 +93,7 @@ export class Item {
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
addAttachment(buffer: Buffer) {
|
||||
addAttachment(buffer: Uint8Array) {
|
||||
this.attachments.push(new Attachment(this.#crypto, this.#data.k, buffer))
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
import type { IAdapter } from "../adapter"
|
||||
import { HMACAssertionError, invariant } from "../errors"
|
||||
import type { Adapter } from "../adapter"
|
||||
import { HMACAssertionError, OPVaultError, invariant } from "../errors"
|
||||
import { OnePasswordFileManager } from "../fs"
|
||||
import { i18n } from "../i18n"
|
||||
import type { EncryptedItem } from "./item"
|
||||
import { Crypto } from "../crypto"
|
||||
import { Item } from "./item"
|
||||
@ -21,7 +20,7 @@ export class Vault {
|
||||
#itemsMap = new WeakValueMap<string, Item>()
|
||||
#crypto: Crypto
|
||||
|
||||
readonly onLock = createEventEmitter<void>()
|
||||
readonly #onLock = createEventEmitter<void>()
|
||||
|
||||
private constructor(
|
||||
profile: Profile,
|
||||
@ -41,8 +40,8 @@ export class Vault {
|
||||
* Create a new OnePassword Vault instance and read all bands.
|
||||
* @internal
|
||||
*/
|
||||
static async of(path: string, profileName = "default", adapter: IAdapter) {
|
||||
const crypto = new Crypto(i18n, adapter)
|
||||
static async of(path: string, profileName = "default", adapter: Adapter) {
|
||||
const crypto = new Crypto(adapter)
|
||||
const files = await OnePasswordFileManager(adapter.fs, path, profileName)
|
||||
const profile = JSON.parse(
|
||||
stripText(await files.getProfile(), /^var profile\s*=/, ";")
|
||||
@ -66,8 +65,7 @@ export class Vault {
|
||||
}
|
||||
}
|
||||
|
||||
const attachments = await files.getAttachments()
|
||||
for (const att of attachments) {
|
||||
for await (const att of files.getAttachments()) {
|
||||
const file = itemsMap.get(att.itemUUID)
|
||||
invariant(file, `Item ${att.itemUUID} of attachment does not exist`)
|
||||
file.addAttachment(await att.getFile())
|
||||
@ -76,6 +74,9 @@ export class Vault {
|
||||
return new Vault(profile, bands, crypto, itemsMap)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the overview of an item given the `uuid`.
|
||||
*/
|
||||
getOverview(uuid: string) {
|
||||
this.#crypto.assertUnlocked()
|
||||
return this.#items.find(x => x.uuid === uuid)?.overview
|
||||
@ -102,7 +103,7 @@ export class Vault {
|
||||
await this.#crypto.unlock(this.#profile, masterPassword)
|
||||
} catch (e) {
|
||||
if (e instanceof HMACAssertionError) {
|
||||
throw new Error(i18n.error.invalidPassword)
|
||||
throw new OPVaultError("Invalid password", "INVALID_PASSWORD")
|
||||
}
|
||||
throw e
|
||||
}
|
||||
@ -114,7 +115,7 @@ export class Vault {
|
||||
*/
|
||||
lock() {
|
||||
this.#crypto.lock()
|
||||
this.onLock()
|
||||
this.#onLock()
|
||||
return this
|
||||
}
|
||||
|
||||
@ -122,7 +123,14 @@ export class Vault {
|
||||
return this.#crypto.locked
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the item with the given `uuid`
|
||||
*/
|
||||
getItem(uuid: string): Promise<Item | undefined>
|
||||
|
||||
/**
|
||||
* Returns the first item with the given title
|
||||
*/
|
||||
getItem(filter: { title: string }): Promise<Item | undefined>
|
||||
|
||||
async getItem(filter: any) {
|
||||
|
@ -119,7 +119,7 @@ export namespace ItemSection {
|
||||
}
|
||||
export type Concealed = {
|
||||
k: "concealed"
|
||||
n: "password"
|
||||
n: "password" | `TOTP_${string}`
|
||||
v: string
|
||||
a?: {
|
||||
generate: "off"
|
||||
|
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>.
|
@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "opvault-web",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.220221",
|
||||
"main": "dist/main/index.js",
|
||||
"author": "proteria",
|
||||
"license": "GPL-3.0-only",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"description": "OnePassword local vault viewer",
|
||||
"scripts": {
|
||||
"dev": "concurrently vite npm:start",
|
||||
@ -14,9 +14,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/css": "^11.7.1",
|
||||
"@emotion/react": "^11.7.1",
|
||||
"@emotion/styled": "^11.6.0",
|
||||
"@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",
|
||||
@ -24,22 +25,23 @@
|
||||
"react-idle-timer": "4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.16.7",
|
||||
"@babel/core": "^7.17.5",
|
||||
"@emotion/babel-plugin": "^11.7.2",
|
||||
"@rollup/plugin-yaml": "^3.1.0",
|
||||
"@types/react": "^17.0.37",
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"@vitejs/plugin-react": "^1.1.3",
|
||||
"@types/babel__core": "^7.1.18",
|
||||
"concurrently": "^6.5.1",
|
||||
"electron": "^16.0.5",
|
||||
"electron-builder": "^22.14.5",
|
||||
"esbuild": "^0.14.5",
|
||||
"@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.45.0",
|
||||
"typescript": "^4.5.4",
|
||||
"vite": "^2.7.3"
|
||||
"sass": "^1.49.8",
|
||||
"typescript": "^4.5.5",
|
||||
"vite": "^2.8.4"
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
#!/bin/sh
|
||||
./scripts/update-version.js
|
||||
./scripts/build-i18n-yml-typedef.js
|
||||
./scripts/build-third-party-license.js
|
||||
./scripts/build-third-party-license-info.js
|
||||
./scripts/build-package-json.js
|
||||
npx vite build
|
||||
NODE_ENV=production ./esbuild.js
|
||||
|
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))
|
@ -1,5 +1,11 @@
|
||||
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()
|
||||
@ -10,5 +16,16 @@ export const SideEffect = memo(() => {
|
||||
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
|
||||
})
|
||||
|
@ -1,6 +1,6 @@
|
||||
import styled from "@emotion/styled"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { ClickableContainer } from "../components/ItemFieldValue"
|
||||
import { Container as ClickableContainer } from "../components/ItemFieldValue/Container"
|
||||
import { scrollbar } from "../styles"
|
||||
|
||||
const Container = styled.div`
|
||||
|
@ -21,7 +21,7 @@ const Header = styled.h2`
|
||||
margin: 0;
|
||||
`
|
||||
const Pre = styled.pre`
|
||||
font-size: 15px;
|
||||
font-size: 1rem;
|
||||
line-height: 1.3em;
|
||||
`
|
||||
|
||||
|
@ -158,16 +158,22 @@ export const FilteredVaultView: React.FC<{ items: Item[] }> = ({ items }) => {
|
||||
onChange={e => setCategory((e.currentTarget.value as Category) || undefined)}
|
||||
>
|
||||
{categoryMap.map(([value, name]) => (
|
||||
<option value={value || ""} key={value}>
|
||||
<option value={value || ""} key={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</CategorySelect>
|
||||
|
||||
<SortSelect value={sortBy} onChange={e => setSortBy(+e.currentTarget.value)}>
|
||||
<option value={SortBy.Name}>{t.options.sort_by_name}</option>
|
||||
<option value={SortBy.CreatedAt}>{t.options.sort_by_created_at}</option>
|
||||
<option value={SortBy.UpdatedAt}>{t.options.sort_by_updated_at}</option>
|
||||
<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} />
|
||||
|
@ -12,7 +12,7 @@ import {
|
||||
FieldTitle,
|
||||
ItemDetailsFieldView,
|
||||
} from "./ItemField"
|
||||
import { PasswordFieldView } from "./ItemFieldValue"
|
||||
import { Password } from "./ItemFieldValue/Password"
|
||||
import { ItemWarning } from "./ItemWarning"
|
||||
|
||||
interface ItemViewProps {
|
||||
@ -146,7 +146,7 @@ export const ItemView = memo<ItemViewProps>(({ className, item }) => {
|
||||
{item.details.password != null && (
|
||||
<ExtraField>
|
||||
<FieldTitle>{t.label.password}</FieldTitle>
|
||||
<PasswordFieldView field={{ v: item.details.password }} />
|
||||
<Password field={{ v: item.details.password }} />
|
||||
</ExtraField>
|
||||
)}
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { memo } from "react"
|
||||
import { memo, useMemo } from "react"
|
||||
import styled from "@emotion/styled"
|
||||
import type { ItemField, ItemSection } from "opvault.js"
|
||||
import { ErrorBoundary } from "./ErrorBoundary"
|
||||
@ -18,6 +18,11 @@ export const FieldTitle: React.FC = styled.div`
|
||||
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
|
||||
}
|
||||
@ -25,7 +30,7 @@ export const ItemFieldView = memo<{
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Container>
|
||||
<FieldTitle>{field.t}</FieldTitle>
|
||||
<FieldTitle>{title}</FieldTitle>
|
||||
<ItemFieldValue field={field} />
|
||||
</Container>
|
||||
</ErrorBoundary>
|
||||
|
@ -34,7 +34,7 @@ const Separator = styled.div`
|
||||
|
||||
const Item = styled.div`
|
||||
cursor: default;
|
||||
font-size: 14px;
|
||||
font-size: 0.875rem;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
height: 2.3em;
|
||||
|
@ -1,152 +0,0 @@
|
||||
import styled from "@emotion/styled"
|
||||
import type { ItemSection, ItemField } from "opvault.js"
|
||||
import { FieldType } from "opvault.js"
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { useTranslate } from "../i18n"
|
||||
import { parseMonthYear } from "../utils"
|
||||
import { BigTextView } from "./BigTextView"
|
||||
import { ErrorBoundary } from "./ErrorBoundary"
|
||||
import { useItemFieldContextMenu } from "./ItemFieldContextMenu"
|
||||
import { toast, ToastType } from "./Toast"
|
||||
|
||||
const Container = styled.div`
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
color: #6fa9ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
`
|
||||
|
||||
export { Container as ClickableContainer }
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
export { Password as PasswordFieldView }
|
||||
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
const DateView: React.FC<{ field: ItemSection.Date }> = ({ field }) => {
|
||||
const date = useMemo(() => new Date(field.v * 1000), [field.v])
|
||||
return <Container>{date.toLocaleDateString()}</Container>
|
||||
}
|
||||
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const ItemFieldValue: React.FC<{
|
||||
field: ItemSection.Any
|
||||
}> = ({ field }) => {
|
||||
if (field.v == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
switch (field.k) {
|
||||
case "concealed":
|
||||
return <Password field={field} />
|
||||
case "monthYear":
|
||||
return <MonthYear field={field} />
|
||||
case "date":
|
||||
return <DateView field={field} />
|
||||
case "address":
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
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>
|
||||
)
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
import styled from "@emotion/styled"
|
||||
import { useCallback } from "react"
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import { useEventListener } from "../utils/useEvent"
|
||||
|
||||
const ModalBackground = styled.div`
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
@ -21,11 +22,25 @@ const ModalBackground2 = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`
|
||||
const ModalContainer = styled.div`
|
||||
const ModalContainer = styled.dialog`
|
||||
background: var(--page-background);
|
||||
box-shadow: rgba(0, 0, 0, 0.25) 0px 14px 28px, rgba(0, 0, 0, 0.22) 0px 10px 10px;
|
||||
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);
|
||||
@ -37,12 +52,15 @@ 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) {
|
||||
@ -53,6 +71,15 @@ export const Modal: React.FC<{
|
||||
[onClose]
|
||||
)
|
||||
|
||||
useEventListener(document.body, "keyup").on(
|
||||
e => {
|
||||
if (show && e.key === "Escape") {
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
[show]
|
||||
)
|
||||
|
||||
if (!show) {
|
||||
return null
|
||||
}
|
||||
@ -61,7 +88,7 @@ export const Modal: React.FC<{
|
||||
<>
|
||||
<ModalBackground />
|
||||
<ModalBackground2 onClick={onBackgroundClick}>
|
||||
<ModalContainer style={{ maxWidth }}>
|
||||
<ModalContainer open ref={dialogRef} style={{ maxWidth }}>
|
||||
<ModalTitle>{title}</ModalTitle>
|
||||
<ModalContent>{children}</ModalContent>
|
||||
</ModalContainer>
|
||||
|
9
packages/web/src/electron/ipc-types.d.ts
vendored
9
packages/web/src/electron/ipc-types.d.ts
vendored
@ -1,9 +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<string[]>
|
||||
readBuffer(path: string): Promise<Uint8Array>
|
||||
readFile(path: string): Promise<string>
|
||||
readDir(path: string): Promise<DirEntry[]>
|
||||
readFile(path: string): Promise<Uint8Array>
|
||||
readTextFile(path: string): Promise<string>
|
||||
writeFile(path: string, data: string): Promise<void>
|
||||
isDirectory(path: string): Promise<boolean>
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
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({
|
||||
@ -15,11 +17,11 @@ registerService({
|
||||
return fs.existsSync(path)
|
||||
},
|
||||
|
||||
async readBuffer(_, path) {
|
||||
async readFile(_, path) {
|
||||
return promises.readFile(path)
|
||||
},
|
||||
|
||||
async readFile(_, path) {
|
||||
async readTextFile(_, path) {
|
||||
return promises.readFile(path, "utf-8")
|
||||
},
|
||||
|
||||
@ -27,13 +29,12 @@ registerService({
|
||||
await promises.writeFile(path, content)
|
||||
},
|
||||
|
||||
async readdir(_, path) {
|
||||
return promises.readdir(path)
|
||||
},
|
||||
|
||||
async isDirectory(_, path) {
|
||||
const stats = await promises.stat(path)
|
||||
return stats.isDirectory()
|
||||
async readDir(_, path) {
|
||||
const entries: DirEntry[] = []
|
||||
for await (const dirent of adapter.fs.readDir(path)) {
|
||||
entries.push(dirent)
|
||||
}
|
||||
return entries
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -176,6 +176,16 @@ options:
|
||||
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
|
||||
@ -192,6 +202,16 @@ noun:
|
||||
fr: secondes
|
||||
ja: 秒
|
||||
|
||||
item:
|
||||
en: item
|
||||
fr: élément
|
||||
ja: アイテム
|
||||
|
||||
items:
|
||||
en: items
|
||||
fr: éléments
|
||||
ja: アイテム
|
||||
|
||||
action:
|
||||
lock:
|
||||
en: Lock
|
||||
|
@ -10,20 +10,22 @@ body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-size: 15px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, system-ui, "Roboto", "Oxygen",
|
||||
"Cantarell", "Droid Sans", "Helvetica Neue", "Noto Sans CJK JP", sans-serif;
|
||||
font-family: var(--sans-serif);
|
||||
}
|
||||
:root {
|
||||
--page-background: #fff;
|
||||
--color: #000;
|
||||
--titlebar-height: 46px;
|
||||
--titlebar-height: 0px;
|
||||
--label-background: #ddd;
|
||||
--selected-background: #d5d5d5;
|
||||
--hover-background: #ddd;
|
||||
--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) {
|
||||
@ -32,6 +34,21 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
@ -43,20 +60,6 @@ body,
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color: #fff;
|
||||
--label-background: #353535;
|
||||
--selected-background: #353535;
|
||||
--border-color: #333;
|
||||
--hover-background: #222;
|
||||
--page-background: #292929;
|
||||
}
|
||||
body {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
pre,
|
||||
code {
|
||||
font-family: var(--monospace);
|
||||
@ -78,7 +81,7 @@ input {
|
||||
}
|
||||
|
||||
input[type="search"],
|
||||
input[type="input"],
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="password"] {
|
||||
@include input;
|
||||
@ -100,7 +103,7 @@ input[type="checkbox" i] {
|
||||
position: relative;
|
||||
&:checked:after {
|
||||
content: "\2714";
|
||||
font-size: 15px;
|
||||
font-size: 1rem;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 3px;
|
||||
@ -125,7 +128,6 @@ select,
|
||||
}
|
||||
}
|
||||
button {
|
||||
font-size: 16px;
|
||||
padding: 8px 15px;
|
||||
box-shadow: rgb(0 0 0 / 7%) 0px 1px 2px;
|
||||
transition: 0.1s;
|
||||
|
@ -36,7 +36,7 @@ const TabButton = styled.button<{ active?: boolean }>`
|
||||
box-shadow: none;
|
||||
display: inline-flex;
|
||||
margin-bottom: 5px;
|
||||
font-size: 22px;
|
||||
font-size: 1.4666em;
|
||||
padding: 10px 14px;
|
||||
${p => p.active && "&:hover { background: var(--selected-background); }"}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
|
@ -4,6 +4,7 @@ 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;
|
||||
@ -72,7 +73,7 @@ const VaultPath = styled.div`
|
||||
export const Unlock: React.FC<{
|
||||
instance: OnePassword
|
||||
vaultPath: string
|
||||
onUnlock(profile: string, password: string): void
|
||||
onUnlock(profile: string, password: string): Promise<boolean>
|
||||
onReturn(): void
|
||||
}> = ({ onUnlock, onReturn, instance, vaultPath }) => {
|
||||
const t = useTranslate()
|
||||
@ -80,12 +81,14 @@ export const Unlock: React.FC<{
|
||||
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)
|
||||
onUnlock(profile, password).then(setFeedback)
|
||||
setPassword("")
|
||||
},
|
||||
[onUnlock, profile, password]
|
||||
@ -132,12 +135,14 @@ export const Unlock: React.FC<{
|
||||
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>
|
||||
|
@ -24,8 +24,13 @@ export const VaultPicker: React.FC<VaultPickerProps> = ({
|
||||
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]
|
||||
)
|
||||
|
@ -25,6 +25,9 @@ const Checkbox = styled.input`
|
||||
margin-left: 0;
|
||||
margin-right: 8px;
|
||||
`
|
||||
const Input = styled.input`
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const GhostLabel = styled.div`
|
||||
opacity: 0.5;
|
||||
@ -42,6 +45,8 @@ export const Settings: React.FC<{
|
||||
|
||||
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}>
|
||||
@ -75,6 +80,7 @@ export const Settings: React.FC<{
|
||||
value={autolockAfter}
|
||||
onChange={e => setAutolockAfter(e.target.valueAsNumber)}
|
||||
disabled={!enableAutoLock}
|
||||
min={5}
|
||||
/>
|
||||
<GhostLabel>
|
||||
<span style={{ opacity: 0 }}>{autolockAfter} </span>
|
||||
@ -82,6 +88,30 @@ export const Settings: React.FC<{
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { Buffer } from "buffer"
|
||||
import type { IAdapter } from "opvault.js/src/adapters"
|
||||
import type { Adapter } from "opvault.js/src/adapter"
|
||||
import type { IPC } from "../electron/ipc-types"
|
||||
import { memoize } from "./memoize"
|
||||
|
||||
@ -9,14 +8,15 @@ export async function openDirectory() {
|
||||
return ipc.showDirectoryPicker()
|
||||
}
|
||||
|
||||
export const electronAdapter: IAdapter = {
|
||||
export const electronAdapter: Adapter = {
|
||||
fs: {
|
||||
exists: path => ipc.pathExists(path),
|
||||
readBuffer: path => ipc.readBuffer(path).then(Buffer.from),
|
||||
readFile: path => ipc.readFile(path),
|
||||
readdir: path => ipc.readdir(path),
|
||||
writeFile: (path, data) => ipc.writeFile(path, data),
|
||||
isDirectory: path => ipc.isDirectory(path),
|
||||
readTextFile: path => ipc.readTextFile(path),
|
||||
async *readDir(path) {
|
||||
yield* await ipc.readDir(path)
|
||||
},
|
||||
writeTextFile: (path, data) => ipc.writeFile(path, data),
|
||||
},
|
||||
subtle: crypto.subtle,
|
||||
}
|
||||
|
@ -6,6 +6,8 @@ export enum Key {
|
||||
PREFERRED_LOCALE = "app.config.locale",
|
||||
ENABLE_AUTO_LOCK = "app.config.enable_auto_lock",
|
||||
AUTO_LOCK_AFTER = "app.config.auto_lock_after",
|
||||
UI_FONT = "app.config.font.ui",
|
||||
MONOSPACE_FONT = "app.config.font.monospace",
|
||||
}
|
||||
|
||||
interface StoredData {
|
||||
@ -14,6 +16,8 @@ interface StoredData {
|
||||
[Key.PREFERRED_LOCALE]: string
|
||||
[Key.ENABLE_AUTO_LOCK]: boolean
|
||||
[Key.AUTO_LOCK_AFTER]: number
|
||||
[Key.UI_FONT]: string
|
||||
[Key.MONOSPACE_FONT]: string
|
||||
}
|
||||
|
||||
const events = new Map(Object.values(Key).map(key => [key, new Set()])) as {
|
||||
@ -74,3 +78,5 @@ const defaults: typeof set = (key, value) => {
|
||||
defaults(Key.ENABLE_AUTO_LOCK, true)
|
||||
defaults(Key.AUTO_LOCK_AFTER, 180)
|
||||
defaults(Key.RECENTLY_OPENED_VAULTS, [])
|
||||
defaults(Key.UI_FONT, "")
|
||||
defaults(Key.MONOSPACE_FONT, "")
|
||||
|
51
packages/web/src/utils/useEvent.ts
Normal file
51
packages/web/src/utils/useEvent.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { useEffect } from "react"
|
||||
|
||||
type UseEventListenerOptions = boolean | AddEventListenerOptions
|
||||
|
||||
export { useEventListener }
|
||||
|
||||
type On<This, Ev> = {
|
||||
on(listener: (this: This, ev: Ev) => void, deps?: any[]): void
|
||||
}
|
||||
|
||||
interface UseEventListener {
|
||||
<K extends keyof MediaQueryListEventMap>(
|
||||
mediaQueryList: MediaQueryList,
|
||||
type: K,
|
||||
options?: UseEventListenerOptions
|
||||
): On<MediaQueryList, MediaQueryListEventMap[K]>
|
||||
<K extends keyof WindowEventMap>(
|
||||
window: Window,
|
||||
type: K,
|
||||
options?: UseEventListenerOptions
|
||||
): On<Window, WindowEventMap[K]>
|
||||
<K extends keyof DocumentEventMap>(
|
||||
document: Document,
|
||||
type: K,
|
||||
options?: UseEventListenerOptions
|
||||
): On<Document, DocumentEventMap[K]>
|
||||
<K extends keyof HTMLElementEventMap>(
|
||||
element: HTMLElement,
|
||||
type: K,
|
||||
options?: UseEventListenerOptions
|
||||
): On<HTMLElement, HTMLElementEventMap[K]>
|
||||
}
|
||||
|
||||
const useEventListener: UseEventListener = function useEventListener(
|
||||
element: EventTarget,
|
||||
type: string,
|
||||
options?: UseEventListenerOptions
|
||||
) {
|
||||
return {
|
||||
on(listener: (ev: any) => any, deps?: any[]) {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(
|
||||
() => {
|
||||
element.addEventListener(type, listener, options)
|
||||
return () => element.removeEventListener(type, listener, options)
|
||||
},
|
||||
deps ? [element, type, ...deps] : undefined
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
48
packages/web/src/utils/useWrongPasswordFeedback.ts
Normal file
48
packages/web/src/utils/useWrongPasswordFeedback.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import styled from "@emotion/styled"
|
||||
import { keyframes } from "@emotion/css"
|
||||
import React, { useCallback, useRef, useState } from "react"
|
||||
|
||||
const shake = keyframes`
|
||||
8%, 41% {
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
25%, 58% {
|
||||
transform: translateX(10px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
92% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
0%, 100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
`
|
||||
|
||||
const animation = `${shake} .5s linear`
|
||||
|
||||
export function useWrongPasswordFeedback() {
|
||||
const timeout = useRef(0)
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
const callback = useCallback((success: boolean) => {
|
||||
setShow(!success)
|
||||
if (timeout.current) {
|
||||
window.clearTimeout(timeout.current)
|
||||
}
|
||||
|
||||
timeout.current = window.setTimeout(() => {
|
||||
setShow(false)
|
||||
}, 500)
|
||||
}, [])
|
||||
|
||||
return [
|
||||
{
|
||||
animation: show ? animation : undefined,
|
||||
error: show,
|
||||
},
|
||||
callback,
|
||||
] as const
|
||||
}
|
4465
pnpm-lock.yaml
generated
4465
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,104 +0,0 @@
|
||||
import { resolve } from "path";
|
||||
import { describe, it, beforeEach } from "mocha";
|
||||
import { expect } from "chai";
|
||||
|
||||
import type { Vault } from "../packages/opvault.js/src/index";
|
||||
import { OnePassword } from "../packages/opvault.js/src/index";
|
||||
|
||||
describe("OnePassword", () => {
|
||||
const freddy = resolve(__dirname, "../freddy-2013-12-04.opvault");
|
||||
|
||||
describe("getProfileNames", () => {
|
||||
it("freddy", async () => {
|
||||
const instance = new OnePassword({ path: freddy });
|
||||
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 }).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 }).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(attachment.file.toString("base64")).to.deep.equal(
|
||||
expected.file
|
||||
);
|
||||
expect(attachment.icon.toString("base64")).to.deep.equal(
|
||||
expected.icon
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("lock", () => {
|
||||
it("locks", async () => {
|
||||
const instance = new OnePassword({ path: freddy });
|
||||
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;
|
||||
});
|
||||
});
|
||||
});
|
@ -1,43 +0,0 @@
|
||||
import { describe, it } from "mocha";
|
||||
import { expect } from "chai";
|
||||
|
||||
import { WeakValueMap } from "../packages/opvault.js/src/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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user