Compare commits
7 Commits
141788714b
...
main
Author | SHA1 | Date | |
---|---|---|---|
7c9455f31b
|
|||
c13003cd6f
|
|||
c3db0e6002
|
|||
ea4c8a3a30
|
|||
ccfe6c60cd
|
|||
01d817a206
|
|||
d2d91e6ab3
|
2
.env.production
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
API_BASE_URL=https://yeah.sure.dog
|
||||||
|
BASE_URL=https://sure.dog
|
1
.gitignore
vendored
@@ -10,6 +10,7 @@ lerna-debug.log*
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
dev-dist
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
|
81
index.html
@@ -5,22 +5,13 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
|
||||||
<title>SURE - Secure URL Requests</title>
|
<title>SURE DOG - Secure URL Requests</title>
|
||||||
|
|
||||||
<script type="module">
|
|
||||||
document.documentElement.classList.remove("no-js");
|
|
||||||
document.documentElement.classList.add("js");
|
|
||||||
|
|
||||||
window.onload = function () {
|
|
||||||
document.body.style.opacity = "1";
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<meta name="description" content="Securely request information via SURE links" />
|
<meta name="description" content="Securely request information via SURE links" />
|
||||||
<meta property="og:title" content="SURE - Secure URL Requests" />
|
<meta property="og:title" content="SURE - Secure URL Requests" />
|
||||||
<meta property="og:description" content="Securely request information via SURE links" />
|
<meta property="og:description" content="Securely request information via SURE links" />
|
||||||
<meta property="og:image" content="https://sure.dog/img/logo.png" />
|
<meta property="og:image" content="https://sure.dog/img/logo.png" />
|
||||||
<meta property="og:image:alt" content="Abstract logo of the letter S" />
|
<meta property="og:image:alt" content="A low poly cartoon portrait of a Border Collie wearing green shades" />
|
||||||
<meta property="og:locale" content="en_US" />
|
<meta property="og:locale" content="en_US" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
@@ -30,7 +21,6 @@
|
|||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="application-name" content="SURE" />
|
<meta name="application-name" content="SURE" />
|
||||||
@@ -39,24 +29,24 @@
|
|||||||
<meta name="msapplication-navbutton-color" content="#44a616" />
|
<meta name="msapplication-navbutton-color" content="#44a616" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="msapplication-starturl" content="/" />
|
<meta name="msapplication-starturl" content="/" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#js-warning {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<noscript>
|
||||||
|
<style>
|
||||||
|
#js-warning {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</noscript>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body style="opacity: 0; background-color: #11191f;">
|
<body>
|
||||||
<header class="container">
|
<header class="container" id="header"></header>
|
||||||
<hgroup>
|
<dialog open id="js-warning">
|
||||||
<h1>
|
|
||||||
SURE
|
|
||||||
</h1>
|
|
||||||
<p>
|
|
||||||
<span style="color: #44a616;">S</span>ecure
|
|
||||||
<span style="color: #44a616;">U</span>RL
|
|
||||||
<span style="color: #44a616;">Re</span>quests
|
|
||||||
</p>
|
|
||||||
</hgroup>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- No JS Warning -->
|
|
||||||
<dialog open class="js-warning">
|
|
||||||
<article>
|
<article>
|
||||||
<header>No Javascript Detected</header>
|
<header>No Javascript Detected</header>
|
||||||
<p>
|
<p>
|
||||||
@@ -66,40 +56,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
<div id="page"></div>
|
||||||
<main class="container">
|
|
||||||
<section>
|
|
||||||
<div id="app"></div>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>How it Works:</summary>
|
|
||||||
<ul>
|
|
||||||
<li>Each client generates an ECDH keypair, consisting of a public key and a private key.</li>
|
|
||||||
<li>Your private key is kept in localStorage, and never leaves your device.</li>
|
|
||||||
<li>Your public key is embedded in the URLs you generate. This key can be safely shared anywhere without
|
|
||||||
compromising security.</li>
|
|
||||||
<li>When another client opens your generated URL, they will find your public ECDH key. They then generate a
|
|
||||||
random IV for this specific message, and use it, along with their private ECDH key and your public ECDH key,
|
|
||||||
to derive a shared secret (AES-GCM).</li>
|
|
||||||
<li>This derived shared secret never leaves their device. It is used to encrypt their message to you.
|
|
||||||
The encrypted message, along with their public key and the IV for this message, are embedded in the URL they
|
|
||||||
generate.</li>
|
|
||||||
<li>Upon opening the response URL, your device uses your private ECDH key, along with the public key and IV
|
|
||||||
from the URL, to recreate the shared secret. This secret is used to decrypt the message. If the message was
|
|
||||||
properly encrypted using the expected keys, it will be successfully decrypted and displayed to you.</li>
|
|
||||||
<li>If you clear your browser's local storage, you will not be able to decrypt any response URLs generated
|
|
||||||
with your previous unique URL.</li>
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="container">
|
|
||||||
<p>
|
|
||||||
<a href="https://git.silentsilas.com/silentsilas/sure" target="_blank" rel="noopener noreferrer">Source Code</a> |
|
|
||||||
<a href="https://silentsilas.com" target="_blank" rel="noopener noreferrer">whoami</a>
|
|
||||||
</p>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
|
4765
package-lock.json
generated
13
package.json
@@ -1,19 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "sure",
|
"name": "sure",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"build-standalone": "tsc && vite build --config vite.standalone.config.ts",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/workbox-precaching": "^5.0.0",
|
||||||
|
"sass": "^1.70.0",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vite": "^5.0.8",
|
"vite": "^5.0.8",
|
||||||
"vite-plugin-make-offline": "^1.0.0"
|
"vite-plugin-make-offline": "^1.0.0",
|
||||||
|
"vite-plugin-pwa": "^0.17.5",
|
||||||
|
"workbox-precaching": "^7.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@picocss/pico": "^1.5.11"
|
"@picocss/pico": "^1.5.11",
|
||||||
|
"uhtml": "^4.4.7",
|
||||||
|
"workbox-window": "^7.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 47 KiB |
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 164 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 42 KiB |
Before Width: | Height: | Size: 540 B After Width: | Height: | Size: 921 B |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.7 KiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
BIN
public/img/flowchart.png
Normal file
After Width: | Height: | Size: 278 KiB |
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 66 KiB |
BIN
public/img/public_key_crypto_chart.png
Normal file
After Width: | Height: | Size: 42 KiB |
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "SURE",
|
|
||||||
"short_name": "SURE",
|
|
||||||
"theme_color": "#44a616",
|
|
||||||
"background_color": "#3d006e",
|
|
||||||
"display": "minimal-ui",
|
|
||||||
"scope": "/",
|
|
||||||
"start_url": "/",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/android-chrome-192x192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/android-chrome-512x512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
52
src/components/FAQ.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { html } from "uhtml";
|
||||||
|
|
||||||
|
export const FAQ = html`<details>
|
||||||
|
<summary><strong>FAQ</strong></summary>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>What is this?</strong>
|
||||||
|
<p>
|
||||||
|
SURE is a system for securely sending information via a URL request. It
|
||||||
|
uses the Elliptic Curve Diffie-Hellman algorithm to generate a shared
|
||||||
|
secret between the sender and recipient, which is then used to encrypt
|
||||||
|
the message in a response URL. The device that generated the URL request
|
||||||
|
can then decrypt the message in the URL sent back to them using their
|
||||||
|
private key.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong
|
||||||
|
>What if I want all of my devices to be able to decrypt messages from
|
||||||
|
the same request?</strong
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
If you want to be able to decrypt messages from the same request on
|
||||||
|
multiple devices, you'll need to export the keypair from the device that
|
||||||
|
generated the request, and then import it into the other devices. You
|
||||||
|
can do this by clicking the "Manage Keys" link at the bottom of the
|
||||||
|
page, and then clicking the "Export Keys" button. This will give you a
|
||||||
|
JSON file that you can import on other devices via the "Import Keys"
|
||||||
|
button.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>What if I lose my keys?</strong>
|
||||||
|
<p>
|
||||||
|
If you lose your keys, either from clearing your localstorage or losing
|
||||||
|
your device, you won't be able to decrypt any messages sent to you.
|
||||||
|
You'll need to send over the new request URL from your device to anyone
|
||||||
|
who wants to send you a message.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Why was this built?</strong>
|
||||||
|
<p>
|
||||||
|
This was built as a proof of concept for a secure messaging system that
|
||||||
|
doesn't require a user to sign up for an account or download an app. It
|
||||||
|
can even work entirely offline if you build the app from source and open
|
||||||
|
the HTML file in your browser. You can also manage your keys so that all
|
||||||
|
of your devices can decrypt messages from the same request URL.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>`;
|
39
src/components/Footer.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { html } from "uhtml";
|
||||||
|
import { openSettings } from "./settings/Settings";
|
||||||
|
|
||||||
|
export const Footer = html`<footer class="container">
|
||||||
|
<div>
|
||||||
|
<p class="center">
|
||||||
|
<a
|
||||||
|
href="https://git.silentsilas.com/silentsilas/sure"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>Source Code</a
|
||||||
|
>
|
||||||
|
|
|
||||||
|
<a
|
||||||
|
href="https://silentsilas.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>whoami</a
|
||||||
|
>
|
||||||
|
|
|
||||||
|
<a href="#" @click=${openSettings}>Settings</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="center">Version ${process.env.PACKAGE_VERSION}</p>
|
||||||
|
<p class="center">
|
||||||
|
Last updated on
|
||||||
|
${new Date(process.env.BUILD_TIME as any).toLocaleString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
second: "numeric",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p class="center">Server: <code>${import.meta.env.VITE_BASE_URL}</code></p>
|
||||||
|
<p class="center">API: <code>${import.meta.env.VITE_API_BASE_URL}</code></p>
|
||||||
|
</div>
|
||||||
|
</footer>`;
|
11
src/components/Header.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { html } from "uhtml";
|
||||||
|
|
||||||
|
export const Header = html` <hgroup>
|
||||||
|
<h1>
|
||||||
|
SURE<span class="primary"><a href="/">DOG</a></span>
|
||||||
|
</h1>
|
||||||
|
<h2>
|
||||||
|
<span class="primary">S</span>ecure <span class="primary">U</span>RL
|
||||||
|
<span class="primary">Re</span>quests
|
||||||
|
</h2>
|
||||||
|
</hgroup>`;
|
43
src/components/HowItWorks.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { html } from "uhtml";
|
||||||
|
|
||||||
|
export const HowItWorks = html`<details>
|
||||||
|
<summary><strong>How it Works:</strong></summary>
|
||||||
|
<p>The first thing to understand is how public key cryptography works.</p>
|
||||||
|
<p>
|
||||||
|
Essentially, you generate a pair of keys that can undo the operations of
|
||||||
|
each other. If you encrypt a message with your public key, it can only be
|
||||||
|
decrypted by your private key.
|
||||||
|
</p>
|
||||||
|
<div style="width: fit-content; margin: auto;">
|
||||||
|
<img
|
||||||
|
width="100%"
|
||||||
|
height="auto"
|
||||||
|
loading="lazy"
|
||||||
|
src="/img/public_key_crypto_chart.png"
|
||||||
|
alt="Chart of the public key cryptography process"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p style="margin-top: 20px">
|
||||||
|
This is why you're safe to share your "Request URL" out in the open. Anyone
|
||||||
|
who accesses this URL will receive your public key, and encrypt the message
|
||||||
|
with it along with their own private key. When you receive the URL with
|
||||||
|
their encrypted message, you take their public key and your private key to
|
||||||
|
undo the operations of their private key and your public key.
|
||||||
|
</p>
|
||||||
|
<p><strong>But of course it's not as simple as that</strong></p>
|
||||||
|
<p>
|
||||||
|
Traditional public-key cryptography like RSA is limited in the messages it
|
||||||
|
can encrypt by the size of the public key. To overcome this limitation, we
|
||||||
|
use ECDH for short public keys that can encrypt an arbitrary amount of data
|
||||||
|
by establishing a shared "symmetric" key for encryption/decryption, which is
|
||||||
|
beyond the scope of this explanation. But the following chart should give
|
||||||
|
you a general idea of how the process works.
|
||||||
|
</p>
|
||||||
|
<img
|
||||||
|
width="100%"
|
||||||
|
height="auto"
|
||||||
|
loading="lazy"
|
||||||
|
src="/img/flowchart.png"
|
||||||
|
alt="Flowchart of the SURE process"
|
||||||
|
/>
|
||||||
|
</details>`;
|
25
src/components/Modal.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Hole, html } from "uhtml";
|
||||||
|
import { signal } from "uhtml/preactive";
|
||||||
|
|
||||||
|
type ModalContent = Hole | ((...args: any[]) => Hole);
|
||||||
|
const isModalOpen = signal(false);
|
||||||
|
const Content = signal<ModalContent | undefined>(undefined);
|
||||||
|
export const AppModal = () => html`<dialog ?open=${isModalOpen.value}>
|
||||||
|
<article class="container-fluid">${renderContent(Content.value)}</article>
|
||||||
|
</dialog>`;
|
||||||
|
|
||||||
|
const renderContent = (innerContent: ModalContent | undefined) => {
|
||||||
|
return typeof innerContent === "function"
|
||||||
|
? (innerContent as (...args: any[]) => Hole)()
|
||||||
|
: innerContent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const openModal = (innerContent: ModalContent) => {
|
||||||
|
Content.value = innerContent;
|
||||||
|
isModalOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const closeModal = () => {
|
||||||
|
isModalOpen.value = false;
|
||||||
|
Content.value = undefined;
|
||||||
|
};
|
70
src/components/Update.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { html } from "uhtml";
|
||||||
|
import { signal } from "uhtml/preactive";
|
||||||
|
import {
|
||||||
|
checkServiceWorkers,
|
||||||
|
deregisterServiceWorkers,
|
||||||
|
} from "./settings/Settings";
|
||||||
|
import { Workbox } from "workbox-window";
|
||||||
|
|
||||||
|
const open = signal(false);
|
||||||
|
const registration = signal<ServiceWorkerRegistration | undefined>(undefined);
|
||||||
|
|
||||||
|
export const UpdateModal = () => {
|
||||||
|
return html` <dialog ?open=${open.value}>
|
||||||
|
<article class="container-fluid">
|
||||||
|
<h3>An update is available!</h3>
|
||||||
|
<p>
|
||||||
|
A new version of the app is available! Click the button below to update
|
||||||
|
and reload. If you're stuck in an endless update loop, click Clear Cache
|
||||||
|
below to fix it.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<button aria-label="Update and reload" @click=${update}>Update</button>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<button
|
||||||
|
class="secondary"
|
||||||
|
aria-label="Clear Cache"
|
||||||
|
@click=${deregisterServiceWorkers}
|
||||||
|
>
|
||||||
|
Clear Cache
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</dialog>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = async (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (registration.value && registration.value.waiting) {
|
||||||
|
console.log("Service Worker is updating.");
|
||||||
|
registration.value.waiting.postMessage({ type: "SKIP_WAITING" });
|
||||||
|
} else {
|
||||||
|
console.error("Service Worker failed to update.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkForUpdates = async () => {
|
||||||
|
try {
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
const wb = new Workbox("/sw.js");
|
||||||
|
|
||||||
|
wb.addEventListener("waiting", () => {
|
||||||
|
console.log("Service Worker is ready to handle offline usage.");
|
||||||
|
open.value = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
wb.addEventListener("controlling", () => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
registration.value = await wb.register();
|
||||||
|
|
||||||
|
console.log("Service Worker has been registered.");
|
||||||
|
|
||||||
|
await checkServiceWorkers();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to register Service Worker: ", err);
|
||||||
|
}
|
||||||
|
};
|
64
src/components/settings/KeyManager.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { html } from "uhtml";
|
||||||
|
import {
|
||||||
|
exportKeys,
|
||||||
|
importAndSaveKeys,
|
||||||
|
retrieveOrGenerateKeyPair,
|
||||||
|
} from "../../utils/crypto.ts";
|
||||||
|
import { signal } from "uhtml/preactive";
|
||||||
|
import { LOCAL_STORAGE_KEYS } from "../../utils/store.ts";
|
||||||
|
import { openSettings } from "./Settings.ts";
|
||||||
|
|
||||||
|
const ecdhPublicKey = signal<string | null>(null);
|
||||||
|
const ecdhPrivateKey = signal<string | null>(null);
|
||||||
|
|
||||||
|
export const KeyManager = () => html`
|
||||||
|
<h2>Key Manager</h2>
|
||||||
|
<p>
|
||||||
|
You can export your keys to transfer to a different device, or import keys exported from a different device.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Public Key:
|
||||||
|
<pre>${ecdhPublicKey}</pre>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Private Key:
|
||||||
|
<pre>${ecdhPrivateKey}</pre>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<button @click=${exportKeys}>Export Keys</button>
|
||||||
|
<button @click=${importKeys}>Import Keys</button>
|
||||||
|
<input type="file" accept=".json" style="display: none" id="keypairImportEl" @change=${handleKeypairImport} />
|
||||||
|
<button class="secondary" @click=${openSettings}>Back</button>
|
||||||
|
</p>`;
|
||||||
|
|
||||||
|
function importKeys(event: Event): void {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
const el = target.nextElementSibling as HTMLInputElement;
|
||||||
|
el.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeypairImport(event: Event): void {
|
||||||
|
const fileInput = event.target as HTMLInputElement;
|
||||||
|
const file = fileInput.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (event) => {
|
||||||
|
const keys = JSON.parse(event.target?.result as string);
|
||||||
|
await importAndSaveKeys(keys);
|
||||||
|
|
||||||
|
alert("Successfully imported keypair");
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initKeyManager() {
|
||||||
|
await retrieveOrGenerateKeyPair();
|
||||||
|
ecdhPublicKey.value = localStorage.getItem(
|
||||||
|
LOCAL_STORAGE_KEYS.ECDH_PUBLIC_KEY
|
||||||
|
);
|
||||||
|
ecdhPrivateKey.value = localStorage.getItem(
|
||||||
|
LOCAL_STORAGE_KEYS.ECDH_PRIVATE_KEY
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
142
src/components/settings/NotificationManager.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { html } from "uhtml";
|
||||||
|
import { signal } from "uhtml/preactive";
|
||||||
|
import { API_BASE_URL } from "../../utils/store.ts";
|
||||||
|
import { retrieveOrGenerateKeyPair } from "../../utils/crypto.ts";
|
||||||
|
import { openSettings } from "./Settings.ts";
|
||||||
|
|
||||||
|
const notificationPermission = signal(Notification.permission);
|
||||||
|
const pushSubscription = signal<PushSubscription | null>(null);
|
||||||
|
export const NotificationManager = () => html` <h2>Notifications</h2>
|
||||||
|
<p>
|
||||||
|
This is an <mark>experimental feature</mark> and may not work as expected.
|
||||||
|
It will not work in offline-mode, and requires subscribing to the VAPID Web
|
||||||
|
Push server at <code>${API_BASE_URL}</code> for it to send Push
|
||||||
|
Notifications to your device in the background.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If enabled, you will receive a notification whenever a user generates a URL
|
||||||
|
with a message to send to you. Upon clicking on the notification, it will
|
||||||
|
immediately take you to their generated URL and reveal the message.
|
||||||
|
</p>
|
||||||
|
<p>Notifications: <code>${notificationPermission.value}</code></p>
|
||||||
|
<p>Subscribed: <code>${pushSubscription.value !== null}</code></p>
|
||||||
|
<p>
|
||||||
|
<button
|
||||||
|
@click=${subscribeUserToPush}
|
||||||
|
?disabled=${notificationPermission.value === "granted" &&
|
||||||
|
pushSubscription.value !== null}
|
||||||
|
>
|
||||||
|
Enable Notifications
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click=${unsubscribeUserFromPush}
|
||||||
|
?disabled=${pushSubscription.value === null}
|
||||||
|
>
|
||||||
|
Unsubscribe
|
||||||
|
</button>
|
||||||
|
<button class="secondary" @click=${openSettings}>Back</button>
|
||||||
|
</p>`;
|
||||||
|
|
||||||
|
export async function sendNotification(publicKey: CryptoKey, url: URL) {
|
||||||
|
const exportedKey = await window.crypto.subtle.exportKey("raw", publicKey);
|
||||||
|
const publicKeyString = btoa(
|
||||||
|
String.fromCharCode(...new Uint8Array(exportedKey))
|
||||||
|
);
|
||||||
|
|
||||||
|
await fetch(`${API_BASE_URL}api/send-notification`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
publicKey: publicKeyString,
|
||||||
|
url: url.toString(),
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkPushSubscription() {
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
pushSubscription.value = subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unsubscribeUserFromPush() {
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
throw new Error("No subscription to unsubscribe");
|
||||||
|
}
|
||||||
|
|
||||||
|
const successful = await subscription.unsubscribe();
|
||||||
|
if (!successful) {
|
||||||
|
throw new Error("Failed to unsubscribe");
|
||||||
|
}
|
||||||
|
|
||||||
|
// await fetch(`${API_BASE_URL}api/unsubscribe`, {
|
||||||
|
// method: "POST",
|
||||||
|
// headers: {
|
||||||
|
// "Content-Type": "application/json",
|
||||||
|
// },
|
||||||
|
// body: JSON.stringify({ subscription }),
|
||||||
|
// });
|
||||||
|
|
||||||
|
pushSubscription.value = null;
|
||||||
|
console.log("User is unsubscribed.");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to unsubscribe the user: ", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function subscribeUserToPush(
|
||||||
|
event: Event | undefined = undefined
|
||||||
|
) {
|
||||||
|
event?.preventDefault();
|
||||||
|
try {
|
||||||
|
notificationPermission.value = Notification.permission;
|
||||||
|
if (notificationPermission.value !== "granted") {
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
|
||||||
|
notificationPermission.value = permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notificationPermission.value !== "granted") {
|
||||||
|
throw new Error(
|
||||||
|
`Notification permission not granted: ${notificationPermission.value}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
let subscription = await registration.pushManager.getSubscription();
|
||||||
|
const response = await fetch(`${API_BASE_URL}api/public-key`);
|
||||||
|
const { publicKey: serverPublicKey } = await response.json();
|
||||||
|
if (!subscription) {
|
||||||
|
subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: serverPublicKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { publicKey: publicKey } = await retrieveOrGenerateKeyPair();
|
||||||
|
|
||||||
|
const exportedKey = await window.crypto.subtle.exportKey("raw", publicKey);
|
||||||
|
const exportedKeyString = btoa(
|
||||||
|
String.fromCharCode(...new Uint8Array(exportedKey))
|
||||||
|
);
|
||||||
|
|
||||||
|
await fetch(`${API_BASE_URL}api/subscribe`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ subscription, publicKey: exportedKeyString }),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("User is subscribed.");
|
||||||
|
checkPushSubscription();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to subscribe the user: ", err);
|
||||||
|
}
|
||||||
|
}
|
56
src/components/settings/Settings.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { html } from "uhtml";
|
||||||
|
import { closeModal, openModal } from "../Modal.ts";
|
||||||
|
import { NotificationManager } from "./NotificationManager.ts";
|
||||||
|
import { KeyManager, initKeyManager } from "./KeyManager.ts";
|
||||||
|
import { hasServiceWorkers } from "../../utils/store.ts";
|
||||||
|
|
||||||
|
export const Settings = () => html`<h2>Settings</h2>
|
||||||
|
<section>
|
||||||
|
<button @click=${openKeyManager}>Manage Keys</button>
|
||||||
|
<button @click=${openNotificationManager}>Notifications</button>
|
||||||
|
<button
|
||||||
|
@click=${deregisterServiceWorkers}
|
||||||
|
?disabled=${!hasServiceWorkers.value}
|
||||||
|
>
|
||||||
|
Clear Cache
|
||||||
|
</button>
|
||||||
|
<button class="secondary" @click=${closeModal}>Close</button>
|
||||||
|
</section>`;
|
||||||
|
|
||||||
|
export function openSettings(event: Event) {
|
||||||
|
event.preventDefault;
|
||||||
|
openModal(Settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openKeyManager(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
await initKeyManager();
|
||||||
|
openModal(KeyManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openNotificationManager(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
openModal(NotificationManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkServiceWorkers() {
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
|
hasServiceWorkers.value = registrations.length > 0;
|
||||||
|
console.log("Service Workers registered:", hasServiceWorkers.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deregisterServiceWorkers(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
|
await Promise.all(
|
||||||
|
registrations.map((registration) => registration.unregister())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await checkServiceWorkers();
|
||||||
|
alert(
|
||||||
|
"Service Workers deregistered! If you think you're not seeing the latest version of the app, try refreshing the page."
|
||||||
|
);
|
||||||
|
}
|
73
src/main.ts
@@ -1,39 +1,40 @@
|
|||||||
import "@picocss/pico/css/pico.min.css";
|
import "./style.scss";
|
||||||
import "./style.css";
|
import { page } from "./utils/store.ts";
|
||||||
import { setupRequestPage } from "./routes/request.ts";
|
import { Header } from "./components/Header.ts";
|
||||||
import { setupSendPage } from "./routes/send.ts";
|
import { html } from "uhtml";
|
||||||
import { setupReceivePage } from "./routes/receive.ts";
|
import { effect } from "uhtml/preactive";
|
||||||
|
import { reactive } from "uhtml/reactive";
|
||||||
|
import { Footer } from "./components/Footer.ts";
|
||||||
|
import { HowItWorks } from "./components/HowItWorks.ts";
|
||||||
|
import { FAQ } from "./components/FAQ.ts";
|
||||||
|
import { routeToPage, hashChange } from "./router.ts";
|
||||||
|
import { UpdateModal, checkForUpdates } from "./components/Update.ts";
|
||||||
|
import { subscribeUserToPush } from "./components/settings/NotificationManager.ts";
|
||||||
|
import { AppModal } from "./components/Modal.ts";
|
||||||
|
|
||||||
const start = () => {
|
try {
|
||||||
const fragmentData = new URLSearchParams(window.location.hash.slice(1));
|
const render = reactive(effect);
|
||||||
const appElement = document.querySelector<HTMLElement>("#app");
|
const template = () => html` ${AppModal()} ${UpdateModal()}
|
||||||
|
<main class="container">
|
||||||
|
<section>${routeToPage(page.value)}</section>
|
||||||
|
<div>${HowItWorks}</div>
|
||||||
|
<div>${FAQ}</div>
|
||||||
|
</main>
|
||||||
|
${Footer}`;
|
||||||
|
|
||||||
if (!appElement) {
|
// Listen for changes in the hash and update the state accordingly
|
||||||
throw new Error("No app element found");
|
window.addEventListener("hashchange", hashChange);
|
||||||
|
|
||||||
|
// Initialize the app
|
||||||
|
hashChange();
|
||||||
|
checkForUpdates();
|
||||||
|
const headerElement = document.getElementById("header");
|
||||||
|
render(headerElement, Header);
|
||||||
|
|
||||||
|
const appElement = document.getElementById("page");
|
||||||
|
render(appElement, template);
|
||||||
|
|
||||||
|
if (Notification.permission === "granted") subscribeUserToPush();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the URL contains the 'p', 'iv', and 'm' parameters, then the receiver page is set up
|
|
||||||
if (
|
|
||||||
fragmentData.has("p") &&
|
|
||||||
fragmentData.has("iv") &&
|
|
||||||
fragmentData.has("m")
|
|
||||||
) {
|
|
||||||
setupReceivePage(appElement, {
|
|
||||||
p: decodeURIComponent(fragmentData.get("p")!),
|
|
||||||
iv: decodeURIComponent(fragmentData.get("iv")!),
|
|
||||||
m: decodeURIComponent(fragmentData.get("m")!),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the URL contains the 'p' parameter, then the sender page is set up
|
|
||||||
if (fragmentData.has("p")) {
|
|
||||||
setupSendPage(appElement, fragmentData.get("p")!);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, the request page is set up
|
|
||||||
setupRequestPage(appElement);
|
|
||||||
};
|
|
||||||
|
|
||||||
start();
|
|
||||||
|
43
src/router.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Hole } from "uhtml";
|
||||||
|
import { RequestPage, generateRequestUrl } from "./routes/request.ts";
|
||||||
|
import { SendPage } from "./routes/send.ts";
|
||||||
|
import { ReceivePage, decryptData } from "./routes/receive.ts";
|
||||||
|
import { NotFoundPage } from "./routes/notfound.ts";
|
||||||
|
import { LOCAL_STORAGE_KEYS, page, params } from "./utils/store.ts";
|
||||||
|
|
||||||
|
export enum Route {
|
||||||
|
Request = "request",
|
||||||
|
Send = "send",
|
||||||
|
Receive = "receive",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const routeToPage = (toPage: Route): Hole => {
|
||||||
|
switch (toPage) {
|
||||||
|
case Route.Request:
|
||||||
|
return RequestPage();
|
||||||
|
case Route.Send:
|
||||||
|
return SendPage();
|
||||||
|
case Route.Receive:
|
||||||
|
return ReceivePage();
|
||||||
|
default:
|
||||||
|
return NotFoundPage;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hashChange = () => {
|
||||||
|
const updatedParams = new URLSearchParams(window.location.hash.slice(1));
|
||||||
|
params.value = updatedParams;
|
||||||
|
const p = params.value.get("p");
|
||||||
|
const iv = params.value.get("iv");
|
||||||
|
const m = params.value.get("m");
|
||||||
|
if (p && iv && m) {
|
||||||
|
decryptData({ p, iv, m });
|
||||||
|
page.value = Route.Receive;
|
||||||
|
} else if (p) {
|
||||||
|
localStorage.setItem(LOCAL_STORAGE_KEYS.REQUEST_PUBLIC_KEY, p);
|
||||||
|
page.value = Route.Send;
|
||||||
|
} else {
|
||||||
|
generateRequestUrl();
|
||||||
|
page.value = Route.Request;
|
||||||
|
}
|
||||||
|
};
|
9
src/routes/notfound.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { html } from "uhtml";
|
||||||
|
|
||||||
|
export const NotFoundPage = html`
|
||||||
|
<h2>You Lost, Dog?</h2>
|
||||||
|
<p>
|
||||||
|
I'm really not sure how you got here. But this is my spot; squatter's rights
|
||||||
|
and all. So please, <a href="/">go take a hike</a>.
|
||||||
|
</p>
|
||||||
|
`;
|
@@ -1,16 +1,27 @@
|
|||||||
|
import { html } from "uhtml";
|
||||||
import {
|
import {
|
||||||
deriveSharedSecret,
|
deriveSharedSecret,
|
||||||
decrypt,
|
decrypt,
|
||||||
retrieveOrGenerateKeyPair,
|
retrieveOrGenerateKeyPair,
|
||||||
} from "../utils/crypto";
|
} from "../utils/crypto.ts";
|
||||||
|
import { signal } from "uhtml/preactive";
|
||||||
|
|
||||||
const MESSAGE_OUTPUT_ID = "message";
|
const MESSAGE_OUTPUT_ID = "message";
|
||||||
const TEMPLATE = `
|
const decryptedData = signal("");
|
||||||
|
|
||||||
|
export const ReceivePage = () => {
|
||||||
|
return html`
|
||||||
<details open>
|
<details open>
|
||||||
<summary>How To Use:</summary>
|
<summary>How To Use:</summary>
|
||||||
<ul>
|
<ul>
|
||||||
<li>If someone used your unique request URL to generate this response URL, you should see the decrypted message below.</li>
|
<li>
|
||||||
<li>Be sure to open this from the same browser you used to generate the original request URL.</li>
|
If someone used your unique request URL to generate this response URL,
|
||||||
|
you should see the decrypted message below.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Be sure to open this from the same browser you used to generate the
|
||||||
|
original request URL.
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -19,20 +30,21 @@ const TEMPLATE = `
|
|||||||
id="${MESSAGE_OUTPUT_ID}"
|
id="${MESSAGE_OUTPUT_ID}"
|
||||||
readonly
|
readonly
|
||||||
aria-label="Decrypted Message"
|
aria-label="Decrypted Message"
|
||||||
|
.value=${decryptedData.value}
|
||||||
></textarea>
|
></textarea>
|
||||||
</form>
|
</form>
|
||||||
`;
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
export async function setupReceivePage(
|
export async function decryptData({
|
||||||
element: HTMLElement,
|
p,
|
||||||
params: { p: string; iv: string; m: string }
|
iv,
|
||||||
) {
|
m,
|
||||||
element.innerHTML = TEMPLATE;
|
}: {
|
||||||
|
p: string;
|
||||||
decryptData(params);
|
iv: string;
|
||||||
}
|
m: string;
|
||||||
|
}) {
|
||||||
async function decryptData({ p, iv, m }: { p: string; iv: string; m: string }) {
|
|
||||||
// Parse the 'p' parameter to get publicB
|
// Parse the 'p' parameter to get publicB
|
||||||
const publicBJwk = JSON.parse(atob(p));
|
const publicBJwk = JSON.parse(atob(p));
|
||||||
|
|
||||||
@@ -54,11 +66,5 @@ async function decryptData({ p, iv, m }: { p: string; iv: string; m: string }) {
|
|||||||
const aesKey = await deriveSharedSecret(keyPairA.privateKey, publicB);
|
const aesKey = await deriveSharedSecret(keyPairA.privateKey, publicB);
|
||||||
|
|
||||||
// Decrypt the message using the AES key and IV
|
// Decrypt the message using the AES key and IV
|
||||||
const decryptedData = await decrypt(aesKey, iv, m);
|
decryptedData.value = await decrypt(aesKey, iv, m);
|
||||||
|
|
||||||
// Update the message output with the decrypted message
|
|
||||||
const messageOutput = document.getElementById(
|
|
||||||
MESSAGE_OUTPUT_ID
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
messageOutput.value = decryptedData;
|
|
||||||
}
|
}
|
||||||
|
@@ -1,13 +1,19 @@
|
|||||||
|
import { html } from "uhtml";
|
||||||
import { retrieveOrGenerateKeyPair } from "../utils/crypto.ts";
|
import { retrieveOrGenerateKeyPair } from "../utils/crypto.ts";
|
||||||
|
import { signal } from "uhtml/preactive";
|
||||||
|
|
||||||
const URL_INPUT_ID = "request-url";
|
const URL_INPUT_ID = "request-url";
|
||||||
const TEMPLATE = `
|
const COPY_BUTTON_ID = "copy-button";
|
||||||
|
const requestUrl = signal<undefined | URL>(undefined);
|
||||||
|
|
||||||
|
export function RequestPage() {
|
||||||
|
return html`
|
||||||
<details open>
|
<details open>
|
||||||
<summary>How To Use:</summary>
|
<summary><strong>How To Use:</strong></summary>
|
||||||
<ol>
|
<ol>
|
||||||
<li><strong>Request:</strong> Copy and share the URL below with the person you want to receive data from.</li>
|
<li>Send the link below to someone you want to get a secret from.</li>
|
||||||
<li><strong>Send:</strong> The recipient will open the URL, enter their data, and hit 'Generate Response'. This generates a new URL with their encrypted message.</li>
|
<li>They add their secret and share the URL the app generates.</li>
|
||||||
<li><strong>Receive:</strong> They send you the newly generated URL. Open it to view the decrypted message. Only your browser can decrypt it.</li>
|
<li>Only your browser can open the URL and decrypt the secret.</li>
|
||||||
</ol>
|
</ol>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -15,31 +21,46 @@ const TEMPLATE = `
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="request-url"
|
name="request-url"
|
||||||
id="${URL_INPUT_ID}"
|
.id=${URL_INPUT_ID}
|
||||||
|
.value=${requestUrl && requestUrl.toString()}
|
||||||
placeholder=""
|
placeholder=""
|
||||||
aria-label="Request URL"
|
aria-label="Request URL"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
aria-label="Copy to Clipboard"
|
||||||
|
.id=${COPY_BUTTON_ID}
|
||||||
|
@click=${(event: Event) =>
|
||||||
|
copyToClipboard(event, requestUrl && requestUrl.toString())}
|
||||||
|
>
|
||||||
|
Copy to Clipboard
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export async function setupRequestPage(element: HTMLElement) {
|
|
||||||
element.innerHTML = TEMPLATE;
|
|
||||||
|
|
||||||
const url = await generateUrl();
|
|
||||||
const input = document.getElementById(URL_INPUT_ID) as HTMLInputElement;
|
|
||||||
input.value = url.toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateUrl(): Promise<URL> {
|
async function copyToClipboard(event: Event, url: string) {
|
||||||
|
try {
|
||||||
|
event.preventDefault();
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
alert("Copied to clipboard!");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy to clipboard: ", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateRequestUrl() {
|
||||||
|
try {
|
||||||
const { publicKey: ecdhPublic } = await retrieveOrGenerateKeyPair();
|
const { publicKey: ecdhPublic } = await retrieveOrGenerateKeyPair();
|
||||||
|
const ecdhPublicJwk = await window.crypto.subtle.exportKey(
|
||||||
// Generate URL with public key as the 'p' search parameter
|
"jwk",
|
||||||
const ecdhPublicJwk = await window.crypto.subtle.exportKey("jwk", ecdhPublic);
|
ecdhPublic
|
||||||
const url = new URL(window.location.toString());
|
);
|
||||||
url.search = "";
|
const updatedUrl = new URL(window.location.toString());
|
||||||
url.hash = `p=${btoa(JSON.stringify(ecdhPublicJwk))}`;
|
updatedUrl.search = "";
|
||||||
|
updatedUrl.hash = `p=${btoa(JSON.stringify(ecdhPublicJwk))}`;
|
||||||
// Return the generated URL
|
requestUrl.value = updatedUrl;
|
||||||
return url;
|
} catch (err) {
|
||||||
|
console.error("Failed to generate request URL: ", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,62 +1,85 @@
|
|||||||
|
import { html } from "uhtml";
|
||||||
import {
|
import {
|
||||||
deriveSharedSecret,
|
deriveSharedSecret,
|
||||||
encrypt,
|
encrypt,
|
||||||
retrieveOrGenerateKeyPair,
|
retrieveOrGenerateKeyPair,
|
||||||
} from "../utils/crypto";
|
} from "../utils/crypto.ts";
|
||||||
|
import { signal } from "uhtml/preactive";
|
||||||
|
import { LOCAL_STORAGE_KEYS } from "../utils/store.ts";
|
||||||
|
import { sendNotification } from "../components/settings/NotificationManager.ts";
|
||||||
|
|
||||||
const ENCRYPTED_URL_INPUT_ID = "encrypted-url";
|
const messageToEncrypt = signal("");
|
||||||
const MESSAGE_INPUT_ID = "message";
|
const encryptedUrl = signal("");
|
||||||
const ENCRYPT_BUTTON_ID = "encrypt";
|
const isCopyButtonDisabled = signal(true);
|
||||||
const REQUEST_PUBLIC_KEY = "requestPublicKey";
|
|
||||||
const TEMPLATE = `
|
export const SendPage = () => {
|
||||||
|
return html`
|
||||||
<details open>
|
<details open>
|
||||||
<summary>How To Use:</summary>
|
<summary>How To Use:</summary>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Enter the information you want to send back to the original requester into the text input below.</li>
|
<li>
|
||||||
<li>Click on 'Generate Response'. This will create a new URL that contains your encrypted message.</li>
|
Enter the information you want to send back to the original requester
|
||||||
<li>Send the newly generated URL back to the original requester. Only their browser will be able to decrypt the message.</li>
|
into the text input below.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Click on 'Generate Response'. This will create a new URL that contains
|
||||||
|
your encrypted message.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Send the newly generated URL back to the original requester. Only
|
||||||
|
their browser will be able to decrypt the message.
|
||||||
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<form>
|
<form>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="message"
|
name="message"
|
||||||
id="${MESSAGE_INPUT_ID}"
|
|
||||||
placeholder="Enter your message here..."
|
placeholder="Enter your message here..."
|
||||||
aria-label="Message to Encrypt"
|
aria-label="Messag
|
||||||
|
to Encrypt"
|
||||||
required
|
required
|
||||||
|
value=${messageToEncrypt.value}
|
||||||
|
@input=${(e: Event) => {
|
||||||
|
messageToEncrypt.value = (e.target as HTMLInputElement).value;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
id="${ENCRYPT_BUTTON_ID}"
|
|
||||||
type="submit"
|
type="submit"
|
||||||
value="Generate Response"
|
value="Generate Response"
|
||||||
aria-label="Generate Response"
|
aria-label="Generate Response"
|
||||||
|
@click=${encryptData}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="encrypted-url"
|
name="encrypted-url"
|
||||||
id="${ENCRYPTED_URL_INPUT_ID}"
|
placeholder="URL with your encrypted response
|
||||||
placeholder="URL with your encrypted response will appear here..."
|
will appear here..."
|
||||||
aria-label="Encrypted URL"
|
aria-label="Encrypted URL"
|
||||||
|
value=${encryptedUrl.value}
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
aria-label="Copy to Clipboard"
|
||||||
|
@click=${copyToClipboard}
|
||||||
|
disabled=${isCopyButtonDisabled.value}
|
||||||
|
>
|
||||||
|
Copy to Clipboard
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
`;
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
export async function setupSendPage(element: HTMLElement, key: string) {
|
async function copyToClipboard(event: Event) {
|
||||||
element.innerHTML = TEMPLATE;
|
event.preventDefault();
|
||||||
localStorage.setItem(REQUEST_PUBLIC_KEY, key);
|
await navigator.clipboard.writeText(encryptedUrl.value);
|
||||||
|
alert(
|
||||||
// Add an event listener to the "Encrypt" button
|
"Copied to clipboard! Send this URL to the requester, or they will be notified via a Push Notification if they have it enabled."
|
||||||
const encryptButton = document.getElementById(
|
);
|
||||||
ENCRYPT_BUTTON_ID
|
|
||||||
) as HTMLButtonElement;
|
|
||||||
encryptButton.addEventListener("click", encryptData);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function encryptData(event: Event) {
|
async function encryptData(event: Event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const key = localStorage.getItem(REQUEST_PUBLIC_KEY)!;
|
const key = localStorage.getItem(LOCAL_STORAGE_KEYS.REQUEST_PUBLIC_KEY)!;
|
||||||
|
|
||||||
// Parse the 'p' parameter to get publicA
|
// Parse the 'p' parameter to get publicA
|
||||||
const publicAJwk = JSON.parse(atob(key));
|
const publicAJwk = JSON.parse(atob(key));
|
||||||
@@ -69,27 +92,17 @@ async function encryptData(event: Event) {
|
|||||||
name: "ECDH",
|
name: "ECDH",
|
||||||
namedCurve: "P-256",
|
namedCurve: "P-256",
|
||||||
},
|
},
|
||||||
false,
|
true,
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyPairB = await retrieveOrGenerateKeyPair();
|
const keyPairB = await retrieveOrGenerateKeyPair();
|
||||||
|
|
||||||
// Retrieve the message input
|
|
||||||
const messageInput = document.getElementById(
|
|
||||||
MESSAGE_INPUT_ID
|
|
||||||
) as HTMLInputElement;
|
|
||||||
|
|
||||||
// Derive the AES key from your private key and the recipient's public key
|
// Derive the AES key from your private key and the recipient's public key
|
||||||
const aesKey = await deriveSharedSecret(keyPairB.privateKey, publicA);
|
const aesKey = await deriveSharedSecret(keyPairB.privateKey, publicA);
|
||||||
|
|
||||||
// Encrypt the message input value using the AES key
|
// Encrypt the message input value using the AES key
|
||||||
const { encryptedData, iv } = await encrypt(aesKey, messageInput.value);
|
const { encryptedData, iv } = await encrypt(aesKey, messageToEncrypt.value);
|
||||||
|
|
||||||
// Update the encrypted URL input with the encrypted message
|
|
||||||
const encryptedUrlInput = document.getElementById(
|
|
||||||
ENCRYPTED_URL_INPUT_ID
|
|
||||||
) as HTMLInputElement;
|
|
||||||
|
|
||||||
const ecdhPublicJwk = await window.crypto.subtle.exportKey(
|
const ecdhPublicJwk = await window.crypto.subtle.exportKey(
|
||||||
"jwk",
|
"jwk",
|
||||||
@@ -107,5 +120,8 @@ async function encryptData(event: Event) {
|
|||||||
)
|
)
|
||||||
)}`;
|
)}`;
|
||||||
|
|
||||||
encryptedUrlInput.value = url.toString();
|
sendNotification(publicA, url);
|
||||||
|
|
||||||
|
encryptedUrl.value = url.toString();
|
||||||
|
isCopyButtonDisabled.value = false;
|
||||||
}
|
}
|
||||||
|
@@ -1,11 +0,0 @@
|
|||||||
.js-warning {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-js .js-warning {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
transition: opacity 1s ease-in-out;
|
|
||||||
}
|
|
52
src/style.scss
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
@import "@picocss/pico/scss/pico";
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
color: #348037;
|
||||||
|
font-weight: bold;
|
||||||
|
&:hover {
|
||||||
|
color: darken(#348037, 10%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
width: fit-content;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Green Light scheme (Default) */
|
||||||
|
/* Can be forced with data-theme="light" */
|
||||||
|
[data-theme="light"],
|
||||||
|
:root:not([data-theme="dark"]) {
|
||||||
|
--primary: #348037;
|
||||||
|
--primary-hover: #265f29;
|
||||||
|
--primary-focus: rgba(39, 92, 42, 0.125);
|
||||||
|
--primary-inverse: #FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Green Dark scheme (Auto) */
|
||||||
|
/* Automatically enabled if user has Dark mode enabled */
|
||||||
|
@media only screen and (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme]) {
|
||||||
|
--primary: #43a047;
|
||||||
|
--primary-hover: #4caf50;
|
||||||
|
--primary-focus: rgba(67, 160, 71, 0.25);
|
||||||
|
--primary-inverse: #FFF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Green Dark scheme (Forced) */
|
||||||
|
/* Enabled if forced with data-theme="dark" */
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--primary: #43a047;
|
||||||
|
--primary-hover: #4caf50;
|
||||||
|
--primary-focus: rgba(67, 160, 71, 0.25);
|
||||||
|
--primary-inverse: #FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Green (Common styles) */
|
||||||
|
:root {
|
||||||
|
--form-element-active-border-color: var(--primary);
|
||||||
|
--form-element-focus-color: var(--primary-focus);
|
||||||
|
--switch-color: var(--primary-inverse);
|
||||||
|
--switch-checked-background-color: var(--primary);
|
||||||
|
}
|
52
src/sw.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching";
|
||||||
|
|
||||||
|
// This array will be injected with the actual file manifest
|
||||||
|
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST);
|
||||||
|
cleanupOutdatedCaches();
|
||||||
|
|
||||||
|
// Receive push notifications
|
||||||
|
self.addEventListener("push", function (e) {
|
||||||
|
if (!(self.Notification && self.Notification.permission === "granted")) {
|
||||||
|
//notifications aren't supported or permission not granted!
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.data) {
|
||||||
|
let message = e.data.json();
|
||||||
|
e.waitUntil(
|
||||||
|
self.registration.showNotification("SURE DOG", {
|
||||||
|
body: message.body,
|
||||||
|
icon: "img/logo.png",
|
||||||
|
data: message.data,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", (e) => {
|
||||||
|
// Close the notification popout
|
||||||
|
e.notification.close();
|
||||||
|
// Get all the Window clients
|
||||||
|
e.waitUntil(
|
||||||
|
clients.matchAll({ type: "window" }).then((clientsArr) => {
|
||||||
|
// If a Window tab matching the targeted URL already exists, focus that;
|
||||||
|
const hadWindowToFocus = clientsArr.some((windowClient) =>
|
||||||
|
windowClient.url === e.notification.data.url
|
||||||
|
? (windowClient.focus(), true)
|
||||||
|
: false
|
||||||
|
);
|
||||||
|
// Otherwise, open a new tab to the applicable URL and focus it.
|
||||||
|
if (!hadWindowToFocus)
|
||||||
|
clients
|
||||||
|
.openWindow(e.notification.data.url)
|
||||||
|
.then((windowClient) => (windowClient ? windowClient.focus() : null));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("message", (event) => {
|
||||||
|
if (event.data && event.data.type === "SKIP_WAITING") {
|
||||||
|
self.skipWaiting();
|
||||||
|
}
|
||||||
|
});
|
@@ -1,3 +1,5 @@
|
|||||||
|
import { LOCAL_STORAGE_KEYS } from "./store";
|
||||||
|
|
||||||
export async function generateKeyPair(): Promise<CryptoKeyPair> {
|
export async function generateKeyPair(): Promise<CryptoKeyPair> {
|
||||||
return await window.crypto.subtle.generateKey(
|
return await window.crypto.subtle.generateKey(
|
||||||
{
|
{
|
||||||
@@ -34,11 +36,15 @@ export async function retrieveOrGenerateKeyPair(): Promise<CryptoKeyPair> {
|
|||||||
let ecdhPrivate: CryptoKey;
|
let ecdhPrivate: CryptoKey;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
localStorage.getItem("ecdhPublic") &&
|
localStorage.getItem(LOCAL_STORAGE_KEYS.ECDH_PUBLIC_KEY) &&
|
||||||
localStorage.getItem("ecdhPrivate")
|
localStorage.getItem(LOCAL_STORAGE_KEYS.ECDH_PRIVATE_KEY)
|
||||||
) {
|
) {
|
||||||
const ecdhPublicJwk = JSON.parse(localStorage.getItem("ecdhPublic")!);
|
const ecdhPublicJwk = JSON.parse(
|
||||||
const ecdhPrivateJwk = JSON.parse(localStorage.getItem("ecdhPrivate")!);
|
localStorage.getItem(LOCAL_STORAGE_KEYS.ECDH_PUBLIC_KEY)!
|
||||||
|
);
|
||||||
|
const ecdhPrivateJwk = JSON.parse(
|
||||||
|
localStorage.getItem(LOCAL_STORAGE_KEYS.ECDH_PRIVATE_KEY)!
|
||||||
|
);
|
||||||
|
|
||||||
ecdhPrivate = await window.crypto.subtle.importKey(
|
ecdhPrivate = await window.crypto.subtle.importKey(
|
||||||
"jwk",
|
"jwk",
|
||||||
@@ -78,8 +84,19 @@ async function saveKeys(ecdhPublic: CryptoKey, ecdhPrivate: CryptoKey) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Store ECDH key pair in local storage
|
// Store ECDH key pair in local storage
|
||||||
localStorage.setItem("ecdhPublic", JSON.stringify(ecdhPublicJwk));
|
localStorage.setItem(
|
||||||
localStorage.setItem("ecdhPrivate", JSON.stringify(ecdhPrivateJwk));
|
LOCAL_STORAGE_KEYS.ECDH_PUBLIC_KEY,
|
||||||
|
JSON.stringify(ecdhPublicJwk)
|
||||||
|
);
|
||||||
|
localStorage.setItem(
|
||||||
|
LOCAL_STORAGE_KEYS.ECDH_PRIVATE_KEY,
|
||||||
|
JSON.stringify(ecdhPrivateJwk)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
public: JSON.stringify(ecdhPublicJwk),
|
||||||
|
private: JSON.stringify(ecdhPrivateJwk),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function encrypt(
|
export async function encrypt(
|
||||||
@@ -111,7 +128,6 @@ export async function decrypt(
|
|||||||
iv: string,
|
iv: string,
|
||||||
encryptedData: string
|
encryptedData: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
console.log(iv);
|
|
||||||
// Decode the iv and encryptedData from base64
|
// Decode the iv and encryptedData from base64
|
||||||
const ivUint8Array = base64ToUint8Array(iv);
|
const ivUint8Array = base64ToUint8Array(iv);
|
||||||
const encryptedDataUint8Array = base64ToUint8Array(encryptedData);
|
const encryptedDataUint8Array = base64ToUint8Array(encryptedData);
|
||||||
@@ -141,3 +157,57 @@ function base64ToUint8Array(base64: string): Uint8Array {
|
|||||||
}
|
}
|
||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function downloadFile(content: string, fileName: string, contentType: string) {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
const file = new Blob([content], { type: contentType });
|
||||||
|
|
||||||
|
a.href = URL.createObjectURL(file);
|
||||||
|
a.download = fileName;
|
||||||
|
a.click();
|
||||||
|
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportKeys(): Promise<void> {
|
||||||
|
const keys = await retrieveOrGenerateKeyPair();
|
||||||
|
|
||||||
|
const exportedKeys = await saveKeys(keys.publicKey, keys.privateKey);
|
||||||
|
const exportedKeysJson = JSON.stringify(exportedKeys);
|
||||||
|
|
||||||
|
downloadFile(exportedKeysJson, "keys.json", "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importAndSaveKeys(keys: {
|
||||||
|
public: string;
|
||||||
|
private: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const ecdhPublicJwk = JSON.parse(keys.public);
|
||||||
|
const ecdhPrivateJwk = JSON.parse(keys.private);
|
||||||
|
|
||||||
|
// Import the keys
|
||||||
|
const ecdhPublic = await window.crypto.subtle.importKey(
|
||||||
|
"jwk",
|
||||||
|
ecdhPublicJwk,
|
||||||
|
{
|
||||||
|
name: "ECDH",
|
||||||
|
namedCurve: "P-256",
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const ecdhPrivate = await window.crypto.subtle.importKey(
|
||||||
|
"jwk",
|
||||||
|
ecdhPrivateJwk,
|
||||||
|
{
|
||||||
|
name: "ECDH",
|
||||||
|
namedCurve: "P-256",
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
["deriveKey", "deriveBits"]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save the imported keys
|
||||||
|
saveKeys(ecdhPublic, ecdhPrivate);
|
||||||
|
}
|
||||||
|
23
src/utils/store.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { signal } from "uhtml/preactive";
|
||||||
|
import { Route } from "../router";
|
||||||
|
|
||||||
|
export const API_BASE_URL = new URL(
|
||||||
|
import.meta.env.VITE_API_BASE_URL || "http://localhost:3000"
|
||||||
|
);
|
||||||
|
export const BASE_URL = new URL(
|
||||||
|
import.meta.env.VITE_BASE_URL || "http://localhost:3000"
|
||||||
|
);
|
||||||
|
|
||||||
|
// the current page / params for this URL
|
||||||
|
export const page = signal(Route.Receive);
|
||||||
|
export const params = signal(
|
||||||
|
new URLSearchParams(window.location.hash.slice(1))
|
||||||
|
);
|
||||||
|
|
||||||
|
export enum LOCAL_STORAGE_KEYS {
|
||||||
|
REQUEST_PUBLIC_KEY = "requestPublicKey",
|
||||||
|
ECDH_PUBLIC_KEY = "ecdhPublic",
|
||||||
|
ECDH_PRIVATE_KEY = "ecdhPrivate",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasServiceWorkers = signal(false);
|
6
src/utils/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_BASE_URL?: string;
|
||||||
|
readonly VITE_API_BASE_URL?: string;
|
||||||
|
}
|
1
src/utils/vite-plugin-pwa.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite-plugin-pwa/client" />
|
1
src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
@@ -1,6 +1,79 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { makeOffline } from "vite-plugin-make-offline";
|
import { version } from "./package.json";
|
||||||
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
|
|
||||||
|
const isProduction = process.env.NODE_ENV === "production";
|
||||||
|
|
||||||
|
const API_BASE_URL = new URL(
|
||||||
|
process.env.VITE_API_BASE_URL || "http://localhost:3000"
|
||||||
|
);
|
||||||
|
const API_BASE_URL_SECURE = API_BASE_URL.protocol === "https:";
|
||||||
|
const BASE_URL = new URL(process.env.VITE_BASE_URL || "http://localhost:5137");
|
||||||
|
const BASE_URL_SECURE = BASE_URL.protocol === "https:";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [makeOffline()], // This is the plugin 😃
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/ws": {
|
||||||
|
target: API_BASE_URL_SECURE
|
||||||
|
? `wss://${API_BASE_URL.hostname}`
|
||||||
|
: `ws://${API_BASE_URL.hostname}`,
|
||||||
|
ws: true,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: isProduction,
|
||||||
|
},
|
||||||
|
"/api": {
|
||||||
|
target: BASE_URL_SECURE
|
||||||
|
? `https://${BASE_URL.hostname}`
|
||||||
|
: `http://${BASE_URL.hostname}`,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: isProduction,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
"process.env.PACKAGE_VERSION": JSON.stringify(version),
|
||||||
|
"process.env.BUILD_TIME": JSON.stringify(new Date().toISOString()),
|
||||||
|
},
|
||||||
|
publicDir: "public",
|
||||||
|
plugins: [
|
||||||
|
VitePWA({
|
||||||
|
srcDir: "src",
|
||||||
|
filename: "sw.js",
|
||||||
|
registerType: "prompt",
|
||||||
|
includeAssets: [
|
||||||
|
"**/*.png",
|
||||||
|
"**/*.jpg",
|
||||||
|
"**/*.jpeg",
|
||||||
|
"**/*.svg",
|
||||||
|
"**/*.gif",
|
||||||
|
],
|
||||||
|
strategies: "injectManifest",
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ["**/*"],
|
||||||
|
globDirectory: "dist",
|
||||||
|
},
|
||||||
|
manifest: {
|
||||||
|
name: "SURE",
|
||||||
|
short_name: "SURE",
|
||||||
|
theme_color: "#44a616",
|
||||||
|
background_color: "#3d006e",
|
||||||
|
display: "minimal-ui",
|
||||||
|
scope: "/",
|
||||||
|
start_url: "/",
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: "/android-chrome-192x192.png",
|
||||||
|
sizes: "192x192",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/android-chrome-512x512.png",
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
6
vite.standalone.config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import { makeOffline } from "vite-plugin-make-offline";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [makeOffline()],
|
||||||
|
});
|