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
|
||||
}
|
||||
|
||||
// Clear storage at regular intervals
|
||||
go func() {
|
||||
ticker := time.Tick(time.Duration(clearInterval) * time.Second)
|
||||
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
|
||||
}
|
||||
|
||||
resBody := struct {
|
||||
Id string `json:"id"`
|
||||
}{
|
||||
Id: id,
|
||||
}
|
||||
err = json.NewEncoder(res).Encode(&resBody)
|
||||
err = json.NewEncoder(res).Encode(&id)
|
||||
if err != nil {
|
||||
http.Error(res, "", http.StatusInternalServerError)
|
||||
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,41 +1,40 @@
|
|||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<!--<link rel="icon" href="favicon.png" />-->
|
||||
<title>PassED</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<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/api.js"></script>
|
||||
<script src="/js/backend.js"></script>
|
||||
<script src="/js/crypto.js"></script>
|
||||
<script src="/js/main.js"></script>
|
||||
|
||||
<title>PassED</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="container">
|
||||
<ul>
|
||||
<li>
|
||||
<strong t="title"></strong>
|
||||
<strong>PassED</strong>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://git.1e99.eu/1e99/passed/" t="source-code"></a>
|
||||
<a href="https://git.1e99.eu/1e99/passed" t="source"></a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<details class="dropdown">
|
||||
<summary t="language"></summary>
|
||||
<ul dir="rtl">
|
||||
<li class="select-language" data-lang="de">Deutsch</li>
|
||||
<li class="select-language" data-lang="en">English</li>
|
||||
<li class="select-language" data-lang="es">Español</li>
|
||||
<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>
|
||||
<li class="select-lang" data-lang="de">Deutsch</li>
|
||||
<li class="select-lang" data-lang="en">English</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
|
@ -43,87 +42,90 @@
|
|||
</nav>
|
||||
|
||||
<main class="container">
|
||||
<article>
|
||||
<header t="enter-password"></header>
|
||||
<article id="loading" class="hidden" aria-busy="true">
|
||||
</article>
|
||||
|
||||
<form id="enter-password">
|
||||
<fieldset id="enter-password">
|
||||
<article id="not-found" class="hidden">
|
||||
<header t="password-not-found"></header>
|
||||
|
||||
<p t="not-found-reasons"></p>
|
||||
|
||||
<button id="not-found-ok" t="ok"></button>
|
||||
</article>
|
||||
|
||||
<article id="confirm" class="hidden">
|
||||
<header t="reveal-password"></header>
|
||||
|
||||
<p t="reveal-password-once"></p>
|
||||
|
||||
<button id="confirm-no" class="secondary" t="no"></button>
|
||||
<button id="confirm-yes" t="yes"></button>
|
||||
</article>
|
||||
|
||||
<article id="view" class="hidden">
|
||||
<p t="password"></p>
|
||||
|
||||
<textarea id="view-password"></textarea>
|
||||
|
||||
<button id="view-ok" t="ok"></button>
|
||||
</article>
|
||||
|
||||
<article id="share" class="hidden">
|
||||
<header t="share-password"></header>
|
||||
|
||||
<form id="share-form">
|
||||
<fieldset id="share-fieldset">
|
||||
<label>
|
||||
<span t="password"></span>
|
||||
<textarea name="password"></textarea>
|
||||
<textarea name="password" id="share-password"></textarea>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span t="expires-in"></span>
|
||||
<select name="expires-in">
|
||||
<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>
|
||||
<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>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit" id="enter-password" t="generate-link"></button>
|
||||
<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="link">
|
||||
<dialog id="share-dialog">
|
||||
<article>
|
||||
<header t="share-link"></header>
|
||||
<header>
|
||||
<button rel="prev" id="share-close"></button>
|
||||
<p t="share-link"></p>
|
||||
</header>
|
||||
|
||||
<fieldset role="group">
|
||||
<input readonly id="link"/>
|
||||
<button t="copy" id="link-copy"></button>
|
||||
<input type="text" readonly id="share-link">
|
||||
<button id="share-copy" t="copy"></button>
|
||||
</fieldset>
|
||||
<footer>
|
||||
<button class="secondary" id="link-close" t="close"></button>
|
||||
</footer>
|
||||
</article>
|
||||
</dialog>
|
||||
|
||||
<dialog id="not-found">
|
||||
<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>
|
||||
<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">
|
||||
<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">
|
||||
<article>
|
||||
<header t="error"></header>
|
||||
<header>
|
||||
<button rel="prev" id="error-close"></button>
|
||||
<p t="error"></p>
|
||||
</header>
|
||||
|
||||
<textarea readonly id="error-message"></textarea>
|
||||
|
||||
<footer>
|
||||
<button t="close" id="error-close"></button>
|
||||
</footer>
|
||||
<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";
|
||||
const ALGORITHM_LENGTH = 256;
|
||||
const IV_LENGTH = 16;
|
||||
class PasswordCrypto {
|
||||
|
||||
const TEXT_ENCODER = new TextEncoder();
|
||||
const TEXT_DECODER = new TextDecoder();
|
||||
#encoder
|
||||
#decoder
|
||||
|
||||
/** Coverts an ArrayBuffer into a Base64 encoded string
|
||||
* @param {ArrayBuffer} buffer
|
||||
#algorithmName
|
||||
#algorithmLength
|
||||
#ivLength
|
||||
|
||||
constructor() {
|
||||
this.#encoder = new TextEncoder()
|
||||
this.#decoder = new TextDecoder()
|
||||
|
||||
this.#algorithmName = "AES-GCM"
|
||||
this.#algorithmLength = 256
|
||||
this.#ivLength = 16
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} buffer
|
||||
* @returns {string}
|
||||
*/
|
||||
function bufferToBase64(buffer) {
|
||||
let binary = "";
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const len = bytes.byteLength;
|
||||
#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]);
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
/** Converts a Base64 encoded string into an ArrayBuffer
|
||||
/**
|
||||
* @param {string} base64
|
||||
* @returns {ArrayBuffer}
|
||||
* @returns {string}
|
||||
*/
|
||||
function base64ToBuffer(base64) {
|
||||
const binaryString = atob(base64);
|
||||
const len = binaryString.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
#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);
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
|
||||
return bytes.buffer;
|
||||
return bytes.buffer
|
||||
}
|
||||
|
||||
/** Generates an key and encrypts "password" using it
|
||||
* @param {Promise<string>} password
|
||||
/**
|
||||
* @param {string} password Plain text password
|
||||
* @returns {Promise<{ password: string, key: string, iv: string }>} Encrypted password
|
||||
*/
|
||||
async function encryptPassword(password) {
|
||||
const iv = new Uint8Array(IV_LENGTH);
|
||||
window.crypto.getRandomValues(iv);
|
||||
async encryptPassword(password) {
|
||||
const iv = new Uint8Array(this.#ivLength)
|
||||
window.crypto.getRandomValues(iv)
|
||||
|
||||
const key = await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: ALGORITHM_NAME,
|
||||
length: ALGORITHM_LENGTH,
|
||||
name: this.#algorithmName,
|
||||
length: this.#algorithmLength,
|
||||
},
|
||||
true,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
)
|
||||
const rawKey = await window.crypto.subtle.exportKey("raw", key)
|
||||
|
||||
const encryptedPassword = await window.crypto.subtle.encrypt(
|
||||
const encrypted = await window.crypto.subtle.encrypt(
|
||||
{
|
||||
name: ALGORITHM_NAME,
|
||||
name: this.#algorithmName,
|
||||
iv: iv,
|
||||
},
|
||||
key,
|
||||
TEXT_ENCODER.encode(password),
|
||||
);
|
||||
|
||||
const exportedKey = await window.crypto.subtle.exportKey("raw", key);
|
||||
|
||||
const encodedPassword = bufferToBase64(encryptedPassword);
|
||||
const encodedKey = bufferToBase64(exportedKey);
|
||||
const encodedIv = bufferToBase64(iv.buffer);
|
||||
this.#encoder.encode(password),
|
||||
)
|
||||
|
||||
return {
|
||||
password: encodedPassword,
|
||||
key: encodedKey,
|
||||
iv: encodedIv,
|
||||
};
|
||||
password: this.#bufferToBase64(encrypted),
|
||||
key: this.#bufferToBase64(rawKey),
|
||||
iv: this.#bufferToBase64(iv.buffer),
|
||||
}
|
||||
}
|
||||
|
||||
/** Decodes to key and iv and decryptes the password
|
||||
* @param {string} password
|
||||
/**
|
||||
* @param {string} password Encrypted password
|
||||
* @param {string} key
|
||||
* @param {string} iv
|
||||
* @returns {Promise<string>}
|
||||
* @returns {Promise<string>} Plain text password
|
||||
*/
|
||||
async function decryptPassword(password, key, iv) {
|
||||
const decodedPassword = base64ToBuffer(password);
|
||||
const decodedKey = base64ToBuffer(key);
|
||||
const decodedIv = base64ToBuffer(iv);
|
||||
|
||||
const cryptoKey = await window.crypto.subtle.importKey(
|
||||
async decryptPassword(password, key, iv) {
|
||||
const ckey = await window.crypto.subtle.importKey(
|
||||
"raw",
|
||||
decodedKey,
|
||||
this.#base64ToBuffer(key),
|
||||
{
|
||||
name: ALGORITHM_NAME,
|
||||
length: ALGORITHM_LENGTH,
|
||||
name: this.#algorithmName,
|
||||
length: this.#algorithmLength,
|
||||
},
|
||||
true,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
)
|
||||
|
||||
const decryptedPassword = await window.crypto.subtle.decrypt(
|
||||
const dpassword = await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: ALGORITHM_NAME,
|
||||
iv: decodedIv,
|
||||
name: this.#algorithmName,
|
||||
iv: this.#base64ToBuffer(iv),
|
||||
},
|
||||
cryptoKey,
|
||||
decodedPassword,
|
||||
);
|
||||
ckey,
|
||||
this.#base64ToBuffer(password),
|
||||
)
|
||||
|
||||
return TEXT_DECODER.decode(decryptedPassword);
|
||||
return this.#decoder.decode(dpassword)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,71 +1,79 @@
|
|||
const LANGUAGE_KEY = "language";
|
||||
const languages = {};
|
||||
let language = "en";
|
||||
class Language {
|
||||
|
||||
/**
|
||||
* @param {string} lang
|
||||
*/
|
||||
async function setLanguage(lang) {
|
||||
if (languages[lang] == undefined) {
|
||||
const res = await fetch(`/lang/${lang}.json`);
|
||||
const json = await res.json();
|
||||
#storageKey
|
||||
#languages
|
||||
#language
|
||||
#errorHandler
|
||||
#defaultLang
|
||||
|
||||
languages[lang] = json;
|
||||
constructor(errorHandler, defaultLang) {
|
||||
this.#storageKey = "language"
|
||||
this.#languages = new Map()
|
||||
this.#errorHandler = errorHandler
|
||||
this.#defaultLang = defaultLang
|
||||
|
||||
const setLanguage = localStorage.getItem(this.#storageKey)
|
||||
if (setLanguage == null) {
|
||||
this.setLanguage(this.#defaultLang)
|
||||
this.#language = this.#defaultLang
|
||||
return
|
||||
}
|
||||
|
||||
language = lang;
|
||||
translatePage();
|
||||
localStorage.setItem(LANGUAGE_KEY, lang);
|
||||
this.#language = setLanguage
|
||||
this.setLanguage(this.#language)
|
||||
}
|
||||
|
||||
async #fetchLanguage(lang) {
|
||||
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 = {}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
value = key
|
||||
}
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
value = value.replaceAll(`{${i}}`, args[i]);
|
||||
value = value.replaceAll(`{${i}}`, args[i])
|
||||
}
|
||||
|
||||
return value;
|
||||
return value
|
||||
}
|
||||
|
||||
function translatePage() {
|
||||
const elements = document.getElementsByTagName("*");
|
||||
#translatePage() {
|
||||
const elements = document.getElementsByTagName("*")
|
||||
for (const element of elements) {
|
||||
const key = element.getAttribute("t");
|
||||
const key = element.getAttribute("t")
|
||||
if (key == null) {
|
||||
continue;
|
||||
continue
|
||||
}
|
||||
|
||||
element.innerHTML = translate(key);
|
||||
element.innerHTML = this.#translate(key)
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
setLanguage(lang) {
|
||||
this.#fetchLanguage(lang)
|
||||
.then(() => {
|
||||
this.#language = lang
|
||||
localStorage.setItem(this.#storageKey, lang)
|
||||
|
||||
window.addEventListener("load", async () => {
|
||||
let lang = localStorage.getItem(LANGUAGE_KEY);
|
||||
if (lang == null) {
|
||||
lang = "en";
|
||||
this.#translatePage()
|
||||
})
|
||||
.catch((err) => this.#errorHandler(err))
|
||||
}
|
||||
}
|
||||
|
||||
await setLanguage(lang);
|
||||
initLanguageButtons();
|
||||
});
|
||||
|
|
|
@ -1,136 +1,200 @@
|
|||
function initErrorDialog() {
|
||||
const dialog = document.querySelector("dialog#error");
|
||||
const message = document.querySelector("textarea#error-message");
|
||||
const close = document.querySelector("button#error-close");
|
||||
/**
|
||||
* @param {(e: *) => void} errorHandler
|
||||
* @param {Backend} backend
|
||||
* @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());
|
||||
|
||||
return (err) => {
|
||||
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();
|
||||
form.addEventListener("submit", async (e) => {
|
||||
const data = new FormData(e.target)
|
||||
e.preventDefault()
|
||||
|
||||
try {
|
||||
fieldset.disabled = true;
|
||||
submit.ariaBusy = "true";
|
||||
fieldset.disabled = true
|
||||
submit.ariaBusy = "true"
|
||||
|
||||
const password = await encryptPassword(data.get("password"));
|
||||
|
||||
const id = await uploadPassword(
|
||||
password.password,
|
||||
const encrypted = await crypto.encryptPassword(data.get("password"))
|
||||
const id = await backend.createPassword(
|
||||
encrypted.password,
|
||||
parseInt(data.get("expires-in")),
|
||||
);
|
||||
)
|
||||
|
||||
const url = new URL(window.location.toString());
|
||||
url.hash = [id, password.key, password.iv].join(":");
|
||||
const url = new URL(window.location)
|
||||
url.hash = [id, encrypted.key, encrypted.iv].join(urlSplit)
|
||||
url.search = ""
|
||||
|
||||
link.value = url.toString();
|
||||
dialog.showModal();
|
||||
} catch (error) {
|
||||
errorDialog(error);
|
||||
link.value = url.toString()
|
||||
dialog.showModal()
|
||||
} catch (e) {
|
||||
errorHandler(e)
|
||||
} finally {
|
||||
fieldset.disabled = false;
|
||||
submit.ariaBusy = "false";
|
||||
ev.target.reset();
|
||||
fieldset.disabled = false
|
||||
submit.ariaBusy = "false"
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
dialog.addEventListener("close", (ev) => {
|
||||
this.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 = ""));
|
||||
copy.addEventListener("click", () => {
|
||||
link.select()
|
||||
link.setSelectionRange(0, 99999)
|
||||
navigator.clipboard.writeText(link.value)
|
||||
})
|
||||
|
||||
close.addEventListener("click", () => {
|
||||
dialog.close();
|
||||
});
|
||||
dialog.close()
|
||||
})
|
||||
|
||||
ok.addEventListener("click", async () => {
|
||||
generate.addEventListener("click", () => {
|
||||
let result = ""
|
||||
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
for (let i = 0; i < 12; i++) {
|
||||
result += charset.charAt(Math.floor(Math.random() * charset.length))
|
||||
}
|
||||
|
||||
password.value = result
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(e: *) => void} errorHandler
|
||||
* @param {Backend} backend
|
||||
* @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")
|
||||
|
||||
const hash = window.location.hash
|
||||
if (hash == "" || hash == "#") {
|
||||
share.classList.remove(hidden)
|
||||
return
|
||||
}
|
||||
|
||||
const raw = hash.substring(1)
|
||||
const [id, key, iv] = raw.split(urlSplit)
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
close.disabled = true;
|
||||
ok.disabled = true;
|
||||
ok.ariaBusy = "true";
|
||||
loading.classList.remove(hidden)
|
||||
|
||||
const encryptedPassword = await getPassword(id);
|
||||
const password = await decryptPassword(encryptedPassword, key, iv);
|
||||
const has = await backend.hasPassword(id)
|
||||
if (!has) {
|
||||
loading.classList.add(hidden)
|
||||
notFound.classList.remove(hidden)
|
||||
return
|
||||
}
|
||||
|
||||
viewPassword(password);
|
||||
} catch (error) {
|
||||
errorDialog(error);
|
||||
loading.classList.add(hidden)
|
||||
confirm.classList.remove(hidden)
|
||||
} catch (e) {
|
||||
errorHandler(e)
|
||||
} finally {
|
||||
close.disabled = false;
|
||||
ok.disabled = false;
|
||||
ok.ariaBusy = "false";
|
||||
dialog.close();
|
||||
loading.classList.add(hidden)
|
||||
}
|
||||
});
|
||||
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
async function viewPassword(password) {
|
||||
const dialog = document.querySelector("dialog#view");
|
||||
const viewPassword = document.querySelector("textarea#view-password");
|
||||
const close = document.querySelector("button#view-close");
|
||||
notFoundOk.addEventListener("click", () => {
|
||||
notFound.classList.add(hidden)
|
||||
share.classList.remove(hidden)
|
||||
window.location.hash = ""
|
||||
})
|
||||
|
||||
close.addEventListener("click", () => dialog.close());
|
||||
dialog.addEventListener("close", () => (window.location.hash = ""), 0);
|
||||
confirmNo.addEventListener("click", () => {
|
||||
confirm.classList.add(hidden)
|
||||
share.classList.remove(hidden)
|
||||
window.location.hash = ""
|
||||
})
|
||||
|
||||
viewPassword.value = password;
|
||||
dialog.showModal();
|
||||
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();
|
||||
initEnterPassword(errorDialog);
|
||||
confirmViewPassword(errorDialog);
|
||||
});
|
||||
/**
|
||||
* @param {Language} language
|
||||
*/
|
||||
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",
|
||||
"source-code": "Quellcode",
|
||||
"language": "Sprache",
|
||||
"enter-password": "Passwort eingeben",
|
||||
"share-password": "Password teilen",
|
||||
"share": "Teilen",
|
||||
"password": "Passwort",
|
||||
"expires-in": "Ablaufzeit",
|
||||
"expires-in.1-hour": "1 Stunde",
|
||||
"expires-in.12-hours": "12 Stunden",
|
||||
"expires-in.1-day": "1 Tag",
|
||||
"expires-in.1-week": "1 Woche",
|
||||
"expires-in.2-weeks": "2 Wochen",
|
||||
"generate-link": "Link erstellen",
|
||||
"1-hour": "1 Stunde",
|
||||
"12-hours": "12 Stunden",
|
||||
"1-day": "1 Tag",
|
||||
"2-days": "2 Tage",
|
||||
"1-week": "1 Woche",
|
||||
"2-weeks": "2 Wochen",
|
||||
"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",
|
||||
"close": "Schließen",
|
||||
"ok": "OK"
|
||||
"error": "Fehler",
|
||||
"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",
|
||||
"source-code": "Source",
|
||||
"language": "Language",
|
||||
"enter-password": "Enter Password",
|
||||
"share-password": "Share password",
|
||||
"share": "Share",
|
||||
"password": "Password",
|
||||
"expires-in": "Expires in",
|
||||
"expires-in.1-hour": "1 Hour",
|
||||
"expires-in.12-hours": "2 Hours",
|
||||
"expires-in.1-day": "1 Day",
|
||||
"expires-in.1-week": "1 Week",
|
||||
"expires-in.2-weeks": "2 Weeks",
|
||||
"generate-link": "Generate 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",
|
||||
"1-hour": "1 hour",
|
||||
"12-hours": "12 hours",
|
||||
"1-day": "1 day",
|
||||
"2-days": "2 days",
|
||||
"1-week": "1 week",
|
||||
"2-weeks": "2 weeks",
|
||||
"share-link": "Share link",
|
||||
"copy": "Copy",
|
||||
"close": "Close",
|
||||
"ok": "OK"
|
||||
"error": "Error",
|
||||
"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