rework entire front end
This commit is contained in:
parent
fc04b6c9cd
commit
a5792c8ad0
18 changed files with 625 additions and 642 deletions
1
main.go
1
main.go
|
@ -100,6 +100,7 @@ func newStorage() (storage password.Storage, err error) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear storage at regular intervals
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.Tick(time.Duration(clearInterval) * time.Second)
|
ticker := time.Tick(time.Duration(clearInterval) * time.Second)
|
||||||
for {
|
for {
|
||||||
|
|
|
@ -1,41 +0,0 @@
|
||||||
package route
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"git.1e99.eu/1e99/passed/password"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetPassword(storage password.Storage) http.HandlerFunc {
|
|
||||||
return func(res http.ResponseWriter, req *http.Request) {
|
|
||||||
id := req.PathValue("id")
|
|
||||||
passwd, err := storage.Get(id)
|
|
||||||
switch {
|
|
||||||
case err == password.ErrNotFound:
|
|
||||||
http.Error(res, "Password not found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
case err != nil:
|
|
||||||
http.Error(res, "", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = storage.Delete(id)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(res, "", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resBody := struct {
|
|
||||||
// Go automatically encodes byte arrays using Base64
|
|
||||||
Password []byte `json:"password"`
|
|
||||||
}{
|
|
||||||
Password: passwd,
|
|
||||||
}
|
|
||||||
err = json.NewEncoder(res).Encode(&resBody)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(res, "", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,24 +0,0 @@
|
||||||
package route
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"git.1e99.eu/1e99/passed/password"
|
|
||||||
)
|
|
||||||
|
|
||||||
func HasPassword(storage password.Storage) http.HandlerFunc {
|
|
||||||
return func(res http.ResponseWriter, req *http.Request) {
|
|
||||||
id := req.PathValue("id")
|
|
||||||
_, err := storage.Get(id)
|
|
||||||
switch {
|
|
||||||
case err == password.ErrNotFound:
|
|
||||||
http.Error(res, "", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
case err != nil:
|
|
||||||
http.Error(res, "", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
res.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -50,15 +50,54 @@ func CreatePassword(storage password.Storage, maxLength int) http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resBody := struct {
|
err = json.NewEncoder(res).Encode(&id)
|
||||||
Id string `json:"id"`
|
|
||||||
}{
|
|
||||||
Id: id,
|
|
||||||
}
|
|
||||||
err = json.NewEncoder(res).Encode(&resBody)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(res, "", http.StatusInternalServerError)
|
http.Error(res, "", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetPassword(storage password.Storage) http.HandlerFunc {
|
||||||
|
return func(res http.ResponseWriter, req *http.Request) {
|
||||||
|
id := req.PathValue("id")
|
||||||
|
passwd, err := storage.Get(id)
|
||||||
|
switch {
|
||||||
|
case err == password.ErrNotFound:
|
||||||
|
http.Error(res, "Password not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
http.Error(res, "", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = storage.Delete(id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(res, "", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.NewEncoder(res).Encode(&passwd)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(res, "", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HasPassword(storage password.Storage) http.HandlerFunc {
|
||||||
|
return func(res http.ResponseWriter, req *http.Request) {
|
||||||
|
id := req.PathValue("id")
|
||||||
|
_, err := storage.Get(id)
|
||||||
|
switch {
|
||||||
|
case err == password.ErrNotFound:
|
||||||
|
http.Error(res, "", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
http.Error(res, "", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
7
static/css/main.css
Normal file
7
static/css/main.css
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
6
static/css/pico.min.css
vendored
6
static/css/pico.min.css
vendored
File diff suppressed because one or more lines are too long
|
@ -1,129 +1,131 @@
|
||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
<html>
|
<head>
|
||||||
<head>
|
<meta charset="UTF-8">
|
||||||
<meta charset="UTF-8" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
||||||
<!--<link rel="icon" href="favicon.png" />-->
|
|
||||||
<title>PassED</title>
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="/css/pico.min.css" />
|
<link rel="stylesheet" href="/css/pico.min.css">
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
|
||||||
<script src="/js/lang.js"></script>
|
<script src="/js/lang.js"></script>
|
||||||
<script src="/js/api.js"></script>
|
<script src="/js/backend.js"></script>
|
||||||
<script src="/js/crypto.js"></script>
|
<script src="/js/crypto.js"></script>
|
||||||
<script src="/js/main.js"></script>
|
<script src="/js/main.js"></script>
|
||||||
</head>
|
|
||||||
<nav class="container">
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<strong t="title"></strong>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<ul>
|
<title>PassED</title>
|
||||||
<li>
|
</head>
|
||||||
<a href="https://git.1e99.eu/1e99/passed/" t="source-code"></a>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
<body>
|
||||||
<details class="dropdown">
|
<nav class="container">
|
||||||
<summary t="language"></summary>
|
<ul>
|
||||||
<ul dir="rtl">
|
<li>
|
||||||
<li class="select-language" data-lang="de">Deutsch</li>
|
<strong>PassED</strong>
|
||||||
<li class="select-language" data-lang="en">English</li>
|
</li>
|
||||||
<li class="select-language" data-lang="es">Español</li>
|
</ul>
|
||||||
<li class="select-language" data-lang="fr">Français</li>
|
|
||||||
<li class="select-language" data-lang="nl">Nederlands</li>
|
|
||||||
<li class="select-language" data-lang="ro">Română</li>
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="container">
|
<ul>
|
||||||
<article>
|
<li>
|
||||||
<header t="enter-password"></header>
|
<a href="https://git.1e99.eu/1e99/passed" t="source"></a>
|
||||||
|
</li>
|
||||||
|
|
||||||
<form id="enter-password">
|
<li>
|
||||||
<fieldset id="enter-password">
|
<details class="dropdown">
|
||||||
<label>
|
<summary t="language"></summary>
|
||||||
<span t="password"></span>
|
<ul dir="rtl">
|
||||||
<textarea name="password"></textarea>
|
<li class="select-lang" data-lang="de">Deutsch</li>
|
||||||
</label>
|
<li class="select-lang" data-lang="en">English</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<label>
|
<main class="container">
|
||||||
<span t="expires-in"></span>
|
<article id="loading" class="hidden" aria-busy="true">
|
||||||
<select name="expires-in">
|
</article>
|
||||||
<option value="3600" selected t="expires-in.1-hour"></option>
|
|
||||||
<option value="43200" t="expires-in.12-hours"></option>
|
|
||||||
<option value="86400" t="expires-in.1-day"></option>
|
|
||||||
<option value="604800" t="expires-in.1-week"></option>
|
|
||||||
<option value="1209600" t="expires-in.2-weeks"></option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<button type="submit" id="enter-password" t="generate-link"></button>
|
<article id="not-found" class="hidden">
|
||||||
</form>
|
<header t="password-not-found"></header>
|
||||||
</article>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<dialog id="link">
|
<p t="not-found-reasons"></p>
|
||||||
<article>
|
|
||||||
<header t="share-link"></header>
|
|
||||||
<fieldset role="group">
|
|
||||||
<input readonly id="link"/>
|
|
||||||
<button t="copy" id="link-copy"></button>
|
|
||||||
</fieldset>
|
|
||||||
<footer>
|
|
||||||
<button class="secondary" id="link-close" t="close"></button>
|
|
||||||
</footer>
|
|
||||||
</article>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<dialog id="not-found">
|
<button id="not-found-ok" t="ok"></button>
|
||||||
<article>
|
</article>
|
||||||
<header t="not-found"></header>
|
|
||||||
<p t="not-found-reason"></p>
|
|
||||||
<footer>
|
|
||||||
<button t="close" id="not-found-close"></button>
|
|
||||||
</footer>
|
|
||||||
</article>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<dialog id="confirm">
|
<article id="confirm" class="hidden">
|
||||||
<article>
|
<header t="reveal-password"></header>
|
||||||
<header t="reveal-password"></header>
|
|
||||||
<p t="reveal-password-once"></p>
|
|
||||||
<footer>
|
|
||||||
<button class="secondary" t="close" id="confirm-close"></button>
|
|
||||||
<button t="ok" id="confirm-ok"></button>
|
|
||||||
</footer>
|
|
||||||
</article>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<dialog id="view">
|
<p t="reveal-password-once"></p>
|
||||||
<article>
|
|
||||||
<header t="password"></header>
|
|
||||||
<textarea readonly id="view-password"></textarea>
|
|
||||||
<footer>
|
|
||||||
<button t="close" id="view-close"></button>
|
|
||||||
</footer>
|
|
||||||
</article>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<dialog id="error">
|
<button id="confirm-no" class="secondary" t="no"></button>
|
||||||
<article>
|
<button id="confirm-yes" t="yes"></button>
|
||||||
<header t="error"></header>
|
</article>
|
||||||
|
|
||||||
<textarea readonly id="error-message"></textarea>
|
<article id="view" class="hidden">
|
||||||
|
<p t="password"></p>
|
||||||
|
|
||||||
<footer>
|
<textarea id="view-password"></textarea>
|
||||||
<button t="close" id="error-close"></button>
|
|
||||||
</footer>
|
<button id="view-ok" t="ok"></button>
|
||||||
</article>
|
</article>
|
||||||
</dialog>
|
|
||||||
</body>
|
<article id="share" class="hidden">
|
||||||
</html>
|
<header t="share-password"></header>
|
||||||
|
|
||||||
|
<form id="share-form">
|
||||||
|
<fieldset id="share-fieldset">
|
||||||
|
<label>
|
||||||
|
<span t="password"></span>
|
||||||
|
<textarea name="password" id="share-password"></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span t="expires-in"></span>
|
||||||
|
<select name="expires-in">
|
||||||
|
<option value="3600" t="1-hour"></option>
|
||||||
|
<option value="43200" t="12-hours"></option>
|
||||||
|
<option value="86400" t="1-day" selected></option>
|
||||||
|
<option value="172800" t="2-days"></option>
|
||||||
|
<option value="604800" t="1-week"></option>
|
||||||
|
<option value="1209600" t="2-weeks"></option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" id="share-submit" t="share"></button>
|
||||||
|
<span>
|
||||||
|
<span t="dont-have-a-password"></span>
|
||||||
|
<a class="contrast" t="generate-one" id="share-generate"></a>
|
||||||
|
</span>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<dialog id="share-dialog">
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<button rel="prev" id="share-close"></button>
|
||||||
|
<p t="share-link"></p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<fieldset role="group">
|
||||||
|
<input type="text" readonly id="share-link">
|
||||||
|
<button id="share-copy" t="copy"></button>
|
||||||
|
</fieldset>
|
||||||
|
</article>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="error">
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<button rel="prev" id="error-close"></button>
|
||||||
|
<p t="error"></p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p id="error-error"></p>
|
||||||
|
</article>
|
||||||
|
</dialog>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
|
@ -1,54 +0,0 @@
|
||||||
/** Upload a password to the server
|
|
||||||
* @param {string} password Encryptes password
|
|
||||||
* @param {number} expiresIn Number of seconds in which the password will expire
|
|
||||||
* @returns {Promise<string>}
|
|
||||||
*/
|
|
||||||
async function uploadPassword(password, expiresIn) {
|
|
||||||
const res = await fetch("/api/password", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
password: password,
|
|
||||||
"expires-in": expiresIn,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const msg = await res.text();
|
|
||||||
throw new Error(`Failed to upload password: ${res.status}: ${msg}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await res.json();
|
|
||||||
return json.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check if the server knows about the password
|
|
||||||
* @param {string} id Password ID
|
|
||||||
* @returns {Promise<bool>}
|
|
||||||
*/
|
|
||||||
async function hasPassword(id) {
|
|
||||||
const res = await fetch(`/api/password/${id}`, {
|
|
||||||
method: "HEAD",
|
|
||||||
});
|
|
||||||
if (res.status == 404) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (res.status == 204) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Failed to check if password exists: ${res.status}: ${msg}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get a password from the server
|
|
||||||
* @param {string} id Password ID
|
|
||||||
* @returns {Promise<string>}
|
|
||||||
*/
|
|
||||||
async function getPassword(id) {
|
|
||||||
const res = await fetch(`/api/password/${id}`);
|
|
||||||
if (!res.ok) {
|
|
||||||
const msg = await res.text();
|
|
||||||
throw new Error(`Failed to get password: ${res.status}: ${msg}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await res.json();
|
|
||||||
return json.password;
|
|
||||||
}
|
|
58
static/js/backend.js
Normal file
58
static/js/backend.js
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
class Backend {
|
||||||
|
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} password Encrypted password
|
||||||
|
* @param {number} expiresIn Seconds until the password will be deleted on the server
|
||||||
|
* @returns {Promise<string>} Password Id
|
||||||
|
*/
|
||||||
|
async createPassword(password, expiresIn) {
|
||||||
|
const res = await fetch("/api/password", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
"password": password,
|
||||||
|
"expires-in": expiresIn,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (res.status != 200) {
|
||||||
|
throw new Error(`Failed to upload password, got status ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = await res.json()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id Password id
|
||||||
|
* @returns {Promise<bool>} If the password exists
|
||||||
|
*/
|
||||||
|
async hasPassword(id) {
|
||||||
|
const res = await fetch(`/api/password/${id}`, {
|
||||||
|
method: "HEAD",
|
||||||
|
})
|
||||||
|
switch (res.status) {
|
||||||
|
case 204:
|
||||||
|
return true
|
||||||
|
case 404:
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
throw new Error(`Failed to check if password exists: ${res.status}: ${msg}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id Password id
|
||||||
|
* @returns {Promise<string>} Encrypted password
|
||||||
|
*/
|
||||||
|
async getPassword(id) {
|
||||||
|
const res = await fetch(`/api/password/${id}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = await res.text()
|
||||||
|
throw new Error(`Failed to get password: ${res.status}: ${msg}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = await res.json()
|
||||||
|
return password
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,108 +1,112 @@
|
||||||
const ALGORITHM_NAME = "AES-GCM";
|
class PasswordCrypto {
|
||||||
const ALGORITHM_LENGTH = 256;
|
|
||||||
const IV_LENGTH = 16;
|
|
||||||
|
|
||||||
const TEXT_ENCODER = new TextEncoder();
|
#encoder
|
||||||
const TEXT_DECODER = new TextDecoder();
|
#decoder
|
||||||
|
|
||||||
/** Coverts an ArrayBuffer into a Base64 encoded string
|
#algorithmName
|
||||||
* @param {ArrayBuffer} buffer
|
#algorithmLength
|
||||||
* @returns {string}
|
#ivLength
|
||||||
*/
|
|
||||||
function bufferToBase64(buffer) {
|
constructor() {
|
||||||
let binary = "";
|
this.#encoder = new TextEncoder()
|
||||||
const bytes = new Uint8Array(buffer);
|
this.#decoder = new TextDecoder()
|
||||||
const len = bytes.byteLength;
|
|
||||||
for (let i = 0; i < len; i++) {
|
this.#algorithmName = "AES-GCM"
|
||||||
binary += String.fromCharCode(bytes[i]);
|
this.#algorithmLength = 256
|
||||||
|
this.#ivLength = 16
|
||||||
}
|
}
|
||||||
|
|
||||||
return btoa(binary);
|
/**
|
||||||
}
|
* @param {string} buffer
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
#bufferToBase64(buffer) {
|
||||||
|
let binary = ""
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
const len = bytes.byteLength
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i])
|
||||||
|
}
|
||||||
|
|
||||||
/** Converts a Base64 encoded string into an ArrayBuffer
|
return btoa(binary)
|
||||||
* @param {string} base64
|
|
||||||
* @returns {ArrayBuffer}
|
|
||||||
*/
|
|
||||||
function base64ToBuffer(base64) {
|
|
||||||
const binaryString = atob(base64);
|
|
||||||
const len = binaryString.length;
|
|
||||||
const bytes = new Uint8Array(len);
|
|
||||||
for (let i = 0; i < len; i++) {
|
|
||||||
bytes[i] = binaryString.charCodeAt(i);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return bytes.buffer;
|
/**
|
||||||
}
|
* @param {string} base64
|
||||||
|
* @returns {string}
|
||||||
/** Generates an key and encrypts "password" using it
|
*/
|
||||||
* @param {Promise<string>} password
|
#base64ToBuffer(base64) {
|
||||||
*/
|
const binaryString = atob(base64)
|
||||||
async function encryptPassword(password) {
|
const len = binaryString.length
|
||||||
const iv = new Uint8Array(IV_LENGTH);
|
const bytes = new Uint8Array(len)
|
||||||
window.crypto.getRandomValues(iv);
|
for (let i = 0; i < len; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i)
|
||||||
const key = await window.crypto.subtle.generateKey(
|
}
|
||||||
{
|
|
||||||
name: ALGORITHM_NAME,
|
return bytes.buffer
|
||||||
length: ALGORITHM_LENGTH,
|
}
|
||||||
},
|
|
||||||
true,
|
/**
|
||||||
["encrypt", "decrypt"],
|
* @param {string} password Plain text password
|
||||||
);
|
* @returns {Promise<{ password: string, key: string, iv: string }>} Encrypted password
|
||||||
|
*/
|
||||||
const encryptedPassword = await window.crypto.subtle.encrypt(
|
async encryptPassword(password) {
|
||||||
{
|
const iv = new Uint8Array(this.#ivLength)
|
||||||
name: ALGORITHM_NAME,
|
window.crypto.getRandomValues(iv)
|
||||||
iv: iv,
|
|
||||||
},
|
const key = await window.crypto.subtle.generateKey(
|
||||||
key,
|
{
|
||||||
TEXT_ENCODER.encode(password),
|
name: this.#algorithmName,
|
||||||
);
|
length: this.#algorithmLength,
|
||||||
|
},
|
||||||
const exportedKey = await window.crypto.subtle.exportKey("raw", key);
|
true,
|
||||||
|
["encrypt", "decrypt"],
|
||||||
const encodedPassword = bufferToBase64(encryptedPassword);
|
)
|
||||||
const encodedKey = bufferToBase64(exportedKey);
|
const rawKey = await window.crypto.subtle.exportKey("raw", key)
|
||||||
const encodedIv = bufferToBase64(iv.buffer);
|
|
||||||
|
const encrypted = await window.crypto.subtle.encrypt(
|
||||||
return {
|
{
|
||||||
password: encodedPassword,
|
name: this.#algorithmName,
|
||||||
key: encodedKey,
|
iv: iv,
|
||||||
iv: encodedIv,
|
},
|
||||||
};
|
key,
|
||||||
}
|
this.#encoder.encode(password),
|
||||||
|
)
|
||||||
/** Decodes to key and iv and decryptes the password
|
|
||||||
* @param {string} password
|
return {
|
||||||
* @param {string} key
|
password: this.#bufferToBase64(encrypted),
|
||||||
* @param {string} iv
|
key: this.#bufferToBase64(rawKey),
|
||||||
* @returns {Promise<string>}
|
iv: this.#bufferToBase64(iv.buffer),
|
||||||
*/
|
}
|
||||||
async function decryptPassword(password, key, iv) {
|
}
|
||||||
const decodedPassword = base64ToBuffer(password);
|
|
||||||
const decodedKey = base64ToBuffer(key);
|
/**
|
||||||
const decodedIv = base64ToBuffer(iv);
|
* @param {string} password Encrypted password
|
||||||
|
* @param {string} key
|
||||||
const cryptoKey = await window.crypto.subtle.importKey(
|
* @param {string} iv
|
||||||
"raw",
|
* @returns {Promise<string>} Plain text password
|
||||||
decodedKey,
|
*/
|
||||||
{
|
async decryptPassword(password, key, iv) {
|
||||||
name: ALGORITHM_NAME,
|
const ckey = await window.crypto.subtle.importKey(
|
||||||
length: ALGORITHM_LENGTH,
|
"raw",
|
||||||
},
|
this.#base64ToBuffer(key),
|
||||||
true,
|
{
|
||||||
["encrypt", "decrypt"],
|
name: this.#algorithmName,
|
||||||
);
|
length: this.#algorithmLength,
|
||||||
|
},
|
||||||
const decryptedPassword = await window.crypto.subtle.decrypt(
|
true,
|
||||||
{
|
["encrypt", "decrypt"],
|
||||||
name: ALGORITHM_NAME,
|
)
|
||||||
iv: decodedIv,
|
|
||||||
},
|
const dpassword = await window.crypto.subtle.decrypt(
|
||||||
cryptoKey,
|
{
|
||||||
decodedPassword,
|
name: this.#algorithmName,
|
||||||
);
|
iv: this.#base64ToBuffer(iv),
|
||||||
|
},
|
||||||
return TEXT_DECODER.decode(decryptedPassword);
|
ckey,
|
||||||
|
this.#base64ToBuffer(password),
|
||||||
|
)
|
||||||
|
|
||||||
|
return this.#decoder.decode(dpassword)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,71 +1,79 @@
|
||||||
const LANGUAGE_KEY = "language";
|
class Language {
|
||||||
const languages = {};
|
|
||||||
let language = "en";
|
|
||||||
|
|
||||||
/**
|
#storageKey
|
||||||
* @param {string} lang
|
#languages
|
||||||
*/
|
#language
|
||||||
async function setLanguage(lang) {
|
#errorHandler
|
||||||
if (languages[lang] == undefined) {
|
#defaultLang
|
||||||
const res = await fetch(`/lang/${lang}.json`);
|
|
||||||
const json = await res.json();
|
|
||||||
|
|
||||||
languages[lang] = json;
|
constructor(errorHandler, defaultLang) {
|
||||||
}
|
this.#storageKey = "language"
|
||||||
|
this.#languages = new Map()
|
||||||
|
this.#errorHandler = errorHandler
|
||||||
|
this.#defaultLang = defaultLang
|
||||||
|
|
||||||
language = lang;
|
const setLanguage = localStorage.getItem(this.#storageKey)
|
||||||
translatePage();
|
if (setLanguage == null) {
|
||||||
localStorage.setItem(LANGUAGE_KEY, lang);
|
this.setLanguage(this.#defaultLang)
|
||||||
}
|
this.#language = this.#defaultLang
|
||||||
|
return
|
||||||
/** Translates a key
|
|
||||||
* @param {string} key
|
|
||||||
* @param {any} args
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
function translate(key, ...args) {
|
|
||||||
const bundle = languages[language];
|
|
||||||
let value = bundle[key];
|
|
||||||
if (value == undefined) {
|
|
||||||
value = key;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
|
||||||
value = value.replaceAll(`{${i}}`, args[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function translatePage() {
|
|
||||||
const elements = document.getElementsByTagName("*");
|
|
||||||
for (const element of elements) {
|
|
||||||
const key = element.getAttribute("t");
|
|
||||||
if (key == null) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
element.innerHTML = translate(key);
|
this.#language = setLanguage
|
||||||
}
|
this.setLanguage(this.#language)
|
||||||
}
|
|
||||||
|
|
||||||
function initLanguageButtons() {
|
|
||||||
const buttons = document.querySelectorAll("li.select-language");
|
|
||||||
for (const button of buttons) {
|
|
||||||
button.addEventListener("click", async (ev) => {
|
|
||||||
const self = ev.target;
|
|
||||||
const lang = self.getAttribute("data-lang");
|
|
||||||
await setLanguage(lang);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("load", async () => {
|
|
||||||
let lang = localStorage.getItem(LANGUAGE_KEY);
|
|
||||||
if (lang == null) {
|
|
||||||
lang = "en";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await setLanguage(lang);
|
async #fetchLanguage(lang) {
|
||||||
initLanguageButtons();
|
const cached = this.#languages.get(lang)
|
||||||
});
|
if (cached != null) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const f = await fetch(`/lang/${lang}.json`)
|
||||||
|
const fetched = await f.json()
|
||||||
|
|
||||||
|
this.#languages.set(lang, fetched)
|
||||||
|
return fetched
|
||||||
|
}
|
||||||
|
|
||||||
|
#translate(key, ...args) {
|
||||||
|
let bundle = this.#languages.get(this.#language)
|
||||||
|
if (bundle == null) {
|
||||||
|
bundle = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = bundle[key];
|
||||||
|
if (value == undefined) {
|
||||||
|
value = key
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
value = value.replaceAll(`{${i}}`, args[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
#translatePage() {
|
||||||
|
const elements = document.getElementsByTagName("*")
|
||||||
|
for (const element of elements) {
|
||||||
|
const key = element.getAttribute("t")
|
||||||
|
if (key == null) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
element.innerHTML = this.#translate(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLanguage(lang) {
|
||||||
|
this.#fetchLanguage(lang)
|
||||||
|
.then(() => {
|
||||||
|
this.#language = lang
|
||||||
|
localStorage.setItem(this.#storageKey, lang)
|
||||||
|
|
||||||
|
this.#translatePage()
|
||||||
|
})
|
||||||
|
.catch((err) => this.#errorHandler(err))
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,136 +1,200 @@
|
||||||
function initErrorDialog() {
|
/**
|
||||||
const dialog = document.querySelector("dialog#error");
|
* @param {(e: *) => void} errorHandler
|
||||||
const message = document.querySelector("textarea#error-message");
|
* @param {Backend} backend
|
||||||
const close = document.querySelector("button#error-close");
|
* @param {PasswordCrypto} crypto
|
||||||
|
* @param {string} urlSplit
|
||||||
|
*/
|
||||||
|
function initShare(errorHandler, backend, crypto, urlSplit) {
|
||||||
|
const form = document.querySelector("form#share-form")
|
||||||
|
const fieldset = document.querySelector("fieldset#share-fieldset")
|
||||||
|
const submit = document.querySelector("button#share-submit")
|
||||||
|
const dialog = document.querySelector("dialog#share-dialog")
|
||||||
|
const link = document.querySelector("input#share-link")
|
||||||
|
const copy = document.querySelector("button#share-copy")
|
||||||
|
const close = document.querySelector("button#share-close")
|
||||||
|
const generate = document.querySelector("a#share-generate")
|
||||||
|
const password = document.querySelector("textarea#share-password")
|
||||||
|
|
||||||
close.addEventListener("click", () => dialog.close());
|
form.addEventListener("submit", async (e) => {
|
||||||
|
const data = new FormData(e.target)
|
||||||
return (err) => {
|
e.preventDefault()
|
||||||
message.value = err.toString();
|
|
||||||
dialog.showModal();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function initEnterPassword(errorDialog) {
|
|
||||||
const form = document.querySelector("form#enter-password");
|
|
||||||
const fieldset = document.querySelector("fieldset#enter-password");
|
|
||||||
const submit = document.querySelector("button#enter-password");
|
|
||||||
|
|
||||||
const dialog = document.querySelector("dialog#link");
|
|
||||||
const link = document.querySelector("input#link");
|
|
||||||
const copy = document.querySelector("button#link-copy");
|
|
||||||
const close = document.querySelector("button#link-close");
|
|
||||||
|
|
||||||
form.addEventListener("submit", async (ev) => {
|
|
||||||
const data = new FormData(ev.target);
|
|
||||||
ev.preventDefault();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fieldset.disabled = true;
|
fieldset.disabled = true
|
||||||
submit.ariaBusy = "true";
|
submit.ariaBusy = "true"
|
||||||
|
|
||||||
const password = await encryptPassword(data.get("password"));
|
const encrypted = await crypto.encryptPassword(data.get("password"))
|
||||||
|
const id = await backend.createPassword(
|
||||||
const id = await uploadPassword(
|
encrypted.password,
|
||||||
password.password,
|
|
||||||
parseInt(data.get("expires-in")),
|
parseInt(data.get("expires-in")),
|
||||||
);
|
)
|
||||||
|
|
||||||
const url = new URL(window.location.toString());
|
const url = new URL(window.location)
|
||||||
url.hash = [id, password.key, password.iv].join(":");
|
url.hash = [id, encrypted.key, encrypted.iv].join(urlSplit)
|
||||||
|
url.search = ""
|
||||||
|
|
||||||
link.value = url.toString();
|
link.value = url.toString()
|
||||||
dialog.showModal();
|
dialog.showModal()
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
errorDialog(error);
|
errorHandler(e)
|
||||||
} finally {
|
} finally {
|
||||||
fieldset.disabled = false;
|
fieldset.disabled = false
|
||||||
submit.ariaBusy = "false";
|
submit.ariaBusy = "false"
|
||||||
ev.target.reset();
|
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
dialog.addEventListener("close", (ev) => {
|
copy.addEventListener("click", () => {
|
||||||
this.link.value = "";
|
link.select()
|
||||||
});
|
link.setSelectionRange(0, 99999)
|
||||||
|
navigator.clipboard.writeText(link.value)
|
||||||
copy.addEventListener("click", (ev) => {
|
})
|
||||||
link.select();
|
|
||||||
link.setSelectionRange(0, 99999);
|
|
||||||
navigator.clipboard.writeText(link.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
close.addEventListener("click", (ev) => {
|
|
||||||
dialog.close();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmViewPassword(errorDialog) {
|
|
||||||
const dialog = document.querySelector("dialog#confirm");
|
|
||||||
const close = document.querySelector("button#confirm-close");
|
|
||||||
const ok = document.querySelector("button#confirm-ok");
|
|
||||||
const notFoundDialog = document.querySelector("dialog#not-found");
|
|
||||||
const notFoundClose = document.querySelector("button#not-found-close");
|
|
||||||
|
|
||||||
let hash = window.location.hash;
|
|
||||||
hash = hash.substring(1);
|
|
||||||
if (hash.trim() == "") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [id, key, iv] = hash.split(":");
|
|
||||||
const has = await hasPassword(id);
|
|
||||||
if (!has) {
|
|
||||||
notFoundClose.addEventListener("click", () => notFoundDialog.close());
|
|
||||||
notFoundDialog.addEventListener("close", () => (window.location.hash = ""));
|
|
||||||
|
|
||||||
notFoundDialog.showModal();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog.addEventListener("close", () => (window.location.hash = ""));
|
|
||||||
|
|
||||||
close.addEventListener("click", () => {
|
close.addEventListener("click", () => {
|
||||||
dialog.close();
|
dialog.close()
|
||||||
});
|
})
|
||||||
|
|
||||||
ok.addEventListener("click", async () => {
|
generate.addEventListener("click", () => {
|
||||||
try {
|
let result = ""
|
||||||
close.disabled = true;
|
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
ok.disabled = true;
|
for (let i = 0; i < 12; i++) {
|
||||||
ok.ariaBusy = "true";
|
result += charset.charAt(Math.floor(Math.random() * charset.length))
|
||||||
|
|
||||||
const encryptedPassword = await getPassword(id);
|
|
||||||
const password = await decryptPassword(encryptedPassword, key, iv);
|
|
||||||
|
|
||||||
viewPassword(password);
|
|
||||||
} catch (error) {
|
|
||||||
errorDialog(error);
|
|
||||||
} finally {
|
|
||||||
close.disabled = false;
|
|
||||||
ok.disabled = false;
|
|
||||||
ok.ariaBusy = "false";
|
|
||||||
dialog.close();
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
dialog.showModal();
|
password.value = result
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function viewPassword(password) {
|
/**
|
||||||
const dialog = document.querySelector("dialog#view");
|
* @param {(e: *) => void} errorHandler
|
||||||
const viewPassword = document.querySelector("textarea#view-password");
|
* @param {Backend} backend
|
||||||
const close = document.querySelector("button#view-close");
|
* @param {PasswordCrypto} crypto
|
||||||
|
* @param {string} urlSplit
|
||||||
|
* @param {string} hidden
|
||||||
|
*/
|
||||||
|
function initView(errorHandler, backend, crypto, urlSplit, hidden) {
|
||||||
|
const share = document.querySelector("article#share")
|
||||||
|
const loading = document.querySelector("article#loading")
|
||||||
|
const confirm = document.querySelector("article#confirm")
|
||||||
|
const confirmNo = document.querySelector("button#confirm-no")
|
||||||
|
const confirmYes = document.querySelector("button#confirm-yes")
|
||||||
|
const notFound = document.querySelector("article#not-found")
|
||||||
|
const notFoundOk = document.querySelector("button#not-found-ok")
|
||||||
|
const view = document.querySelector("article#view")
|
||||||
|
const viewPassword = document.querySelector("textarea#view-password")
|
||||||
|
const viewOk = document.querySelector("button#view-ok")
|
||||||
|
|
||||||
close.addEventListener("click", () => dialog.close());
|
const hash = window.location.hash
|
||||||
dialog.addEventListener("close", () => (window.location.hash = ""), 0);
|
if (hash == "" || hash == "#") {
|
||||||
|
share.classList.remove(hidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
viewPassword.value = password;
|
const raw = hash.substring(1)
|
||||||
dialog.showModal();
|
const [id, key, iv] = raw.split(urlSplit)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
loading.classList.remove(hidden)
|
||||||
|
|
||||||
|
const has = await backend.hasPassword(id)
|
||||||
|
if (!has) {
|
||||||
|
loading.classList.add(hidden)
|
||||||
|
notFound.classList.remove(hidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.classList.add(hidden)
|
||||||
|
confirm.classList.remove(hidden)
|
||||||
|
} catch (e) {
|
||||||
|
errorHandler(e)
|
||||||
|
} finally {
|
||||||
|
loading.classList.add(hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notFoundOk.addEventListener("click", () => {
|
||||||
|
notFound.classList.add(hidden)
|
||||||
|
share.classList.remove(hidden)
|
||||||
|
window.location.hash = ""
|
||||||
|
})
|
||||||
|
|
||||||
|
confirmNo.addEventListener("click", () => {
|
||||||
|
confirm.classList.add(hidden)
|
||||||
|
share.classList.remove(hidden)
|
||||||
|
window.location.hash = ""
|
||||||
|
})
|
||||||
|
|
||||||
|
confirmYes.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
confirmYes.ariaBusy = "true"
|
||||||
|
confirmNo.disabled = true
|
||||||
|
confirmYes.disabled = true
|
||||||
|
|
||||||
|
const encrypted = await backend.getPassword(id)
|
||||||
|
const password = await crypto.decryptPassword(encrypted, key, iv)
|
||||||
|
|
||||||
|
viewPassword.innerText = password
|
||||||
|
confirm.classList.add(hidden)
|
||||||
|
view.classList.remove(hidden)
|
||||||
|
window.location.hash = ""
|
||||||
|
} catch (e) {
|
||||||
|
errorHandler(e)
|
||||||
|
} finally {
|
||||||
|
confirmYes.ariaBusy = "false"
|
||||||
|
confirmNo.disabled = false
|
||||||
|
confirmYes.disabled = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
viewOk.addEventListener("click", () => {
|
||||||
|
view.classList.add(hidden)
|
||||||
|
share.classList.remove(hidden)
|
||||||
|
window.location.hash = ""
|
||||||
|
})
|
||||||
|
|
||||||
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("load", () => {
|
/**
|
||||||
const errorDialog = initErrorDialog();
|
* @param {Language} language
|
||||||
initEnterPassword(errorDialog);
|
*/
|
||||||
confirmViewPassword(errorDialog);
|
function initLanguage(language) {
|
||||||
});
|
const selectLanguages = document.querySelectorAll("li.select-lang")
|
||||||
|
for (const selectLang of selectLanguages) {
|
||||||
|
const lang = selectLang.getAttribute("data-lang")
|
||||||
|
|
||||||
|
selectLang.addEventListener("click", async () => {
|
||||||
|
language.setLanguage(lang)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const dialog = document.querySelector("dialog#error")
|
||||||
|
const error = document.querySelector("p#error-error")
|
||||||
|
const close = document.querySelector("button#error-close")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {*} error Error
|
||||||
|
*/
|
||||||
|
function handleError(err) {
|
||||||
|
console.error(err)
|
||||||
|
error.innerText = err
|
||||||
|
dialog.showModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
close.addEventListener("click", () => {
|
||||||
|
dialog.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
const backend = new Backend()
|
||||||
|
const crypto = new PasswordCrypto()
|
||||||
|
const language = new Language(handleError, "en")
|
||||||
|
const urlSplit = ":"
|
||||||
|
|
||||||
|
initShare(handleError, backend, crypto, urlSplit)
|
||||||
|
initView(handleError, backend, crypto, urlSplit, "hidden")
|
||||||
|
initLanguage(language)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => init())
|
||||||
|
|
|
@ -1,22 +1,26 @@
|
||||||
{
|
{
|
||||||
"title": "PassED",
|
"share-password": "Password teilen",
|
||||||
"source-code": "Quellcode",
|
"share": "Teilen",
|
||||||
"language": "Sprache",
|
|
||||||
"enter-password": "Passwort eingeben",
|
|
||||||
"password": "Passwort",
|
"password": "Passwort",
|
||||||
"expires-in": "Ablaufzeit",
|
"expires-in": "Ablaufzeit",
|
||||||
"expires-in.1-hour": "1 Stunde",
|
"1-hour": "1 Stunde",
|
||||||
"expires-in.12-hours": "12 Stunden",
|
"12-hours": "12 Stunden",
|
||||||
"expires-in.1-day": "1 Tag",
|
"1-day": "1 Tag",
|
||||||
"expires-in.1-week": "1 Woche",
|
"2-days": "2 Tage",
|
||||||
"expires-in.2-weeks": "2 Wochen",
|
"1-week": "1 Woche",
|
||||||
"generate-link": "Link erstellen",
|
"2-weeks": "2 Wochen",
|
||||||
"share-link": "Link teilen",
|
"share-link": "Link teilen",
|
||||||
"not-found": "Passwort wurde nicht gefunden",
|
|
||||||
"not-found-reason": "Das von Ihnen angefragte Passwort ist vielleicht abgelaufen, wurde bereits angeschaut oder hat noch nie existiert.",
|
|
||||||
"reveal-password": "Passwort anschauen",
|
|
||||||
"reveal-password-once": "Sie können sich das Passwort nur einmal anschauen.",
|
|
||||||
"copy": "Kopieren",
|
"copy": "Kopieren",
|
||||||
"close": "Schließen",
|
"error": "Fehler",
|
||||||
"ok": "OK"
|
"ok": "OK",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nein",
|
||||||
|
"reveal-password": "Password anzeigen",
|
||||||
|
"reveal-password-once": "Sie können sich das Password nur ein mal anschauen.",
|
||||||
|
"not-found": "Passwort nicht gefunden",
|
||||||
|
"not-found-reasons": "Das von ihnen angefragte Passwort ist evtl abgelaufen, wurde bereits angesehen oder hat noch nie existiert.",
|
||||||
|
"source": "Quellcode",
|
||||||
|
"language": "Sprace",
|
||||||
|
"dont-have-a-password": "Haben Sie kein Passwort?",
|
||||||
|
"generate-one": "Generieren Sie eins."
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,23 +1,26 @@
|
||||||
{
|
{
|
||||||
"title": "PassED",
|
"share-password": "Share password",
|
||||||
"source-code": "Source",
|
"share": "Share",
|
||||||
"language": "Language",
|
|
||||||
"enter-password": "Enter Password",
|
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"expires-in": "Expires in",
|
"expires-in": "Expires in",
|
||||||
"expires-in.1-hour": "1 Hour",
|
"1-hour": "1 hour",
|
||||||
"expires-in.12-hours": "2 Hours",
|
"12-hours": "12 hours",
|
||||||
"expires-in.1-day": "1 Day",
|
"1-day": "1 day",
|
||||||
"expires-in.1-week": "1 Week",
|
"2-days": "2 days",
|
||||||
"expires-in.2-weeks": "2 Weeks",
|
"1-week": "1 week",
|
||||||
"generate-link": "Generate Link",
|
"2-weeks": "2 weeks",
|
||||||
"share-link": "Share Link",
|
"share-link": "Share link",
|
||||||
"not-found": "Password not found",
|
|
||||||
"not-found-reason": "The password you requested may have expired, been viewed before or never even existed in the first place.",
|
|
||||||
"reveal-password": "Reveal Password",
|
|
||||||
"reveal-password-once": "You may only reveal the password once",
|
|
||||||
"error": "Error",
|
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"close": "Close",
|
"error": "Error",
|
||||||
"ok": "OK"
|
"ok": "OK",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No",
|
||||||
|
"reveal-password": "Reveal password",
|
||||||
|
"reveal-password-once": "You may only reveal the password once.",
|
||||||
|
"not-found": "Password not found",
|
||||||
|
"not-found-reasons": "The password you requested may have expired, been viewed before or never even existed in the first place.",
|
||||||
|
"source": "Source",
|
||||||
|
"language": "Language",
|
||||||
|
"dont-have-a-password": "Don't have a password?",
|
||||||
|
"generate-one": "Generate one."
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
{
|
|
||||||
"title": "PassED",
|
|
||||||
"source-code": "Fuente",
|
|
||||||
"language": "Idioma",
|
|
||||||
"enter-password": "Introducir Contraseña",
|
|
||||||
"password": "Contraseña",
|
|
||||||
"expires-in": "Expira en",
|
|
||||||
"expires-in.1-hour": "1 Hora",
|
|
||||||
"expires-in.12-hours": "2 Horas",
|
|
||||||
"expires-in.1-day": "1 Día",
|
|
||||||
"expires-in.1-week": "1 Semana",
|
|
||||||
"expires-in.2-weeks": "2 Semanas",
|
|
||||||
"generate-link": "Generar Enlace",
|
|
||||||
"share-link": "Compartir Enlace",
|
|
||||||
"not-found": "Contraseña no encontrada",
|
|
||||||
"not-found-reason": "La contraseña que solicitaste puede haber expirado, haber sido vista antes o nunca haber existido en primer lugar.",
|
|
||||||
"reveal-password": "Revelar Contraseña",
|
|
||||||
"reveal-password-once": "Solo puedes revelar la contraseña una vez",
|
|
||||||
"copy": "Copiar",
|
|
||||||
"close": "Cerrar",
|
|
||||||
"ok": "OK"
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
{
|
|
||||||
"title": "PassED",
|
|
||||||
"source-code": "Source",
|
|
||||||
"language": "Langue",
|
|
||||||
"enter-password": "Entrer le mot de passe",
|
|
||||||
"password": "Mot de passe",
|
|
||||||
"expires-in": "Expire dans",
|
|
||||||
"expires-in.1-hour": "1 Heure",
|
|
||||||
"expires-in.12-hours": "2 Heures",
|
|
||||||
"expires-in.1-day": "1 Jour",
|
|
||||||
"expires-in.1-week": "1 Semaine",
|
|
||||||
"expires-in.2-weeks": "2 Semaines",
|
|
||||||
"generate-link": "Générer le lien",
|
|
||||||
"share-link": "Partager le lien",
|
|
||||||
"not-found": "Mot de passe non trouvé",
|
|
||||||
"not-found-reason": "Le mot de passe que vous avez demandé a peut-être expiré, a été consulté auparavant ou n'a jamais existé.",
|
|
||||||
"reveal-password": "Révéler le mot de passe",
|
|
||||||
"reveal-password-once": "Vous ne pouvez révéler le mot de passe qu'une seule fois",
|
|
||||||
"copy": "Copier",
|
|
||||||
"close": "Fermer",
|
|
||||||
"ok": "OK"
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
{
|
|
||||||
"title": "PassED",
|
|
||||||
"source-code": "Bron",
|
|
||||||
"language": "Taal",
|
|
||||||
"enter-password": "Wachtwoord invoeren",
|
|
||||||
"password": "Wachtwoord",
|
|
||||||
"expires-in": "Expires in",
|
|
||||||
"expires-in.1-hour": "1 uur",
|
|
||||||
"expires-in.12-hours": "2 uur",
|
|
||||||
"expires-in.1-day": "1 dag",
|
|
||||||
"expires-in.1-week": "1 week",
|
|
||||||
"expires-in.2-weeks": "2 weken",
|
|
||||||
"generate-link": "Maak link",
|
|
||||||
"share-link": "Deel Link",
|
|
||||||
"not-found": "Wachtwoord niet gevonden",
|
|
||||||
"not-found-reason": "Het door u aangevraagde wachtwoord is mogelijk verlopen, eerder bekeken of heeft zelfs nooit bestaan.",
|
|
||||||
"reveal-password": "Toon wachtwoord",
|
|
||||||
"reveal-password-once": "U mag het wachtwoord slechts één keer onthullen.",
|
|
||||||
"copy": "Kopieer",
|
|
||||||
"close": "Sluit",
|
|
||||||
"ok": "OK"
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
{
|
|
||||||
"title": "PassED",
|
|
||||||
"source-code": "Sursă",
|
|
||||||
"language": "Limbă",
|
|
||||||
"enter-password": "Introduceți parolă",
|
|
||||||
"password": "Parolă",
|
|
||||||
"expires-in": "Expiră în",
|
|
||||||
"expires-in.1-hour": "O oră",
|
|
||||||
"expires-in.12-hours": "2 ore",
|
|
||||||
"expires-in.1-day": "O zi",
|
|
||||||
"expires-in.1-week": "O săptămână",
|
|
||||||
"expires-in.2-weeks": "2 săptămâni",
|
|
||||||
"generate-link": "Generați link",
|
|
||||||
"share-link": "Distribuiți link",
|
|
||||||
"not-found": "Parola nu poate fi găsită",
|
|
||||||
"not-found-reason": "Parola cerută este posibil să fi expirat, fost folosită, sau să nu existe.",
|
|
||||||
"reveal-password": "Dezvăluiți parola",
|
|
||||||
"reveal-password-once": "Parola nu poate fi dezvăluită decât o dată",
|
|
||||||
"copy": "Copiază",
|
|
||||||
"close": "Inchide",
|
|
||||||
"ok": "OK"
|
|
||||||
}
|
|
Loading…
Add table
Reference in a new issue