From 4919f028c884a041da7ff098abb02389b4eac598 Mon Sep 17 00:00:00 2001 From: 简律纯 Date: Tue, 18 Apr 2023 03:02:17 +0800 Subject: ✨add envshare docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- envshare/app/[compositeKey]/page.tsx | 7 ++ envshare/app/components/analytics.tsx | 22 ++++ envshare/app/components/error.tsx | 11 ++ envshare/app/components/stats.tsx | 57 +++++++++ envshare/app/components/testimony.tsx | 125 +++++++++++++++++++ envshare/app/components/title.tsx | 9 ++ envshare/app/deploy/page.tsx | 89 +++++++++++++ envshare/app/globals.css | 15 +++ envshare/app/head.tsx | 42 +++++++ envshare/app/header.tsx | 60 +++++++++ envshare/app/layout.tsx | 58 +++++++++ envshare/app/page.tsx | 50 ++++++++ envshare/app/share/page.tsx | 227 ++++++++++++++++++++++++++++++++++ envshare/app/unseal/page.tsx | 159 ++++++++++++++++++++++++ 14 files changed, 931 insertions(+) create mode 100644 envshare/app/[compositeKey]/page.tsx create mode 100644 envshare/app/components/analytics.tsx create mode 100644 envshare/app/components/error.tsx create mode 100644 envshare/app/components/stats.tsx create mode 100644 envshare/app/components/testimony.tsx create mode 100644 envshare/app/components/title.tsx create mode 100644 envshare/app/deploy/page.tsx create mode 100644 envshare/app/globals.css create mode 100644 envshare/app/head.tsx create mode 100644 envshare/app/header.tsx create mode 100644 envshare/app/layout.tsx create mode 100644 envshare/app/page.tsx create mode 100644 envshare/app/share/page.tsx create mode 100644 envshare/app/unseal/page.tsx (limited to 'envshare/app') 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 ( + { + 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 = ({ message }) => { + return ( +
+ {message} +
+ ); +}; 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 ( +
+
    + {stats.map(({ label, value }) => ( +
  • +
    + {Intl.NumberFormat("en-US", { notation: "compact" }).format(value)} +
    +
    {label}
    +
  • + ))} +
+
+ ); +}); + +// stupid hack to make "server components" actually work with components +// https://www.youtube.com/watch?v=h_9Vx6kio2s +function asyncComponent(fn: (arg: T) => Promise): (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 = ({ children }) => { + return {children}; +}; + +const Author: React.FC> = ({ children, href }) => ( + + {children} + +); + +const Title: React.FC> = ({ children, href }) => ( + + {children} + +); + +export const Testimonials = () => { + const posts: { + content: React.ReactNode; + link: string; + author: { + name: React.ReactNode; + title?: React.ReactNode; + image: string; + }; + }[] = [ + { + content: ( +
+

+ My cursory audit of @chronark_'s envshare: +

+

+ It is light, extremely functional, and does its symmetric block cipher correctly, unique initialization + vectors, decryption keys derived securely. +

+
+

Easily modified to remove minimal analytics. Superior to Privnote.

+
+

Self-hosting is easy. 👏

+
+ ), + link: "https://twitter.com/FrederikMarkor/status/1615299856205250560", + author: { + name: Frederik Markor, + title: CEO @discreet, + image: "https://pbs.twimg.com/profile_images/1438061314010664962/NecuMIGR_400x400.jpg", + }, + }, + { + content: ( +
+

I'm particularly chuffed about this launch, for a couple of reasons:

+
    +
  • + ◆ Built on @nextjs + @upstash, hosted on{" "} + @vercel +
  • +
  • ◆ 100% free to use & open source
  • +
  • ◆ One-click deploy via Vercel + Upstash integration
  • +
+

Deploy your own → http://vercel.fyi/envshare

+
+ ), + 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: Steven Tey, + title: Senior Developer Advocate at Vercel, + image: "https://pbs.twimg.com/profile_images/1506792347840888834/dS-r50Je_400x400.jpg", + }, + }, + { + content: ( +
+

+ Congratulations on the launch @chronark_👏! This is such a valuable product + for developers. Icing on the cake is that it's open source! ✨ +

+
+ ), + link: "https://twitter.com/DesignSiddharth/status/1615293209164546048", + author: { + name: @DesignSiddharth, + image: "https://pbs.twimg.com/profile_images/1613772710009765888/MbSblJYf_400x400.jpg", + }, + }, + ]; + + return ( +
+
    + {posts.map((post, i) => ( +
    + + {post.content} + +
    +
    +
    {post.author.name}
    +
    {post.author.title}
    +
    +
    + +
    +
    +
    + ))} +
+
+ ); +}; 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 = ({ children }): JSX.Element => { + return ( +

+ {children} +

+ ); +}; 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. +
+ Click the button below to sign up and create a new Redis database on Upstash. + + ), + cta: ( + + Create Database + + + ), + }, + { + name: "Copy the REST connection credentials", + description: ( +

+ After creating the database, scroll to the bottom and make a note of UPSTASH_REDIS_REST_URL and{" "} + UPSTASH_REDIS_REST_TOKEN, you need them in the next step +

+ ), + }, + { + name: "Deploy to Vercel", + description: "Deploy the app to Vercel and paste the connection credentials into the environment variables.", + cta: ( + + Deploy + + + ), + }, +]; + +export default function Deploy() { + return ( +
+ Deploy EnvShare for Free +

+ You can deploy your own hosted version of EnvShare, you just need an Upstash and Vercel account. +

+
    + {steps.map((step, stepIdx) => ( +
  1. +
  2. + ))} +
+
+ ); +} 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 ( + <> + EnvShare + + + + + + + + + + + {/* Open Graph / Facebook */} + + + + + + + + + {/* Twitter */} + + + + + + + ); +} 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 ( +
+
+
+ + EnvShare + + {/* Desktop navigation */} + +
+
+ + {/* Fancy fading bottom border */} +
+ ); +}; 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 ( + + + + { + // Not everyone will want to host envshare on Vercel, so it makes sense to make this opt-in. + process.env.ENABLE_VERCEL_ANALYTICS ? : null + } + +
+ +
{children}
+ +
+
+

+ Built by{" "} + + @chronark_ + + and{" "} + + many others{" "} + +

+

+ EnvShare is deployed on{" "} + + Vercel + {" "} + and uses{" "} + + Upstash + {" "} + for storing encrypted data. +

+
+
+ + + ); +} 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 ( +
+
+
+ + EnvShare is Open Source on{" "} + + GitHub + + +
+
+

+ Share Environment Variables Securely +

+

+ 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. +

+
+ + Deploy + + + Share + + +
+
+
+

Used and trusted by a growing community

+ + +
+ ); +} 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 ( +
+ {error ? : null} + + {link ? ( +
+ Share this link with others +
+
+              {link}
+            
+ +
+
+ ) : ( +
{ + e.preventDefault(); + if (text.length <= 0) return; + onSubmit(); + }} + > + Encrypt and Share + +
+            
+ + +