aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/envshare/app
diff options
context:
space:
mode:
Diffstat (limited to 'envshare/app')
-rw-r--r--envshare/app/[compositeKey]/page.tsx7
-rw-r--r--envshare/app/components/analytics.tsx22
-rw-r--r--envshare/app/components/error.tsx11
-rw-r--r--envshare/app/components/stats.tsx57
-rw-r--r--envshare/app/components/testimony.tsx125
-rw-r--r--envshare/app/components/title.tsx9
-rw-r--r--envshare/app/deploy/page.tsx89
-rw-r--r--envshare/app/globals.css15
-rw-r--r--envshare/app/head.tsx42
-rw-r--r--envshare/app/header.tsx60
-rw-r--r--envshare/app/layout.tsx58
-rw-r--r--envshare/app/page.tsx50
-rw-r--r--envshare/app/share/page.tsx227
-rw-r--r--envshare/app/unseal/page.tsx159
14 files changed, 931 insertions, 0 deletions
diff --git a/envshare/app/[compositeKey]/page.tsx b/envshare/app/[compositeKey]/page.tsx
new file mode 100644
index 0000000..d91f182
--- /dev/null
+++ b/envshare/app/[compositeKey]/page.tsx
@@ -0,0 +1,7 @@
+import { redirect } from "next/navigation";
+
+// This page is here for backwards compatibility with old links.
+// Old links were of the form /{compositeKey} and now they are of the form /unseal#{compositeKey}
+export default function Page(props: { params: { compositeKey: string } }) {
+ return redirect(`/unseal#${props.params.compositeKey}`);
+}
diff --git a/envshare/app/components/analytics.tsx b/envshare/app/components/analytics.tsx
new file mode 100644
index 0000000..ef6a2ae
--- /dev/null
+++ b/envshare/app/components/analytics.tsx
@@ -0,0 +1,22 @@
+"use client";
+import { Analytics as VercelAnalytics } from "@vercel/analytics/react";
+
+const track = ["/", "/share", "/deploy", "/unseal"];
+
+export function Analytics() {
+ return (
+ <VercelAnalytics
+ beforeSend={(event) => {
+ const url = new URL(event.url);
+ if (!track.includes(url.pathname)) {
+ url.pathname = "/__redacted";
+ return {
+ ...event,
+ url: url.href,
+ };
+ }
+ return event;
+ }}
+ />
+ );
+}
diff --git a/envshare/app/components/error.tsx b/envshare/app/components/error.tsx
new file mode 100644
index 0000000..acf36d7
--- /dev/null
+++ b/envshare/app/components/error.tsx
@@ -0,0 +1,11 @@
+type Props = {
+ message: string;
+};
+
+export const ErrorMessage: React.FC<Props> = ({ message }) => {
+ return (
+ <div className="flex items-center justify-center my-8 lg:my-16">
+ <span className="px-4 py-2 text-red-500 border rounded border-red-500/50 bg-red-500/10"> {message}</span>
+ </div>
+ );
+};
diff --git a/envshare/app/components/stats.tsx b/envshare/app/components/stats.tsx
new file mode 100644
index 0000000..31d74bc
--- /dev/null
+++ b/envshare/app/components/stats.tsx
@@ -0,0 +1,57 @@
+import { Redis } from "@upstash/redis";
+
+const redis = Redis.fromEnv();
+export const revalidate = 60;
+
+export const Stats = asyncComponent(async () => {
+ const [reads, writes] = await redis
+ .pipeline()
+ .get("envshare:metrics:reads")
+ .get("envshare:metrics:writes")
+ .exec<[number, number]>();
+ const stars = await fetch("https://api.github.com/repos/chronark/envshare")
+ .then((res) => res.json())
+ .then((json) => json.stargazers_count as number);
+
+ const stats = [
+ {
+ label: "Documents Encrypted",
+ value: writes,
+ },
+ {
+ label: "Documents Decrypted",
+ value: reads,
+ },
+ ] satisfies { label: string; value: number }[];
+
+ if (stars) {
+ stats.push({
+ label: "GitHub Stars",
+ value: stars,
+ });
+ }
+
+ return (
+ <section className="container mx-auto">
+ <ul className="grid grid-cols-1 gap-4 sm:grid-cols-3 ">
+ {stats.map(({ label, value }) => (
+ <li
+ key={label}
+ className="flex items-center justify-between gap-2 px-4 py-3 overflow-hidden rounded m sm:flex-col"
+ >
+ <dd className="text-2xl font-bold tracking-tight text-center sm:text-5xl text-zinc-200">
+ {Intl.NumberFormat("en-US", { notation: "compact" }).format(value)}
+ </dd>
+ <dt className="leading-6 text-center text-zinc-500">{label}</dt>
+ </li>
+ ))}
+ </ul>
+ </section>
+ );
+});
+
+// stupid hack to make "server components" actually work with components
+// https://www.youtube.com/watch?v=h_9Vx6kio2s
+function asyncComponent<T, R>(fn: (arg: T) => Promise<R>): (arg: T) => R {
+ return fn as (arg: T) => R;
+}
diff --git a/envshare/app/components/testimony.tsx b/envshare/app/components/testimony.tsx
new file mode 100644
index 0000000..757a953
--- /dev/null
+++ b/envshare/app/components/testimony.tsx
@@ -0,0 +1,125 @@
+"use client";
+import Image from "next/image";
+import Link from "next/link";
+import { Props } from "next/script";
+import React, { PropsWithChildren } from "react";
+
+const TwitterHandle: React.FC<PropsWithChildren> = ({ children }) => {
+ return <span className="text-blue-500">{children}</span>;
+};
+
+const Author: React.FC<PropsWithChildren<{ href: string }>> = ({ children, href }) => (
+ <Link target="_blank" rel="noopener noreferrer" href={href} className="duration-150 text-zinc-200 hover:text-zinc-50">
+ {children}
+ </Link>
+);
+
+const Title: React.FC<PropsWithChildren<{ href: string }>> = ({ children, href }) => (
+ <Link
+ target="_blank"
+ rel="noopener noreferrer"
+ href={href}
+ className="text-sm duration-150 text-zinc-500 hover:text-zinc-300"
+ >
+ {children}
+ </Link>
+);
+
+export const Testimonials = () => {
+ const posts: {
+ content: React.ReactNode;
+ link: string;
+ author: {
+ name: React.ReactNode;
+ title?: React.ReactNode;
+ image: string;
+ };
+ }[] = [
+ {
+ content: (
+ <div>
+ <p>
+ My cursory audit of <TwitterHandle>@chronark_</TwitterHandle>'s envshare:
+ </p>
+ <p>
+ It is light, extremely functional, and does its symmetric block cipher correctly, unique initialization
+ vectors, decryption keys derived securely.
+ </p>
+ <br />
+ <p>Easily modified to remove minimal analytics. Superior to Privnote.</p>
+ <br />
+ <p>Self-hosting is easy. 👏</p>
+ </div>
+ ),
+ link: "https://twitter.com/FrederikMarkor/status/1615299856205250560",
+ author: {
+ name: <Author href="https://twitter.com/FrederikMarkor">Frederik Markor</Author>,
+ title: <Title href="https://discreet.net">CEO @discreet</Title>,
+ image: "https://pbs.twimg.com/profile_images/1438061314010664962/NecuMIGR_400x400.jpg",
+ },
+ },
+ {
+ content: (
+ <div>
+ <p>I'm particularly chuffed about this launch, for a couple of reasons:</p>
+ <ul>
+ <li>
+ ◆ Built on <TwitterHandle>@nextjs</TwitterHandle> + <TwitterHandle>@upstash</TwitterHandle>, hosted on{" "}
+ <TwitterHandle>@vercel</TwitterHandle>
+ </li>
+ <li>◆ 100% free to use & open source</li>
+ <li>◆ One-click deploy via Vercel + Upstash integration</li>
+ </ul>
+ <p>Deploy your own → http://vercel.fyi/envshare</p>
+ </div>
+ ),
+ link: "https://twitter.com/steventey/status/1615035241772482567?ref_src=twsrc%5Etfw%7Ctwcamp%5Etweetembed%7Ctwterm%5E1615035241772482567%7Ctwgr%5E1db44bb10c690189e24c980fcd787299961c34c6%7Ctwcon%5Es1_&ref_url=https%3A%2F%2Fpublish.twitter.com%2F%3Fquery%3Dhttps3A2F2Ftwitter.com2Fsteventey2Fstatus2F1615035241772482567widget%3DTweet",
+ author: {
+ name: <Author href="https://twitter.com/steventey">Steven Tey</Author>,
+ title: <Title href="https://vercel.com">Senior Developer Advocate at Vercel</Title>,
+ image: "https://pbs.twimg.com/profile_images/1506792347840888834/dS-r50Je_400x400.jpg",
+ },
+ },
+ {
+ content: (
+ <div>
+ <p>
+ Congratulations on the launch <TwitterHandle>@chronark_</TwitterHandle>👏! This is such a valuable product
+ for developers. Icing on the cake is that it's open source! ✨
+ </p>
+ </div>
+ ),
+ link: "https://twitter.com/DesignSiddharth/status/1615293209164546048",
+ author: {
+ name: <Author href="https://twitter.com/DesignSiddharth">@DesignSiddharth</Author>,
+ image: "https://pbs.twimg.com/profile_images/1613772710009765888/MbSblJYf_400x400.jpg",
+ },
+ },
+ ];
+
+ return (
+ <section className="container mx-auto">
+ <ul role="list" className="grid max-w-2xl grid-cols-1 gap-16 mx-auto sm:gap-8 lg:max-w-none lg:grid-cols-3">
+ {posts.map((post, i) => (
+ <div
+ key={i}
+ className="flex flex-col justify-between duration-150 border rounded border-zinc-500/30 hover:border-zinc-300/30 hover:bg-zinc-900/30 group"
+ >
+ <Link href={post.link} className="whitespace-pre-line text text-zinc-500 p-6">
+ {post.content}
+ </Link>
+ <div className="relative flex items-start justify-between p-6 duration-150 border-t bg-zinc-900/40 border-zinc-500/30 group-hover:border-zinc-300/30">
+ <div>
+ <div className="text-base font-display text-zinc-200">{post.author.name}</div>
+ <div className="mt-1 text-sm text-zinc-500">{post.author.title}</div>
+ </div>
+ <div className="overflow-hidden rounded-full bg-zinc-50">
+ <Image className="object-cover h-14 w-14" src={post.author.image} alt="" width={56} height={56} />
+ </div>
+ </div>
+ </div>
+ ))}
+ </ul>
+ </section>
+ );
+};
diff --git a/envshare/app/components/title.tsx b/envshare/app/components/title.tsx
new file mode 100644
index 0000000..b9b7f3c
--- /dev/null
+++ b/envshare/app/components/title.tsx
@@ -0,0 +1,9 @@
+import React, { PropsWithChildren } from "react";
+
+export const Title: React.FC<PropsWithChildren> = ({ children }): JSX.Element => {
+ return (
+ <h1 className="py-4 text-5xl font-bold text-center text-transparent bg-gradient-to-t bg-clip-text from-zinc-100/60 to-white">
+ {children}
+ </h1>
+ );
+};
diff --git a/envshare/app/deploy/page.tsx b/envshare/app/deploy/page.tsx
new file mode 100644
index 0000000..b515144
--- /dev/null
+++ b/envshare/app/deploy/page.tsx
@@ -0,0 +1,89 @@
+"use client";
+import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid";
+import Link from "next/link";
+import { Title } from "@components/title";
+import React from "react";
+const steps: {
+ name: string;
+ description: string | React.ReactNode;
+ cta?: React.ReactNode;
+}[] = [
+ {
+ name: "Create a new Redis database on Upstash",
+ description: (
+ <>
+ Upstash offers a serverless Redis database with a generous free tier of up to 10,000 requests per day. That's
+ more than enough.
+ <br />
+ Click the button below to sign up and create a new Redis database on Upstash.
+ </>
+ ),
+ cta: (
+ <Link
+ href="https://console.upstash.com/redis"
+ className="flex items-center justify-center w-full gap-2 px-4 py-2 text-sm text-center transition-all duration-150 rounded text-zinc-800 hover:text-zinc-100 bg-zinc-200 hover:bg-transparent ring-1 ring-zinc-100"
+ >
+ <span>Create Database</span>
+ <ArrowTopRightOnSquareIcon className="w-4 h-4" />
+ </Link>
+ ),
+ },
+ {
+ name: "Copy the REST connection credentials",
+ description: (
+ <p>
+ After creating the database, scroll to the bottom and make a note of <code>UPSTASH_REDIS_REST_URL</code> and{" "}
+ <code>UPSTASH_REDIS_REST_TOKEN</code>, you need them in the next step
+ </p>
+ ),
+ },
+ {
+ name: "Deploy to Vercel",
+ description: "Deploy the app to Vercel and paste the connection credentials into the environment variables.",
+ cta: (
+ <Link
+ href="https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fchronark%2Fenvshare&env=UPSTASH_REDIS_REST_URL,UPSTASH_REDIS_REST_TOKEN&demo-title=Share%20Environment%20Variables%20Securely&demo-url=https%3A%2F%2Fcryptic.vercel.app"
+ className="flex items-center justify-center w-full gap-2 px-4 py-2 text-sm text-center transition-all duration-150 rounded text-zinc-800 hover:text-zinc-100 bg-zinc-200 hover:bg-transparent ring-1 ring-zinc-100"
+ >
+ <span>Deploy</span>
+ <ArrowTopRightOnSquareIcon className="w-4 h-4" />
+ </Link>
+ ),
+ },
+];
+
+export default function Deploy() {
+ return (
+ <div className="container px-8 mx-auto mt-16 lg:mt-32 ">
+ <Title>Deploy EnvShare for Free</Title>
+ <p className="mt-4 text-sm text-center text-zinc-600">
+ You can deploy your own hosted version of EnvShare, you just need an Upstash and Vercel account.
+ </p>
+ <ol className="flex flex-col items-center justify-center mt-8 md:mt-16 xl:mt-24">
+ {steps.map((step, stepIdx) => (
+ <li key={step.name} className="relative flex flex-col items-center gap-4 pb-16 group md:gap-8 md:pb-24">
+ <span
+ className="absolute top-4 h-full w-0.5 bg-gradient-to-b from-blue-500/60 via-blue-500/10 to-transparent"
+ aria-hidden="true"
+ />
+ <span className="flex items-center h-9" aria-hidden="true">
+ <span className="relative z-10 flex items-center justify-center w-8 h-8 text-sm text-blue-400 duration-150 border border-blue-400 rounded-full bg-zinc-900 group-hover:border-blue-500 drop-shadow-blue">
+ {stepIdx + 1}
+ </span>
+ </span>
+ <div className="z-10 flex flex-col items-center">
+ <h2 className="text-xl font-medium duration-150 lg:text-2xl text-zinc-200 group-hover:text-white">
+ {step.name}
+ </h2>
+
+ <div className="mt-4 text-sm text-center duration-1000 text-zinc-500 group-hover:text-zinc-400">
+ {step.description}
+ </div>
+ <div className="w-full mt-8 md:w-auto">{step.cta}</div>
+ </div>
+ </li>
+ ))}
+ </ol>
+ </div>
+ );
+}
diff --git a/envshare/app/globals.css b/envshare/app/globals.css
new file mode 100644
index 0000000..b764a11
--- /dev/null
+++ b/envshare/app/globals.css
@@ -0,0 +1,15 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer utilities {
+ input[type="number"]::-webkit-inner-spin-button,
+ input[type="number"]::-webkit-outer-spin-button {
+ @apply appearance-none;
+ }
+
+
+ input[type="file"] {
+ @apply appearance-none;
+ }
+} \ No newline at end of file
diff --git a/envshare/app/head.tsx b/envshare/app/head.tsx
new file mode 100644
index 0000000..aecaa44
--- /dev/null
+++ b/envshare/app/head.tsx
@@ -0,0 +1,42 @@
+export default function Head({ title, subtitle }: { title: string; subtitle: string }) {
+ // Fallback tagline
+ title ??= "Share Environment Variables Securely";
+ subtitle ??= "EnvShare";
+
+ const baseUrl = process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "http://localhost:3000";
+
+ const url = new URL("/api/v1/og", baseUrl);
+ url.searchParams.set("title", title);
+ url.searchParams.set("subtitle", subtitle);
+
+ return (
+ <>
+ <title>EnvShare</title>
+ <meta content="width=device-width, initial-scale=1" name="viewport" />
+ <meta name="description" content={subtitle} />
+ <meta name="theme-color" content="#000000" />
+ <meta name="title" content={title} />
+ <meta name="keywords" content="envshare, secure, secrets, share, environment, variables" />
+ <meta name="language" content="English" />
+ <meta name="revisit-after" content="7 days" />
+ <meta name="robots" content="all" />
+ <meta httpEquiv="Content-Type" content="text/html; charset=utf-8" />
+
+ {/* Open Graph / Facebook */}
+ <meta property="og:type" content="website" />
+ <meta property="og:url" content={baseUrl} />
+ <meta property='og:image' content={url.toString()} />
+ <meta property='og:title' content={title} />
+ <meta property='og:description' content={subtitle} />
+ <meta property="og:image:width" content="1200" />
+ <meta property="og:image:height" content="630" />
+
+ {/* Twitter */}
+ <meta property="twitter:card" content="summary_large_image" />
+ <meta property="twitter:url" content={baseUrl} />
+ <meta property="twitter:title" content={title} />
+ <meta property="twitter:description" content={subtitle} />
+ <meta property="twitter:image" content={url.toString()} />
+ </>
+ );
+}
diff --git a/envshare/app/header.tsx b/envshare/app/header.tsx
new file mode 100644
index 0000000..872459a
--- /dev/null
+++ b/envshare/app/header.tsx
@@ -0,0 +1,60 @@
+"use client";
+import React from "react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+const navigation = [
+ {
+ name: "Share",
+ href: "/share",
+ },
+ {
+ name: "Unseal",
+ href: "/unseal",
+ },
+
+ {
+ name: "Deploy",
+ href: "/deploy",
+ },
+ {
+ name: "GitHub",
+ href: "https://github.com/chronark/envshare",
+ external: true,
+ },
+] satisfies { name: string; href: string; external?: boolean }[];
+
+export const Header: React.FC = () => {
+ const pathname = usePathname();
+ return (
+ <header className="top-0 z-30 w-full px-4 sm:fixed backdrop-blur bh-zinc-900/50">
+ <div className="container mx-auto">
+ <div className="flex flex-col items-center justify-between gap-2 pt-6 sm:h-20 sm:flex-row sm:pt-0">
+ <Link href="/" className="text-2xl font-semibold duration-150 text-zinc-100 hover:text-white">
+ EnvShare
+ </Link>
+ {/* Desktop navigation */}
+ <nav className="flex items-center grow">
+ <ul className="flex flex-wrap items-center justify-end gap-4 grow">
+ {navigation.map((item) => (
+ <li className="" key={item.href}>
+ <Link
+ className={`flex items-center px-3 py-2 duration-150 text-sm sm:text-base hover:text-zinc-50
+ ${pathname === item.href ? "text-zinc-200" : "text-zinc-400"}`}
+ href={item.href}
+ target={item.external ? "_blank" : undefined}
+ rel={item.external ? "noopener noreferrer" : undefined}
+ >
+ {item.name}
+ </Link>
+ </li>
+ ))}
+ </ul>
+ </nav>
+ </div>
+ </div>
+
+ {/* Fancy fading bottom border */}
+ </header>
+ );
+};
diff --git a/envshare/app/layout.tsx b/envshare/app/layout.tsx
new file mode 100644
index 0000000..557407d
--- /dev/null
+++ b/envshare/app/layout.tsx
@@ -0,0 +1,58 @@
+import "./globals.css";
+import { Inter } from "@next/font/google";
+import Link from "next/link";
+import { Header } from "./header";
+
+import { Analytics } from "@components/analytics";
+const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+ <html lang="en" className={inter.variable}>
+ <head />
+ <body className="relative min-h-screen bg-black bg-gradient-to-tr from-zinc-900/50 to-zinc-700/30">
+ {
+ // Not everyone will want to host envshare on Vercel, so it makes sense to make this opt-in.
+ process.env.ENABLE_VERCEL_ANALYTICS ? <Analytics /> : null
+ }
+
+ <Header />
+
+ <main className=" min-h-[80vh] ">{children}</main>
+
+ <footer className="bottom-0 border-t inset-2x-0 border-zinc-500/10">
+ <div className="flex flex-col gap-1 px-6 py-12 mx-auto text-xs text-center text-zinc-700 max-w-7xl lg:px-8">
+ <p>
+ Built by{" "}
+ <Link href="https://twitter.com/chronark_" className="font-semibold duration-150 hover:text-zinc-200">
+ @chronark_
+ </Link>
+ and{" "}
+ <Link
+ href="https://github.com/chronark/envshare/graphs/contributors"
+ className="underline duration-150 hover:text-zinc-200"
+ >
+ many others{" "}
+ </Link>
+ </p>
+ <p>
+ EnvShare is deployed on{" "}
+ <Link target="_blank" href="https://vercel.com" className="underline duration-150 hover:text-zinc-200">
+ Vercel
+ </Link>{" "}
+ and uses{" "}
+ <Link target="_blank" href="https://upstash.com" className="underline duration-150 hover:text-zinc-200">
+ Upstash
+ </Link>{" "}
+ for storing encrypted data.
+ </p>
+ </div>
+ </footer>
+ </body>
+ </html>
+ );
+}
diff --git a/envshare/app/page.tsx b/envshare/app/page.tsx
new file mode 100644
index 0000000..ba659ca
--- /dev/null
+++ b/envshare/app/page.tsx
@@ -0,0 +1,50 @@
+import Link from "next/link";
+import { Stats } from "./components/stats";
+import { Testimonials } from "./components/testimony";
+
+export default function Home() {
+ return (
+ <div className="flex flex-col gap-8 pb-8 md:gap-16 md:pb-16 xl:pb-24">
+ <div className="flex flex-col items-center justify-center max-w-3xl px-8 mx-auto mt-8 sm:min-h-screen sm:mt-0 sm:px-0">
+ <div className="hidden sm:mb-8 sm:flex sm:justify-center">
+ <Link
+ href="https://github.com/chronark/envshare"
+ className="text-zinc-400 relative overflow-hidden rounded-full py-1.5 px-4 text-sm leading-6 ring-1 ring-zinc-100/10 hover:ring-zinc-100/30 duration-150"
+ >
+ EnvShare is Open Source on{" "}
+ <span className="font-semibold text-zinc-200">
+ GitHub <span aria-hidden="true">&rarr;</span>
+ </span>
+ </Link>
+ </div>
+ <div>
+ <h1 className="py-4 text-5xl font-bold tracking-tight text-center text-transparent bg-gradient-to-t bg-clip-text from-zinc-100/50 to-white sm:text-7xl">
+ Share Environment Variables Securely
+ </h1>
+ <p className="mt-6 leading-5 text-zinc-600 sm:text-center">
+ Your document is encrypted in your browser before being stored for a limited period of time and read
+ operations. Unencrypted data never leaves your browser.
+ </p>
+ <div className="flex flex-col justify-center gap-4 mx-auto mt-8 sm:flex-row sm:max-w-lg ">
+ <Link
+ href="/deploy"
+ className="sm:w-1/2 sm:text-center inline-block space-x-2 rounded px-4 py-1.5 md:py-2 text-base font-semibold leading-7 text-white ring-1 ring-zinc-600 hover:bg-white hover:text-zinc-900 duration-150 hover:ring-white hover:drop-shadow-cta"
+ >
+ Deploy
+ </Link>
+ <Link
+ href="/share"
+ className="sm:w-1/2 sm:text-center inline-block transition-all space-x-2 rounded px-4 py-1.5 md:py-2 text-base font-semibold leading-7 text-zinc-800 bg-zinc-50 ring-1 ring-transparent hover:text-zinc-100 hover:ring-zinc-600/80 hover:bg-zinc-900/20 duration-150 hover:drop-shadow-cta"
+ >
+ <span>Share</span>
+ <span aria-hidden="true">&rarr;</span>
+ </Link>
+ </div>
+ </div>
+ </div>
+ <h2 className="py-4 text-3xl font-bold text-center text-zinc-300 ">Used and trusted by a growing community</h2>
+ <Stats />
+ <Testimonials />
+ </div>
+ );
+}
diff --git a/envshare/app/share/page.tsx b/envshare/app/share/page.tsx
new file mode 100644
index 0000000..b6089f0
--- /dev/null
+++ b/envshare/app/share/page.tsx
@@ -0,0 +1,227 @@
+"use client";
+import { toBase58 } from "util/base58";
+import { useState, Fragment } from "react";
+import { Cog6ToothIcon, ClipboardDocumentIcon, ClipboardDocumentCheckIcon } from "@heroicons/react/24/outline";
+import { Title } from "@components/title";
+import { encrypt } from "pkg/encryption";
+import { ErrorMessage } from "@components/error";
+import { encodeCompositeKey } from "pkg/encoding";
+import { LATEST_KEY_VERSION } from "pkg/constants";
+
+export default function Home() {
+ const [text, setText] = useState("");
+ const [reads, setReads] = useState(999);
+
+ const [ttl, setTtl] = useState(7);
+ const [ttlMultiplier, setTtlMultiplier] = useState(60 * 60 * 24);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [copied, setCopied] = useState(false);
+
+ const [link, setLink] = useState("");
+
+ const onSubmit = async () => {
+ try {
+ setError("");
+ setLink("");
+ setLoading(true);
+
+ const { encrypted, iv, key } = await encrypt(text);
+
+ const { id } = (await fetch("/api/v1/store", {
+ method: "POST",
+ body: JSON.stringify({
+ ttl: ttl * ttlMultiplier,
+ reads,
+ encrypted: toBase58(encrypted),
+ iv: toBase58(iv),
+ }),
+ }).then((r) => r.json())) as { id: string };
+
+ const compositeKey = encodeCompositeKey(LATEST_KEY_VERSION, id, key);
+
+ const url = new URL(window.location.href);
+ url.pathname = "/unseal";
+ url.hash = compositeKey;
+ setCopied(false);
+ setLink(url.toString());
+ } catch (e) {
+ console.error(e);
+ setError((e as Error).message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ <div className="container px-8 mx-auto mt-16 lg:mt-32 ">
+ {error ? <ErrorMessage message={error} /> : null}
+
+ {link ? (
+ <div className="flex flex-col items-center justify-center w-full h-full mt-8 md:mt-16 xl:mt-32">
+ <Title>Share this link with others</Title>
+ <div className="relative flex items-stretch flex-grow mt-16 focus-within:z-10">
+ <pre className="px-4 py-3 font-mono text-center bg-transparent border rounded border-zinc-600 focus:border-zinc-100/80 focus:ring-0 sm:text-sm text-zinc-100">
+ {link}
+ </pre>
+ <button
+ type="button"
+ className="relative inline-flex items-center px-4 py-2 -ml-px space-x-2 text-sm font-medium duration-150 border text-zinc-700 border-zinc-300 rounded-r-md bg-zinc-50 hover focus:border-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500 hover:text-zinc-900 hover:bg-white"
+ onClick={() => {
+ navigator.clipboard.writeText(link);
+ setCopied(true);
+ }}
+ >
+ {copied ? (
+ <ClipboardDocumentCheckIcon className="w-5 h-5" aria-hidden="true" />
+ ) : (
+ <ClipboardDocumentIcon className="w-5 h-5" aria-hidden="true" />
+ )}{" "}
+ <span>{copied ? "Copied" : "Copy"}</span>
+ </button>
+ </div>
+ </div>
+ ) : (
+ <form
+ className="max-w-3xl mx-auto"
+ onSubmit={(e) => {
+ e.preventDefault();
+ if (text.length <= 0) return;
+ onSubmit();
+ }}
+ >
+ <Title>Encrypt and Share</Title>
+
+ <pre className="px-4 py-3 mt-8 font-mono text-left bg-transparent border rounded border-zinc-600 focus:border-zinc-100/80 focus:ring-0 sm:text-sm text-zinc-100">
+ <div className="flex items-start px-1 text-sm">
+ <div aria-hidden="true" className="pr-4 font-mono border-r select-none border-zinc-300/5 text-zinc-700">
+ {Array.from({
+ length: text.split("\n").length,
+ }).map((_, index) => (
+ <Fragment key={index}>
+ {(index + 1).toString().padStart(2, "0")}
+ <br />
+ </Fragment>
+ ))}
+ </div>
+
+ <textarea
+ id="text"
+ name="text"
+ value={text}
+ minLength={1}
+ onChange={(e) => setText(e.target.value)}
+ rows={Math.max(5, text.split("\n").length)}
+ placeholder="DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres"
+ className="w-full p-0 text-base bg-transparent border-0 appearance-none resize-none hover:resize text-zinc-100 placeholder-zinc-500 focus:ring-0 sm:text-sm"
+ />
+ </div>
+ </pre>
+
+ <div className="flex flex-col items-center justify-center w-full gap-4 mt-4 sm:flex-row">
+ <div className="w-full sm:w-1/5">
+ <label
+ className="flex items-center justify-center h-16 px-3 py-2 text-sm whitespace-no-wrap duration-150 border rounded hover:border-zinc-100/80 border-zinc-600 focus:border-zinc-100/80 focus:ring-0 text-zinc-100 hover:text-white hover:cursor-pointer "
+ htmlFor="file_input"
+ >
+ Upload a file
+ </label>
+ <input
+ className="hidden"
+ id="file_input"
+ type="file"
+ onChange={(e) => {
+ const file = e.target.files![0];
+ if (file.size > 1024 * 16) {
+ setError("File size must be less than 16kb");
+ return;
+ }
+
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ const t = e.target!.result as string;
+ setText(t);
+ };
+ reader.readAsText(file);
+ }}
+ />
+ </div>
+
+ <div className="w-full h-16 px-3 py-2 duration-150 border rounded sm:w-2/5 hover:border-zinc-100/80 border-zinc-600 focus-within:border-zinc-100/80 focus-within:ring-0 ">
+ <label htmlFor="reads" className="block text-xs font-medium text-zinc-100">
+ READS
+ </label>
+ <input
+ type="number"
+ name="reads"
+ id="reads"
+ className="w-full p-0 text-base bg-transparent border-0 appearance-none text-zinc-100 placeholder-zinc-500 focus:ring-0 sm:text-sm"
+ value={reads}
+ onChange={(e) => setReads(e.target.valueAsNumber)}
+ />
+ </div>
+ <div className="relative w-full h-16 px-3 py-2 duration-150 border rounded sm:w-2/5 hover:border-zinc-100/80 border-zinc-600 focus-within:border-zinc-100/80 focus-within:ring-0 ">
+ <label htmlFor="reads" className="block text-xs font-medium text-zinc-100">
+ TTL
+ </label>
+ <input
+ type="number"
+ name="reads"
+ id="reads"
+ className="w-full p-0 text-base bg-transparent border-0 appearance-none text-zinc-100 placeholder-zinc-500 focus:ring-0 sm:text-sm"
+ value={ttl}
+ onChange={(e) => setTtl(e.target.valueAsNumber)}
+ />
+ <div className="absolute inset-y-0 right-0 flex items-center">
+ <label htmlFor="ttlMultiplier" className="sr-only" />
+ <select
+ id="ttlMultiplier"
+ name="ttlMultiplier"
+ className="h-full py-0 pl-2 bg-transparent border-0 border-transparent rounded pr-7 text-zinc-500 focus:ring-0 sm:text-sm"
+ onChange={(e) => setTtlMultiplier(parseInt(e.target.value))}
+ defaultValue={60 * 60 * 24}
+ >
+ <option value={60}>{ttl === 1 ? "Minute" : "Minutes"}</option>
+ <option value={60 * 60}>{ttl === 1 ? "Hour" : "Hours"}</option>
+ <option value={60 * 60 * 24}>{ttl === 1 ? "Day" : "Days"}</option>
+ </select>
+ </div>
+ </div>
+ </div>
+ <button
+ type="submit"
+ disabled={loading || text.length <= 0}
+ className={`mt-6 w-full h-12 inline-flex justify-center items-center transition-all rounded px-4 py-1.5 md:py-2 text-base font-semibold leading-7 bg-zinc-200 ring-1 ring-transparent duration-150 ${
+ text.length <= 0
+ ? "text-zinc-400 cursor-not-allowed"
+ : "text-zinc-900 hover:text-zinc-100 hover:ring-zinc-600/80 hover:bg-zinc-900/20"
+ } ${loading ? "animate-pulse" : ""}`}
+ >
+ <span>{loading ? <Cog6ToothIcon className="w-5 h-5 animate-spin" /> : "Share"}</span>
+ </button>
+
+ <div className="mt-8">
+ <ul className="space-y-2 text-xs text-zinc-500">
+ <li>
+ <p>
+ <span className="font-semibold text-zinc-400">Reads:</span> The number of reads determines how often
+ the data can be shared, before it deletes itself. 0 means unlimited.
+ </p>
+ </li>
+ <li>
+ <p>
+ <span className="font-semibold text-zinc-400">TTL:</span> You can add a TTL (time to live) to the
+ data, to automatically delete it after a certain amount of time. 0 means no TTL.
+ </p>
+ </li>
+ <p>
+ Clicking Share will generate a new symmetrical key and encrypt your data before sending only the
+ encrypted data to the server.
+ </p>
+ </ul>
+ </div>
+ </form>
+ )}
+ </div>
+ );
+}
diff --git a/envshare/app/unseal/page.tsx b/envshare/app/unseal/page.tsx
new file mode 100644
index 0000000..0b63e6b
--- /dev/null
+++ b/envshare/app/unseal/page.tsx
@@ -0,0 +1,159 @@
+"use client";
+import React, { Fragment, useState, useEffect } from "react";
+import { ClipboardDocumentCheckIcon, ClipboardDocumentIcon, Cog6ToothIcon } from "@heroicons/react/24/outline";
+
+import { Title } from "@components/title";
+
+import { decodeCompositeKey } from "pkg/encoding";
+import { decrypt } from "pkg/encryption";
+import Link from "next/link";
+import { ErrorMessage } from "@components/error";
+
+export default function Unseal() {
+ const [compositeKey, setCompositeKey] = useState<string>("");
+ useEffect(() => {
+ if (typeof window !== "undefined") {
+ setCompositeKey(window.location.hash.replace(/^#/, ""));
+ }
+ }, []);
+
+ const [text, setText] = useState<string | null>(null);
+ const [loading, setLoading] = useState(false);
+ const [remainingReads, setRemainingReads] = useState<number | null>(null);
+ const [error, setError] = useState<string | null>(null);
+ const [copied, setCopied] = useState(false);
+
+ const onSubmit = async () => {
+ try {
+ setError(null);
+ setText(null);
+ setLoading(true);
+
+ if (!compositeKey) {
+ throw new Error("No id provided");
+ }
+
+ const { id, encryptionKey, version } = decodeCompositeKey(compositeKey);
+ const res = await fetch(`/api/v1/load?id=${id}`);
+ if (!res.ok) {
+ throw new Error(await res.text());
+ }
+ const json = (await res.json()) as {
+ iv: string;
+ encrypted: string;
+ remainingReads: number | null;
+ };
+ setRemainingReads(json.remainingReads);
+
+ const decrypted = await decrypt(json.encrypted, encryptionKey, json.iv, version);
+
+ setText(decrypted);
+ } catch (e) {
+ console.error(e);
+ setError((e as Error).message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ <div className="container px-8 mx-auto mt-16 lg:mt-32 ">
+ {error ? <ErrorMessage message={error} /> : null}
+ {text ? (
+ <div className="max-w-4xl mx-auto">
+ {remainingReads !== null ? (
+ <div className="text-sm text-center text-zinc-600">
+ {remainingReads > 0 ? (
+ <p>
+ This document can be read <span className="text-zinc-100">{remainingReads}</span> more times.
+ </p>
+ ) : (
+ <p className="text-zinc-400">
+ This was the last time this document could be read. It was deleted from storage.
+ </p>
+ )}
+ </div>
+ ) : null}
+ <pre className="px-4 py-3 mt-8 font-mono text-left bg-transparent border rounded border-zinc-600 focus:border-zinc-100/80 focus:ring-0 sm:text-sm text-zinc-100">
+ <div className="flex items-start px-1 text-sm">
+ <div aria-hidden="true" className="pr-4 font-mono border-r select-none border-zinc-300/5 text-zinc-700">
+ {Array.from({
+ length: text.split("\n").length,
+ }).map((_, index) => (
+ <Fragment key={index}>
+ {(index + 1).toString().padStart(2, "0")}
+ <br />
+ </Fragment>
+ ))}
+ </div>
+ <div>
+ <pre className="flex overflow-x-auto">
+ <code className="px-4 text-left">{text}</code>
+ </pre>
+ </div>
+ </div>
+ </pre>
+
+ <div className="flex items-center justify-end gap-4 mt-4">
+ <Link
+ href="/share"
+ type="button"
+ className="relative inline-flex items-center px-4 py-2 -ml-px space-x-2 text-sm font-medium duration-150 border rounded text-zinc-300 border-zinc-300/40 hover:border-zinc-300 focus:outline-none hover:text-white"
+ >
+ Share another
+ </Link>
+ <button
+ type="button"
+ className="relative inline-flex items-center px-4 py-2 -ml-px space-x-2 text-sm font-medium duration-150 border rounded text-zinc-700 border-zinc-300 bg-zinc-50 hover focus:border-zinc-500 focus:outline-none hover:text-zinc-50 hover:bg-zinc-900"
+ onClick={() => {
+ navigator.clipboard.writeText(text);
+ setCopied(true);
+ }}
+ >
+ {copied ? (
+ <ClipboardDocumentCheckIcon className="w-5 h-5" aria-hidden="true" />
+ ) : (
+ <ClipboardDocumentIcon className="w-5 h-5" aria-hidden="true" />
+ )}{" "}
+ <span>{copied ? "Copied" : "Copy"}</span>
+ </button>
+ </div>
+ </div>
+ ) : (
+ <form
+ className="max-w-3xl mx-auto "
+ onSubmit={(e) => {
+ e.preventDefault();
+ onSubmit();
+ }}
+ >
+ <Title>Decrypt a document</Title>
+
+ <div className="px-3 py-2 mt-8 border rounded border-zinc-600 focus-within:border-zinc-100/80 focus-within:ring-0 ">
+ <label htmlFor="id" className="block text-xs font-medium text-zinc-100">
+ ID
+ </label>
+ <input
+ type="text"
+ name="compositeKey"
+ id="compositeKey"
+ className="w-full p-0 text-base bg-transparent border-0 appearance-none text-zinc-100 placeholder-zinc-500 focus:ring-0 sm:text-sm"
+ value={compositeKey}
+ onChange={(e) => setCompositeKey(e.target.value)}
+ />
+ </div>
+
+ <button
+ type="submit"
+ disabled={loading}
+ className={`mt-8 w-full h-12 inline-flex justify-center items-center transition-all rounded px-4 py-1.5 md:py-2 text-base font-semibold leading-7 text-zinc-800 bg-zinc-200 ring-1 duration-150 hover:text-black hover:drop-shadow-cta hover:bg-white ${
+ loading ? "animate-pulse" : ""
+ }`}
+ >
+ <span>{loading ? <Cog6ToothIcon className="w-5 h-5 animate-spin" /> : "Unseal"}</span>
+ </button>
+ </form>
+ )}
+ </div>
+ );
+}