diff options
| author | 2023-04-18 03:02:17 +0800 | |
|---|---|---|
| committer | 2023-04-18 03:02:17 +0800 | |
| commit | 4919f028c884a041da7ff098abb02389b4eac598 (patch) | |
| tree | b0f482568c4b8c8a680ce6e2e70a7b7ca87dc190 /envshare/app | |
| parent | b135aac8531c1e1488147ad8c6f98eddbdbe0c99 (diff) | |
| download | HydroRoll-4919f028c884a041da7ff098abb02389b4eac598.tar.gz HydroRoll-4919f028c884a041da7ff098abb02389b4eac598.zip | |
✨add envshare docs
Diffstat (limited to 'envshare/app')
| -rw-r--r-- | envshare/app/[compositeKey]/page.tsx | 7 | ||||
| -rw-r--r-- | envshare/app/components/analytics.tsx | 22 | ||||
| -rw-r--r-- | envshare/app/components/error.tsx | 11 | ||||
| -rw-r--r-- | envshare/app/components/stats.tsx | 57 | ||||
| -rw-r--r-- | envshare/app/components/testimony.tsx | 125 | ||||
| -rw-r--r-- | envshare/app/components/title.tsx | 9 | ||||
| -rw-r--r-- | envshare/app/deploy/page.tsx | 89 | ||||
| -rw-r--r-- | envshare/app/globals.css | 15 | ||||
| -rw-r--r-- | envshare/app/head.tsx | 42 | ||||
| -rw-r--r-- | envshare/app/header.tsx | 60 | ||||
| -rw-r--r-- | envshare/app/layout.tsx | 58 | ||||
| -rw-r--r-- | envshare/app/page.tsx | 50 | ||||
| -rw-r--r-- | envshare/app/share/page.tsx | 227 | ||||
| -rw-r--r-- | envshare/app/unseal/page.tsx | 159 |
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">→</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">→</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> + ); +} |
