Compare commits
4 commits
e3805b1a04
...
9daefc9d2d
Author | SHA1 | Date | |
---|---|---|---|
|
9daefc9d2d | ||
|
5b29bb7285 | ||
|
7536e5d9af | ||
|
aaa1715f32 |
8 changed files with 501 additions and 426 deletions
15
main.go
15
main.go
|
@ -22,7 +22,20 @@ func run() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer storage.Close()
|
go func() {
|
||||||
|
ticker := time.Tick(20 * time.Second)
|
||||||
|
for {
|
||||||
|
<-ticker
|
||||||
|
|
||||||
|
err := storage.ClearExpired()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to clear expired passwords: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Cleared expired passwords.")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("GET /", routes.ServeFiles(embedFS, "static"))
|
mux.Handle("GET /", routes.ServeFiles(embedFS, "static"))
|
||||||
|
|
52
static/api.js
Normal file
52
static/api.js
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
/** Upload a password to the server
|
||||||
|
* @param {string} password Encryptes password
|
||||||
|
* @param {Promise<number>} expiresIn Number of seconds in which the password will expire
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
108
static/crypto.js
Normal file
108
static/crypto.js
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
const ALGORITHM_NAME = "AES-GCM";
|
||||||
|
const ALGORITHM_LENGTH = 256;
|
||||||
|
const IV_LENGTH = 16;
|
||||||
|
|
||||||
|
const TEXT_ENCODER = new TextEncoder();
|
||||||
|
const TEXT_DECODER = new TextDecoder();
|
||||||
|
|
||||||
|
/** Coverts an ArrayBuffer into a Base64 encoded string
|
||||||
|
* @param {ArrayBuffer} buffer
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function 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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Converts a Base64 encoded string into an ArrayBuffer
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generates an key and encrypts "password" using it
|
||||||
|
* @param {Promise<string>} password
|
||||||
|
*/
|
||||||
|
async function encryptPassword(password) {
|
||||||
|
const iv = new Uint8Array(IV_LENGTH);
|
||||||
|
window.crypto.getRandomValues(iv);
|
||||||
|
|
||||||
|
const key = await window.crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: ALGORITHM_NAME,
|
||||||
|
length: ALGORITHM_LENGTH,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
["encrypt", "decrypt"],
|
||||||
|
);
|
||||||
|
|
||||||
|
const encryptedPassword = await window.crypto.subtle.encrypt(
|
||||||
|
{
|
||||||
|
name: ALGORITHM_NAME,
|
||||||
|
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);
|
||||||
|
|
||||||
|
return {
|
||||||
|
password: encodedPassword,
|
||||||
|
key: encodedKey,
|
||||||
|
iv: encodedIv,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decodes to key and iv and decryptes the password
|
||||||
|
* @param {string} password
|
||||||
|
* @param {string} key
|
||||||
|
* @param {string} iv
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
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(
|
||||||
|
"raw",
|
||||||
|
decodedKey,
|
||||||
|
{
|
||||||
|
name: ALGORITHM_NAME,
|
||||||
|
length: ALGORITHM_LENGTH,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
["encrypt", "decrypt"],
|
||||||
|
);
|
||||||
|
|
||||||
|
const decryptedPassword = await window.crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: ALGORITHM_NAME,
|
||||||
|
iv: decodedIv,
|
||||||
|
},
|
||||||
|
cryptoKey,
|
||||||
|
decodedPassword,
|
||||||
|
);
|
||||||
|
|
||||||
|
return TEXT_DECODER.decode(decryptedPassword);
|
||||||
|
}
|
|
@ -7,11 +7,11 @@
|
||||||
<!--<link rel="icon" href="favicon.png" />-->
|
<!--<link rel="icon" href="favicon.png" />-->
|
||||||
<title>PassED</title>
|
<title>PassED</title>
|
||||||
|
|
||||||
|
<script src="/crypto.js"></script>
|
||||||
|
<script src="/api.js"></script>
|
||||||
<script src="/index.js" defer></script>
|
<script src="/index.js" defer></script>
|
||||||
<link rel="stylesheet" href="/pico.min.css" />
|
<link rel="stylesheet" href="/pico.min.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
|
||||||
<nav class="container-fluid">
|
<nav class="container-fluid">
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
|
@ -27,14 +27,10 @@
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="container">
|
<main class="container">
|
||||||
<noscript>
|
|
||||||
<h1>This website requires JavaScript to function.</h1>
|
|
||||||
</noscript>
|
|
||||||
|
|
||||||
<article>
|
<article>
|
||||||
<header>Enter your password</header>
|
<header>Enter Password</header>
|
||||||
|
|
||||||
<form id="password">
|
<form id="enter-password">
|
||||||
<label>
|
<label>
|
||||||
Password
|
Password
|
||||||
<textarea name="password"></textarea>
|
<textarea name="password"></textarea>
|
||||||
|
@ -54,74 +50,79 @@
|
||||||
<button type="submit">Generate URL</button>
|
<button type="submit">Generate URL</button>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<dialog id="url">
|
|
||||||
<article>
|
|
||||||
<header>Password URL</header>
|
|
||||||
|
|
||||||
<fieldset role="group">
|
|
||||||
<input id="password-url" readonly autofocus />
|
|
||||||
<button id="url-copy">Copy</button>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<button id="share-another">Share Another</button>
|
|
||||||
</article>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<dialog id="confirm-view">
|
|
||||||
<article>
|
|
||||||
<header>View Password</header>
|
|
||||||
|
|
||||||
<p class="pico-color-red-500">
|
|
||||||
You may only reveal the password once.
|
|
||||||
</p>
|
|
||||||
<button id="confirm-view">OK</button>
|
|
||||||
</article>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<dialog id="view">
|
|
||||||
<article>
|
|
||||||
<header>Password</header>
|
|
||||||
|
|
||||||
<textarea id="password" readonly></textarea>
|
|
||||||
</article>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<dialog id="loading">
|
|
||||||
<article>
|
|
||||||
<header>Loading...</header>
|
|
||||||
</article>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<dialog id="error">
|
|
||||||
<article>
|
|
||||||
<header>Error</header>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
Something went horribly wrong..
|
|
||||||
<textarea id="error-message" readonly></textarea>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<form method="get" action="/">
|
|
||||||
<button type="submit">Close</button>
|
|
||||||
</form>
|
|
||||||
</article>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<dialog id="password-ne">
|
|
||||||
<article>
|
|
||||||
<header>Password does not exist</header>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
The password you requested may have expired, been viewed
|
|
||||||
before or never even existed in the first place.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form method="get" action="/">
|
|
||||||
<button type="submit">Close</button>
|
|
||||||
</form>
|
|
||||||
</article>
|
|
||||||
</dialog>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<dialog id="url-dialog">
|
||||||
|
<article>
|
||||||
|
<header>Password URL</header>
|
||||||
|
|
||||||
|
<fieldset role="group">
|
||||||
|
<input id="url" readonly autofocus />
|
||||||
|
<button id="url-copy">Copy</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<button id="url-close">Close</button>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="view-dialog">
|
||||||
|
<article>
|
||||||
|
<header>View Password</header>
|
||||||
|
|
||||||
|
<textarea readonly id="view-password" input></textarea>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<button id="view-close">Close</button>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="confirm-view-dialog">
|
||||||
|
<article>
|
||||||
|
<header>View Password</header>
|
||||||
|
|
||||||
|
<p>You may only reveal the password once.</p>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<button id="view-cancel" class="secondary">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button id="view-confirm">Confirm</button>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="not-found">
|
||||||
|
<article>
|
||||||
|
<header>Password does not exist</header>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The password you requested may have expired, been viewed
|
||||||
|
before or never even existed in the first place.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<button id="not-found-close" class="secondary">Close</button>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="loading-dialog">
|
||||||
|
<h1>Loading...</h1>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="error-dialog">
|
||||||
|
<article>
|
||||||
|
<header>Error</header>
|
||||||
|
|
||||||
|
<textarea id="error" readonly></textarea>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<button id="error-reload">Reload the page</button>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</dialog>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
434
static/index.js
434
static/index.js
|
@ -1,269 +1,199 @@
|
||||||
/*
|
const loadingDialog = {
|
||||||
Welcome to the magic behind PassED!
|
dialog: document.querySelector("dialog#loading-dialog"),
|
||||||
This file is responsible for encrypting passwords in the browser and to only send encrypted passwords to the server.
|
init() {
|
||||||
|
this.dialog.addEventListener("cancel", (ev) => {
|
||||||
- https://git.1e99.eu/1e99/passed
|
|
||||||
- https://discord.gg/NuGxJKtDKS
|
|
||||||
*/
|
|
||||||
|
|
||||||
const ALGORITHM_NAME = "AES-GCM";
|
|
||||||
const ALGORITHM_LENGTH = 256;
|
|
||||||
const IV_LENGTH = 16;
|
|
||||||
|
|
||||||
const TEXT_ENCODER = new TextEncoder();
|
|
||||||
const TEXT_DECODER = new TextDecoder();
|
|
||||||
|
|
||||||
const ACTIVE_PAGE = "page-active";
|
|
||||||
|
|
||||||
/** Coverts an ArrayBuffer into a Base64 encoded string
|
|
||||||
* @param {ArrayBuffer} buffer
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
function 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]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return btoa(binary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Converts a Base64 encoded string into an ArrayBuffer
|
|
||||||
* @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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generates an key and encrypts "password" using it
|
|
||||||
* @param {string} password
|
|
||||||
*/
|
|
||||||
async function encryptPassword(password) {
|
|
||||||
const iv = new Uint8Array(IV_LENGTH);
|
|
||||||
window.crypto.getRandomValues(iv);
|
|
||||||
|
|
||||||
const key = await window.crypto.subtle.generateKey(
|
|
||||||
{
|
|
||||||
name: ALGORITHM_NAME,
|
|
||||||
length: ALGORITHM_LENGTH,
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
["encrypt", "decrypt"],
|
|
||||||
);
|
|
||||||
|
|
||||||
const encryptedPassword = await window.crypto.subtle.encrypt(
|
|
||||||
{
|
|
||||||
name: ALGORITHM_NAME,
|
|
||||||
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);
|
|
||||||
|
|
||||||
return {
|
|
||||||
password: encodedPassword,
|
|
||||||
key: encodedKey,
|
|
||||||
iv: encodedIv,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Decodes to key and iv and decryptes the password
|
|
||||||
* @param {string} password
|
|
||||||
* @param {string} key
|
|
||||||
* @param {string} iv
|
|
||||||
*/
|
|
||||||
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(
|
|
||||||
"raw",
|
|
||||||
decodedKey,
|
|
||||||
{
|
|
||||||
name: ALGORITHM_NAME,
|
|
||||||
length: ALGORITHM_LENGTH,
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
["encrypt", "decrypt"],
|
|
||||||
);
|
|
||||||
|
|
||||||
const decryptedPassword = await window.crypto.subtle.decrypt(
|
|
||||||
{
|
|
||||||
name: ALGORITHM_NAME,
|
|
||||||
iv: decodedIv,
|
|
||||||
},
|
|
||||||
cryptoKey,
|
|
||||||
decodedPassword,
|
|
||||||
);
|
|
||||||
|
|
||||||
return TEXT_DECODER.decode(decryptedPassword);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showError(error) {
|
|
||||||
const errorDialog = document.querySelector("dialog#error");
|
|
||||||
const errorMessage = document.querySelector("textarea#error-message");
|
|
||||||
|
|
||||||
errorMessage.value = error;
|
|
||||||
errorDialog.showModal();
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
const passwordForm = document.querySelector("form#password");
|
|
||||||
const loadingDialog = document.querySelector("dialog#loading");
|
|
||||||
const urlDialog = document.querySelector("dialog#url");
|
|
||||||
const urlCopy = document.querySelector("button#url-copy");
|
|
||||||
const passwordUrl = document.querySelector("input#password-url");
|
|
||||||
const shareAnother = document.querySelector("button#share-another");
|
|
||||||
|
|
||||||
passwordForm.addEventListener("submit", async (ev) => {
|
|
||||||
try {
|
|
||||||
loadingDialog.showModal();
|
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const data = new FormData(ev.target);
|
|
||||||
|
|
||||||
const encrypted = await encryptPassword(data.get("password"));
|
|
||||||
|
|
||||||
const res = await fetch("/api/password", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
password: encrypted.password,
|
|
||||||
"expires-in": parseInt(data.get("expires-in")),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const msg = await res.text();
|
|
||||||
throw new Error(`Failed to upload password: ${res.status}: ${msg}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await res.json();
|
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.set("id", json.id);
|
|
||||||
params.set("key", encrypted.key);
|
|
||||||
params.set("iv", encrypted.iv);
|
|
||||||
|
|
||||||
const url = new URL(window.location);
|
|
||||||
url.search = params.toString();
|
|
||||||
passwordUrl.value = url.toString();
|
|
||||||
|
|
||||||
urlDialog.showModal();
|
|
||||||
passwordUrl.select();
|
|
||||||
} catch (error) {
|
|
||||||
showError(error);
|
|
||||||
} finally {
|
|
||||||
loadingDialog.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
loadingDialog.addEventListener("close", (ev) => ev.preventDefault());
|
|
||||||
shareAnother.addEventListener("click", (ev) => {
|
|
||||||
passwordForm.reset();
|
|
||||||
passwordUrl.value = "";
|
|
||||||
urlDialog.close();
|
|
||||||
});
|
|
||||||
urlCopy.addEventListener("click", async (ev) => {
|
|
||||||
passwordUrl.select();
|
|
||||||
passwordUrl.setSelectionRange(0, 99999);
|
|
||||||
await navigator.clipboard.writeText(passwordUrl.value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmViewPassword(params) {
|
|
||||||
const confirmDialog = document.querySelector("dialog#confirm-view");
|
|
||||||
const confirm = document.querySelector("button#confirm-view");
|
|
||||||
const loadingDialog = document.querySelector("dialog#loading");
|
|
||||||
const passwordNEDialog = document.querySelector("dialog#password-ne");
|
|
||||||
|
|
||||||
try {
|
|
||||||
loadingDialog.showModal();
|
|
||||||
|
|
||||||
const res = await fetch(`/api/password/${params.get("id")}`, {
|
|
||||||
method: "HEAD",
|
|
||||||
});
|
});
|
||||||
console.log(res.status);
|
},
|
||||||
if (res.status == 404) {
|
show() {
|
||||||
loadingDialog.close();
|
this.dialog.showModal();
|
||||||
passwordNEDialog.showModal();
|
},
|
||||||
console.log("Return");
|
close() {
|
||||||
|
this.dialog.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorDialog = {
|
||||||
|
dialog: document.querySelector("dialog#error-dialog"),
|
||||||
|
error: document.querySelector("textarea#error"),
|
||||||
|
reload: document.querySelector("button#error-reload"),
|
||||||
|
init() {
|
||||||
|
this.dialog.addEventListener("close", (ev) => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.reload.addEventListener("click", (ev) => {
|
||||||
|
window.location.href = "/";
|
||||||
|
});
|
||||||
|
},
|
||||||
|
show(err) {
|
||||||
|
this.error.value = err;
|
||||||
|
console.error(err);
|
||||||
|
this.dialog.showModal();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const urlDialog = {
|
||||||
|
dialog: document.querySelector("dialog#url-dialog"),
|
||||||
|
url: document.querySelector("input#url"),
|
||||||
|
urlCopy: document.querySelector("button#url-copy"),
|
||||||
|
close: document.querySelector("button#url-close"),
|
||||||
|
init() {
|
||||||
|
this.dialog.addEventListener("close", (ev) => {});
|
||||||
|
|
||||||
|
this.urlCopy.addEventListener("click", (ev) => {
|
||||||
|
this.url.select();
|
||||||
|
this.url.setSelectionRange(0, 99999);
|
||||||
|
navigator.clipboard.writeText(this.url.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.close.addEventListener("click", (ev) => {
|
||||||
|
this.dialog.close();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
show(url) {
|
||||||
|
this.url.value = url;
|
||||||
|
this.dialog.showModal();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const notFoundDialog = {
|
||||||
|
dialog: document.querySelector("dialog#not-found"),
|
||||||
|
close: document.querySelector("button#not-found-close"),
|
||||||
|
init() {
|
||||||
|
this.dialog.addEventListener("close", (ev) => {
|
||||||
|
window.location.search = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
this.close.addEventListener("click", (ev) => {
|
||||||
|
window.location.search = "";
|
||||||
|
});
|
||||||
|
},
|
||||||
|
show() {
|
||||||
|
this.dialog.showModal();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewDialog = {
|
||||||
|
dialog: document.querySelector("dialog#view-dialog"),
|
||||||
|
password: document.querySelector("textarea#view-password"),
|
||||||
|
close: document.querySelector("button#view-close"),
|
||||||
|
init() {
|
||||||
|
this.dialog.addEventListener("close", (ev) => {
|
||||||
|
window.location.search = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
this.close.addEventListener("click", (ev) => {
|
||||||
|
this.dialog.close();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
show(password) {
|
||||||
|
this.password.value = password;
|
||||||
|
this.dialog.showModal();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmViewDialog = {
|
||||||
|
dialog: document.querySelector("dialog#confirm-view-dialog"),
|
||||||
|
cancel: document.querySelector("button#view-cancel"),
|
||||||
|
confirm: document.querySelector("button#view-confirm"),
|
||||||
|
resolve: null,
|
||||||
|
reject: null,
|
||||||
|
init() {
|
||||||
|
this.dialog.addEventListener("cancel", (ev) => {
|
||||||
|
this.dialog.close();
|
||||||
|
this.resolve(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.cancel.addEventListener("click", (ev) => {
|
||||||
|
this.dialog.close();
|
||||||
|
this.resolve(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.confirm.addEventListener("click", (ev) => {
|
||||||
|
this.dialog.close();
|
||||||
|
this.resolve(true);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
show() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.resolve = resolve;
|
||||||
|
this.reject = reject;
|
||||||
|
this.dialog.showModal();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function viewPassword() {
|
||||||
|
try {
|
||||||
|
loadingDialog.show();
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const id = params.get("id");
|
||||||
|
const key = params.get("key");
|
||||||
|
const iv = params.get("iv");
|
||||||
|
|
||||||
|
const exists = await hasPassword(id);
|
||||||
|
if (!exists) {
|
||||||
|
notFoundDialog.show();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
const shouldView = await confirmViewDialog.show();
|
||||||
const msg = await res.text();
|
if (!shouldView) {
|
||||||
throw new Error(
|
// This is needed for the redirect, otherwise the user won't get redirected
|
||||||
`Failed to check if password exists: ${res.status}: ${msg}`,
|
setTimeout(() => (window.location.search = ""), 0);
|
||||||
);
|
return;
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showError(error);
|
|
||||||
} finally {
|
|
||||||
}
|
|
||||||
|
|
||||||
loadingDialog.close();
|
|
||||||
confirmDialog.showModal();
|
|
||||||
|
|
||||||
confirm.addEventListener("click", (ev) => {
|
|
||||||
confirmDialog.close();
|
|
||||||
viewPassword(params);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function viewPassword(params) {
|
|
||||||
const viewDialog = document.querySelector("dialog#view");
|
|
||||||
const password = document.querySelector("textarea#password");
|
|
||||||
const loadingDialog = document.querySelector("dialog#loading");
|
|
||||||
|
|
||||||
try {
|
|
||||||
loadingDialog.showModal();
|
|
||||||
|
|
||||||
const res = await fetch(`/api/password/${params.get("id")}`);
|
|
||||||
if (!res.ok) {
|
|
||||||
const msg = await res.text();
|
|
||||||
throw new Error(`Failed to load password: ${res.status}: ${msg}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = await res.json();
|
const encrypted = await getPassword(id);
|
||||||
|
const password = await decryptPassword(encrypted, key, iv);
|
||||||
|
|
||||||
const decrypted = await decryptPassword(
|
viewDialog.show(password);
|
||||||
json.password,
|
|
||||||
params.get("key"),
|
|
||||||
params.get("iv"),
|
|
||||||
);
|
|
||||||
|
|
||||||
password.value = decrypted;
|
|
||||||
viewDialog.showModal();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error);
|
errorDialog.show(error);
|
||||||
} finally {
|
} finally {
|
||||||
loadingDialog.close();
|
loadingDialog.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("load", () => {
|
const enterPassword = document.querySelector("form#enter-password");
|
||||||
const query = window.location.search;
|
|
||||||
if (query.trim() != "") {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
confirmViewPassword(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
init();
|
enterPassword.addEventListener("submit", async (ev) => {
|
||||||
|
try {
|
||||||
|
loadingDialog.show();
|
||||||
|
ev.preventDefault();
|
||||||
|
const data = new FormData(ev.target);
|
||||||
|
|
||||||
|
const password = await encryptPassword(data.get("password"));
|
||||||
|
const id = await uploadPassword(
|
||||||
|
password.password,
|
||||||
|
parseInt(data.get("expires-in")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("id", id);
|
||||||
|
params.set("key", password.key);
|
||||||
|
params.set("iv", password.iv);
|
||||||
|
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.search = params.toString();
|
||||||
|
urlDialog.show(url.toString());
|
||||||
|
} catch (error) {
|
||||||
|
errorDialog.show(error);
|
||||||
|
} finally {
|
||||||
|
loadingDialog.close();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
loadingDialog.init();
|
||||||
|
errorDialog.init();
|
||||||
|
urlDialog.init();
|
||||||
|
notFoundDialog.init();
|
||||||
|
viewDialog.init();
|
||||||
|
confirmViewDialog.init();
|
||||||
|
|
||||||
|
const query = window.location.search;
|
||||||
|
if (query.trim() != "") {
|
||||||
|
viewPassword();
|
||||||
|
}
|
||||||
|
|
|
@ -8,23 +8,21 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewDirStore(clearInterval time.Duration, path string) Store {
|
func NewDirStore(path string) Store {
|
||||||
store := &dir{
|
store := &dir{
|
||||||
clearInterval: clearInterval,
|
path: path,
|
||||||
timeLayout: time.RFC3339Nano,
|
|
||||||
path: path,
|
|
||||||
close: make(chan bool),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
go store.clearExpired()
|
|
||||||
return store
|
return store
|
||||||
}
|
}
|
||||||
|
|
||||||
type dir struct {
|
type dir struct {
|
||||||
clearInterval time.Duration
|
path string
|
||||||
timeLayout string
|
}
|
||||||
path string
|
|
||||||
close chan bool
|
type dirEntry struct {
|
||||||
|
Password []byte
|
||||||
|
ExpiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *dir) Create(password []byte, expiresAt time.Time) (string, error) {
|
func (store *dir) Create(password []byte, expiresAt time.Time) (string, error) {
|
||||||
|
@ -46,7 +44,7 @@ func (store *dir) Create(password []byte, expiresAt time.Time) (string, error) {
|
||||||
|
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
entry := entry{
|
entry := dirEntry{
|
||||||
Password: password,
|
Password: password,
|
||||||
ExpiresAt: expiresAt,
|
ExpiresAt: expiresAt,
|
||||||
}
|
}
|
||||||
|
@ -79,7 +77,7 @@ func (store *dir) Get(id string) ([]byte, error) {
|
||||||
|
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
var entry entry
|
var entry dirEntry
|
||||||
err = gob.NewDecoder(file).Decode(&entry)
|
err = gob.NewDecoder(file).Decode(&entry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -98,56 +96,45 @@ func (store *dir) Delete(id string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *dir) Close() error {
|
func (store *dir) ClearExpired() error {
|
||||||
store.close <- true
|
now := time.Now()
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (store *dir) clearExpired() error {
|
entries, err := os.ReadDir(store.path)
|
||||||
ticker := time.NewTicker(store.clearInterval)
|
if err != nil {
|
||||||
defer ticker.Stop()
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
for {
|
for _, file := range entries {
|
||||||
select {
|
id := file.Name()
|
||||||
case <-store.close:
|
path := store.getPath(id)
|
||||||
return nil
|
file, err := os.OpenFile(
|
||||||
case <-ticker.C:
|
path,
|
||||||
// TODO: Error handling?
|
os.O_RDONLY,
|
||||||
now := time.Now()
|
0,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
entries, err := os.ReadDir(store.path)
|
defer file.Close()
|
||||||
|
|
||||||
|
var entry dirEntry
|
||||||
|
err = gob.NewDecoder(file).Decode(&entry)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if now.After(entry.ExpiresAt) {
|
||||||
|
// Close file early as we need to delete it
|
||||||
|
file.Close()
|
||||||
|
err = store.Delete(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
return err
|
||||||
}
|
|
||||||
|
|
||||||
for _, file := range entries {
|
|
||||||
id := file.Name()
|
|
||||||
path := store.getPath(id)
|
|
||||||
file, err := os.OpenFile(
|
|
||||||
path,
|
|
||||||
os.O_RDONLY,
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
var entry entry
|
|
||||||
err = gob.NewDecoder(file).Decode(&entry)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if now.After(entry.ExpiresAt) {
|
|
||||||
// Close file early as we need to delete it
|
|
||||||
file.Close()
|
|
||||||
store.Delete(id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *dir) getPath(id string) string {
|
func (store *dir) getPath(id string) string {
|
||||||
|
|
|
@ -5,23 +5,23 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewRamStore(clearInterval time.Duration) Store {
|
func NewRamStore() Store {
|
||||||
store := &ram{
|
store := &ram{
|
||||||
clearInterval: clearInterval,
|
passwords: make(map[string]ramEntry),
|
||||||
passwords: make(map[string]entry),
|
lock: sync.Mutex{},
|
||||||
lock: sync.Mutex{},
|
|
||||||
close: make(chan bool),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
go store.clearExpired()
|
|
||||||
return store
|
return store
|
||||||
}
|
}
|
||||||
|
|
||||||
type ram struct {
|
type ram struct {
|
||||||
clearInterval time.Duration
|
passwords map[string]ramEntry
|
||||||
passwords map[string]entry
|
lock sync.Mutex
|
||||||
lock sync.Mutex
|
}
|
||||||
close chan bool
|
|
||||||
|
type ramEntry struct {
|
||||||
|
Password []byte
|
||||||
|
ExpiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *ram) Create(password []byte, expiresAt time.Time) (string, error) {
|
func (store *ram) Create(password []byte, expiresAt time.Time) (string, error) {
|
||||||
|
@ -35,7 +35,7 @@ func (store *ram) Create(password []byte, expiresAt time.Time) (string, error) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
store.passwords[id] = entry{
|
store.passwords[id] = ramEntry{
|
||||||
Password: password,
|
Password: password,
|
||||||
ExpiresAt: expiresAt,
|
ExpiresAt: expiresAt,
|
||||||
}
|
}
|
||||||
|
@ -63,30 +63,19 @@ func (store *ram) Delete(id string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *ram) Close() error {
|
func (store *ram) ClearExpired() error {
|
||||||
store.close <- true
|
store.lock.Lock()
|
||||||
return nil
|
defer store.lock.Unlock()
|
||||||
}
|
time := time.Now()
|
||||||
|
|
||||||
func (store *ram) clearExpired() error {
|
for id, password := range store.passwords {
|
||||||
ticker := time.NewTicker(store.clearInterval)
|
if time.After(password.ExpiresAt) {
|
||||||
defer ticker.Stop()
|
err := store.Delete(id)
|
||||||
|
if err != nil {
|
||||||
for {
|
return err
|
||||||
select {
|
|
||||||
case <-store.close:
|
|
||||||
return nil
|
|
||||||
case <-ticker.C:
|
|
||||||
store.lock.Lock()
|
|
||||||
time := time.Now()
|
|
||||||
|
|
||||||
for id, password := range store.passwords {
|
|
||||||
if time.After(password.ExpiresAt) {
|
|
||||||
store.Delete(id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
store.lock.Unlock()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,16 +14,11 @@ var (
|
||||||
ErrFull = errors.New("storage is filled")
|
ErrFull = errors.New("storage is filled")
|
||||||
)
|
)
|
||||||
|
|
||||||
type entry struct {
|
|
||||||
Password []byte
|
|
||||||
ExpiresAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type Store interface {
|
type Store interface {
|
||||||
Create(password []byte, expiresAt time.Time) (string, error)
|
Create(password []byte, expiresAt time.Time) (string, error)
|
||||||
Get(id string) ([]byte, error)
|
Get(id string) ([]byte, error)
|
||||||
Delete(id string) error
|
Delete(id string) error
|
||||||
Close() error
|
ClearExpired() error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStore() (Store, error) {
|
func NewStore() (Store, error) {
|
||||||
|
@ -32,7 +27,7 @@ func NewStore() (Store, error) {
|
||||||
|
|
||||||
switch storeType {
|
switch storeType {
|
||||||
case "ram":
|
case "ram":
|
||||||
return NewRamStore(20 * time.Second), nil
|
return NewRamStore(), nil
|
||||||
case "dir":
|
case "dir":
|
||||||
path := os.Getenv("PASSED_STORE_DIR_PATH")
|
path := os.Getenv("PASSED_STORE_DIR_PATH")
|
||||||
if path == "" {
|
if path == "" {
|
||||||
|
@ -40,11 +35,11 @@ func NewStore() (Store, error) {
|
||||||
path = "passwords"
|
path = "passwords"
|
||||||
}
|
}
|
||||||
|
|
||||||
return NewDirStore(60*time.Second, path), nil
|
return NewDirStore(path), nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
log.Printf("No PASSED_STORE_TYPE provided, defaulting to memory store.")
|
log.Printf("No PASSED_STORE_TYPE provided, defaulting to memory store.")
|
||||||
return NewRamStore(20 * time.Second), nil
|
return NewRamStore(), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue