├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── README.md ├── assets ├── css │ └── secret.css ├── images │ ├── favicon.ico │ └── postlight-labs.gif └── js │ └── triplesec-3.0.27-min.js ├── jest.config.js ├── package-lock.json ├── package.json ├── scripts ├── build.js ├── deploy.js ├── metadata.js └── start.js ├── src ├── @types │ └── triplesec.d.ts ├── app.tsx ├── client.ts ├── components │ ├── app.css │ ├── encrypt-inputs.tsx │ ├── icons.tsx │ ├── not-found.tsx │ ├── root.tsx │ ├── share-overlay.tsx │ ├── view.tsx │ ├── wrapper.tsx │ └── write.tsx ├── lib │ ├── crypto.ts │ ├── kv.ts │ ├── request-match.test.ts │ ├── request-match.ts │ └── time.ts ├── page.ts ├── router.ts ├── store.ts └── worker.ts ├── tsconfig.json ├── webpack.client.js └── webpack.worker.js /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | scripts 4 | webpack.*.js -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "es6": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:react/recommended", 11 | "prettier", 12 | "prettier/react", 13 | "prettier/@typescript-eslint", 14 | "plugin:jest/recommended" 15 | ], 16 | "globals": { 17 | "triplesec": "readonly" 18 | }, 19 | "parserOptions": { 20 | "project": "./tsconfig.json" 21 | }, 22 | "plugins": ["prettier"], 23 | "rules": { 24 | "no-console": ["error", { "allow": ["warn", "error"] }], 25 | "prettier/prettier": "error", 26 | "react/no-unknown-property": 0, 27 | "react/prop-types": 0, 28 | "@typescript-eslint/camelcase": 0, 29 | "@typescript-eslint/indent": ["error", 2], 30 | "@typescript-eslint/explicit-function-return-type": [ 31 | "warn", 32 | { "allowExpressions": true } 33 | ], 34 | "@typescript-eslint/no-use-before-define": ["error", { "functions": false }] 35 | }, 36 | "settings": { 37 | "react": { 38 | "pragma": "h", 39 | "version": "16" 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | dist/ 3 | node_modules/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## secretmsg 2 | 3 | A [Cloudflare Workers](https://www.cloudflare.com/products/cloudflare-workers/) and [Workers KV](https://www.cloudflare.com/products/workers-kv/) demo that encrypts messages in the browser and stores them in a global key-value store for easy sharing. 4 | 5 | (This project was generated with [Cloudflare Worker App Kit](https://github.com/postlight/cloudflare-worker-app-kit)) 6 | 7 | ### Crypto 8 | 9 | All the cryptography is done in the browser using [Triplesec](https://keybase.io/triplesec). A library made by the folks at [Keybase](https://keybase.io/). They do publish an npm module for Node.js. Unfortunately, that doesn't include the browser version of the library, so I've included the latest release [in the repo](assets/js/). 10 | 11 | ## Requirements 12 | 13 | Node.js v10 is required to run this project. If you use [Volta](https://volta.sh), the version has been pinned in the package.json file. 14 | 15 | ## Scripts 16 | 17 | ```bash 18 | # Start a dev server at http://localhost:3333 19 | npm start 20 | 21 | # Run jest tests 22 | npm test 23 | 24 | # Output production-ready JS & CSS bundles in dist folder 25 | npm run build 26 | 27 | # Build files, copy static assets to S3, and deploy worker to Cloudflare 28 | npm run deploy 29 | 30 | # Check source files for common errors 31 | npm run lint 32 | ``` 33 | 34 | ## Environment Variables 35 | 36 | These environment variables are required to deploy the app. 37 | 38 | ```bash 39 | BUCKET=bucket-name 40 | AWS_KEY=XXXACCESSKEYXXX 41 | AWS_SECRET=XXXXXXXXXSECRETXXXXXXXXX 42 | AWS_REGION=us-east-1 43 | CF_ZONE_ID=XXXXXXXXXWORKERZONEIDXXXXXXXXX 44 | CF_KEY=XXXXCLOUDFLAREAUTHKEYXXXX 45 | CF_EMAIL=account@email.com 46 | ``` 47 | 48 | If you want to use Workers KV you'll need the `CF_KV_NAMESPACES` environment variable during development and when you deploy. 49 | 50 | ```bash 51 | # single KV namespace 52 | CF_KV_NAMESPACES="NAME XXXXXXXXXNAMESPACEIDXXXXXXXXX" 53 | 54 | # multiple namespace are supported, separate with a comma 55 | CF_KV_NAMESPACES="NS_1 XXXXXXXNAMESPACEIDXXXXXXX, NS_2 XXXXXXXNAMESPACEIDXXXXXXX" 56 | ``` 57 | 58 | Similarly, you can bind any other strings like with `CF_WORKER_BINDINGS` 59 | 60 | ```bash 61 | CF_WORKER_BINDINGS="KEY_1 value1, KEY_2 value2" 62 | ``` 63 | -------------------------------------------------------------------------------- /assets/css/secret.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --ui-color: #cecece; 3 | --ui-disabled: #4e4a56; 4 | --action-color: #23af85; 5 | --error-color: #cee242; 6 | } 7 | 8 | html { 9 | box-sizing: border-box; 10 | font-size: 62.5%; 11 | } 12 | 13 | *, 14 | *:before, 15 | *:after { 16 | box-sizing: inherit; 17 | } 18 | 19 | body, 20 | h1, 21 | h2, 22 | h3, 23 | h4, 24 | h5, 25 | h6, 26 | p, 27 | ol, 28 | ul { 29 | margin: 0; 30 | padding: 0; 31 | font-weight: normal; 32 | } 33 | 34 | ol, 35 | ul { 36 | list-style: none; 37 | } 38 | 39 | img { 40 | max-width: 100%; 41 | height: auto; 42 | } 43 | 44 | body { 45 | color: var(--ui-color); 46 | background: #262534; 47 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, 48 | Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; 49 | } 50 | 51 | a { 52 | color: #a0e8d2; 53 | text-decoration: none; 54 | } 55 | 56 | a:hover { 57 | text-decoration: underline; 58 | } 59 | 60 | a:active { 61 | color: #bcffea; 62 | } 63 | 64 | a:visited { 65 | color: #a0e8d2; 66 | } 67 | 68 | .wrapper { 69 | max-width: 600px; 70 | padding: 2rem; 71 | margin: 0 auto; 72 | } 73 | 74 | .write-group { 75 | position: relative; 76 | margin-bottom: 1.5rem; 77 | border-bottom: 1px solid #534a54; 78 | } 79 | 80 | .msg-input-label { 81 | display: block; 82 | margin-bottom: 2rem; 83 | font-size: 1.2rem; 84 | } 85 | 86 | #msg-input, 87 | .view-msg { 88 | display: block; 89 | width: 100%; 90 | padding: 1rem 0; 91 | border: none; 92 | font-size: 1.6rem; 93 | line-height: 1.5; 94 | background-color: transparent; 95 | font-family: Monaco, Consolas, monospace; 96 | color: #fff; 97 | } 98 | 99 | #msg-input:focus { 100 | outline: none; 101 | caret-color: var(--action-color); 102 | } 103 | 104 | #msg-input:disabled { 105 | color: var(--ui-color); 106 | } 107 | 108 | .encrypt-inputs-wrapper { 109 | display: flex; 110 | } 111 | 112 | .progress-bar { 113 | position: absolute; 114 | width: 100%; 115 | bottom: -1px; 116 | left: 0; 117 | height: 1px; 118 | background-color: var(--action-color); 119 | transform-origin: 0; 120 | transform: scaleX(0); 121 | } 122 | 123 | .encrypt-inputs { 124 | display: flex; 125 | flex: 1; 126 | flex-wrap: wrap; 127 | align-items: baseline; 128 | } 129 | 130 | .unlock-icon { 131 | flex: 0 0 24px; 132 | margin-top: 24px; 133 | margin-left: -4px; 134 | margin-right: 1rem; 135 | } 136 | 137 | .pass-group { 138 | width: 100%; 139 | margin-bottom: 1rem; 140 | } 141 | 142 | .pass-label { 143 | display: block; 144 | margin-bottom: 0.5rem; 145 | font-size: 1.2rem; 146 | } 147 | 148 | #pass-input { 149 | width: 100%; 150 | padding: 1rem; 151 | border: none; 152 | border-radius: 3px; 153 | background-color: var(--ui-disabled); 154 | color: #ccc; 155 | font-size: 1.6rem; 156 | } 157 | 158 | #pass-input:focus { 159 | outline: none; 160 | background-color: #fff; 161 | color: #000; 162 | box-shadow: 0 0 0 2px var(--action-color); 163 | } 164 | 165 | #pass-input.error { 166 | box-shadow: 0 0 0 2px var(--error-color); 167 | } 168 | 169 | .select-group { 170 | flex: 1; 171 | } 172 | 173 | .expire-select { 174 | font-size: 1.4rem; 175 | } 176 | 177 | .expire-select:focus { 178 | outline: none; 179 | box-shadow: 0 0 0 2px var(--action-color); 180 | } 181 | 182 | .submit-group { 183 | flex: 0; 184 | } 185 | 186 | .decrypt-inputs { 187 | display: flex; 188 | align-items: flex-end; 189 | } 190 | 191 | .decrypt-inputs .lock-icon { 192 | margin-top: 0; 193 | margin-bottom: 9px; 194 | } 195 | 196 | .decrypt-inputs .pass-group { 197 | margin-bottom: 0; 198 | margin-right: 1rem; 199 | } 200 | 201 | .decrypt-inputs .submit-btn { 202 | padding: 1.05rem 1.5rem; 203 | } 204 | 205 | .decrypt-error { 206 | padding: 1rem 2.8rem; 207 | font-size: 1.8rem; 208 | font-style: italic; 209 | color: var(--error-color); 210 | } 211 | 212 | .btn { 213 | display: block; 214 | padding: 0.8rem 1.5rem; 215 | border: none; 216 | border-radius: 3px; 217 | font-size: 1.4rem; 218 | font-weight: bold; 219 | background-color: var(--action-color); 220 | color: #fff; 221 | } 222 | 223 | .btn:focus { 224 | outline: none; 225 | box-shadow: 0 0 0 2px #fff; 226 | } 227 | 228 | .btn:disabled { 229 | background-color: var(--ui-disabled); 230 | color: #262534; 231 | } 232 | 233 | .share-overlay { 234 | display: flex; 235 | align-items: center; 236 | } 237 | 238 | .lock-icon { 239 | flex: 0 0 24px; 240 | margin-top: -8px; 241 | margin-left: -4px; 242 | margin-right: 1rem; 243 | } 244 | 245 | .share-info { 246 | flex: 1; 247 | } 248 | 249 | .share-date { 250 | display: block; 251 | margin-bottom: 0.5rem; 252 | font-size: 1.3rem; 253 | line-height: 1; 254 | } 255 | 256 | .share-link { 257 | display: block; 258 | margin-bottom: 0.5rem; 259 | font-size: 1.8rem; 260 | line-height: 1; 261 | } 262 | 263 | .share-expire { 264 | font-size: 1.4rem; 265 | font-style: italic; 266 | } 267 | 268 | .view-msg { 269 | position: relative; 270 | color: var(--ui-color); 271 | padding-bottom: 1.5em; 272 | margin-bottom: 1.5em; 273 | border-bottom: 1px solid #534a54; 274 | } 275 | 276 | .view-msg.encrypted { 277 | word-wrap: break-word; 278 | } 279 | 280 | .meta { 281 | display: flex; 282 | } 283 | 284 | .meta .share-date, 285 | .meta .share-expire { 286 | font-size: 1.4rem; 287 | flex: 1; 288 | } 289 | 290 | .meta .share-expire { 291 | text-align: right; 292 | } 293 | 294 | .not-found { 295 | margin-top: 3rem; 296 | margin-right: 24rem; 297 | } 298 | 299 | .not-found h1 { 300 | font-size: 2.4rem; 301 | margin-bottom: 1.2rem; 302 | color: #fff; 303 | } 304 | 305 | .not-found p { 306 | font-size: 1.8rem; 307 | line-height: 1.4; 308 | margin-bottom: 1rem; 309 | } 310 | 311 | .not-found a { 312 | font-size: 1.6rem; 313 | } 314 | 315 | .footer { 316 | padding: 16rem 0 8rem; 317 | display: flex; 318 | flex-direction: column; 319 | align-items: center; 320 | flex-shrink: 0; 321 | } 322 | 323 | .footer p { 324 | margin: 0; 325 | color: #828282; 326 | font-size: 1.6rem; 327 | line-height: 2; 328 | } 329 | -------------------------------------------------------------------------------- /assets/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/postlight/secretmsg/f539415c6a2e9cfb4d70c69e73cc146fd667bde4/assets/images/favicon.ico -------------------------------------------------------------------------------- /assets/images/postlight-labs.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/postlight/secretmsg/f539415c6a2e9cfb4d70c69e73cc146fd667bde4/assets/images/postlight-labs.gif -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest" 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "secretmsg", 3 | "version": "1.0.1", 4 | "description": "Cloudflare Worker app for encrypting and decrypting messages", 5 | "author": "Postlight", 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "node scripts/build.js", 9 | "deploy": "node scripts/deploy.js", 10 | "lint": "eslint --ext .js,.ts,.tsx ./src", 11 | "start": "node scripts/start.js", 12 | "test": "jest" 13 | }, 14 | "dependencies": { 15 | "copy-to-clipboard": "^3.3.1", 16 | "hashids": "1.2.2", 17 | "preact": "8.4.2", 18 | "preact-render-to-string": "4.1.0", 19 | "unistore": "3.2.1", 20 | "url-pattern": "1.0.3" 21 | }, 22 | "devDependencies": { 23 | "@dollarshaveclub/cloudworker": "0.0.11", 24 | "@types/gtag.js": "0.0.4", 25 | "@types/hashids": "1.0.30", 26 | "@types/jest": "24.0.11", 27 | "@typescript-eslint/eslint-plugin": "1.4.2", 28 | "@typescript-eslint/parser": "1.4.2", 29 | "acorn": "6.1.1", 30 | "async": "2.6.2", 31 | "aws-sdk": "2.424.0", 32 | "cross-spawn": "6.0.5", 33 | "css-loader": "2.1.1", 34 | "eslint": "5.15.3", 35 | "eslint-config-prettier": "4.1.0", 36 | "eslint-plugin-jest": "22.4.1", 37 | "eslint-plugin-prettier": "3.0.1", 38 | "eslint-plugin-react": "7.12.4", 39 | "form-data": "2.3.3", 40 | "jest": "24.5.0", 41 | "mini-css-extract-plugin": "0.5.0", 42 | "node-fetch": "2.3.0", 43 | "null-loader": "0.1.1", 44 | "prettier": "1.16.4", 45 | "raw-body": "2.3.3", 46 | "serve-static": "1.13.2", 47 | "style-loader": "0.23.1", 48 | "ts-jest": "24.0.0", 49 | "ts-loader": "5.3.3", 50 | "typescript": "3.3.4000", 51 | "webpack": "4.29.6", 52 | "webpack-dev-middleware": "3.6.1" 53 | }, 54 | "volta": { 55 | "node": "10.23.1" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | const webpack = require("webpack"); 2 | const clientConfig = require("../webpack.client"); 3 | const workerConfig = require("../webpack.worker"); 4 | 5 | console.time("Build complete"); 6 | 7 | const compiler = webpack([clientConfig(true), workerConfig(true)]); 8 | function cliBuild() { 9 | compiler.run((err, stats) => { 10 | if (err) { 11 | return console.error(err.stack || err, err.details || ""); 12 | } 13 | 14 | const info = stats.toJson({ 15 | all: false, 16 | errors: true, 17 | warnings: true, 18 | assets: true 19 | }); 20 | if (stats.hasErrors()) { 21 | return console.error(info.errors); 22 | } 23 | if (stats.hasWarnings()) { 24 | return console.warn(info.warnings); 25 | } 26 | 27 | console.timeEnd("Build complete"); 28 | const [clientStats, workerStats] = info.children; 29 | console.log("-----------------------------------------"); 30 | clientStats.assets.forEach(assetResult); 31 | workerStats.assets.forEach(assetResult); 32 | console.log(""); 33 | }); 34 | } 35 | 36 | function assetResult(asset) { 37 | const size = 38 | asset.size > 1024 39 | ? `${Math.round(asset.size / 1024)} KB` 40 | : `${asset.size} B`; 41 | console.log(`* ${asset.name} - ${size}`); 42 | } 43 | 44 | // Runs if called from CLI, like `node build.js` 45 | if (require.main === module) { 46 | cliBuild(); 47 | } 48 | 49 | // Expose the build as an async function to be used by other scripts 50 | module.exports = () => 51 | new Promise((resolve, reject) => { 52 | compiler.run((err, stats) => { 53 | if (err) { 54 | return reject(err); 55 | } 56 | 57 | const info = stats.toJson({ 58 | all: false, 59 | errors: true, 60 | warnings: true, 61 | assets: true 62 | }); 63 | 64 | if (stats.hasErrors()) { 65 | return reject( 66 | new Error(`${info.errors.map(err => `${err}`)}`).join("\n\n") 67 | ); 68 | } 69 | if (stats.hasWarnings()) { 70 | console.warn(info.warnings); 71 | } 72 | const [clientStats, workerStats] = info.children; 73 | resolve({ 74 | client: clientStats.assets.map(asset => asset.name), 75 | worker: workerStats.assets.map(asset => asset.name) 76 | }); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /scripts/deploy.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const FormData = require("form-data"); 4 | const spawn = require("cross-spawn"); 5 | const mapLimit = require("async/mapLimit"); 6 | const S3 = require("aws-sdk/clients/s3"); 7 | const fetch = require("node-fetch"); 8 | const build = require("./build"); 9 | const metadata = require("./metadata"); 10 | 11 | const s3 = new S3({ 12 | accessKeyId: process.env.AWS_KEY, 13 | secretAccessKey: process.env.AWS_SECRET, 14 | region: process.env.AWS_REGION 15 | }); 16 | const bucket = process.env.BUCKET; 17 | 18 | (async () => { 19 | try { 20 | // check 21 | if (!assertConfig()) return; 22 | 23 | // build 24 | await npm("install"); 25 | const files = await build(); 26 | 27 | // copy files to s3 28 | await Promise.all([ 29 | s3UploadDirectory(path.join(__dirname, "../dist")), 30 | s3UploadDirectory(path.join(__dirname, "../assets")) 31 | ]); 32 | 33 | // update metadata 34 | const jsFiles = files.client.filter(filename => /\.js$/.test(filename)); 35 | const cssFiles = files.client.filter(filename => /\.css$/.test(filename)); 36 | const metadataJson = JSON.stringify(metadata(jsFiles, cssFiles)); 37 | 38 | // update worker 39 | const scriptPath = path.resolve(__dirname, "../dist", files.worker[0]); 40 | const script = fs.readFileSync(scriptPath, "utf8"); 41 | updateWorker(script, metadataJson); 42 | 43 | // finally, purge cache 44 | purgeCache(); 45 | } catch (err) { 46 | console.error(err); 47 | } 48 | })(); 49 | 50 | function assertConfig() { 51 | const missing = []; 52 | [ 53 | "BUCKET", 54 | "AWS_KEY", 55 | "AWS_SECRET", 56 | "AWS_REGION", 57 | "CF_ZONE_ID", 58 | "CF_KEY", 59 | "CF_EMAIL" 60 | ].forEach(ev => { 61 | if (!process.env[ev] || process.env[ev].length < 1) { 62 | missing.push(ev); 63 | } 64 | }); 65 | if (missing.length > 0) { 66 | console.log(` 67 | Deploy failed 68 | ------------- 69 | The following environment variables must be set: 70 | ${missing.join(" ")} 71 | `); 72 | return false; 73 | } 74 | return true; 75 | } 76 | 77 | function npm(...commands) { 78 | const result = spawn.sync("npm", commands, { stdio: "inherit" }); 79 | if (result.status !== 0) { 80 | throw new Error(result.stderr.toString()); 81 | } 82 | return; 83 | } 84 | 85 | function s3UploadDirectory(dir) { 86 | return new Promise((resolve, reject) => { 87 | const files = fileList(dir); 88 | mapLimit( 89 | files, 90 | 10, 91 | async ({ src, dest }) => await upload(src, dest, bucket), 92 | (err, contents) => { 93 | if (err) return reject(err); 94 | console.log("Files uploaded to S3"); 95 | console.log("-----------------------------------------"); 96 | console.log(contents.map(obj => `* ${obj}`).join("\n")); 97 | console.log(""); 98 | resolve(); 99 | } 100 | ); 101 | }); 102 | } 103 | 104 | // gather flat list of files in directory tree - { src, dest } 105 | function fileList(dir) { 106 | const output = []; 107 | const ls = (srcDir, destDir) => { 108 | const filenames = fs.readdirSync(srcDir); 109 | filenames.forEach(name => { 110 | if (name.startsWith(".")) return; 111 | const src = path.join(srcDir, name); 112 | const stats = fs.statSync(src); 113 | if (stats.isDirectory()) { 114 | ls(src, `${destDir}${name}/`); 115 | } else if (stats.isFile()) { 116 | output.push({ src, dest: `${destDir}${name}` }); 117 | } 118 | }); 119 | }; 120 | ls(dir, "assets/"); 121 | return output; 122 | } 123 | 124 | function upload(src, dest, bucket) { 125 | const buff = fs.readFileSync(src); 126 | return new Promise((resolve, reject) => { 127 | const ext = path.extname(src).replace(/^\./, ""); 128 | s3.upload( 129 | { 130 | ACL: "public-read", 131 | Body: buff, 132 | Bucket: bucket, 133 | CacheControl: "public, max-age=31536000", 134 | ContentType: contentType(ext), 135 | Expires: oneYear(), 136 | Key: dest 137 | }, 138 | (err, data) => { 139 | if (err) return reject(err); 140 | resolve(data.Location); 141 | } 142 | ); 143 | }); 144 | } 145 | 146 | function contentType(ext) { 147 | const types = { 148 | css: "text/css", 149 | gif: "image/gif", 150 | html: "text/html", 151 | ico: "image/x-icon", 152 | jpeg: "image/jpg", 153 | jpg: "image/jpg", 154 | js: "application/javascript", 155 | json: "application/json", 156 | map: "application/json", 157 | png: "image/png", 158 | svg: "image/svg+xml", 159 | txt: "text/plain", 160 | xml: "application/xml" 161 | }; 162 | return types[ext] || "application/octet-stream"; 163 | } 164 | 165 | function oneYear() { 166 | const d = new Date(); 167 | d.setFullYear(d.getFullYear() + 1); 168 | return d.getTime(); 169 | } 170 | 171 | async function updateWorker(script, metadataJson) { 172 | const cfApi = "https://api.cloudflare.com/client/v4"; 173 | const zoneId = process.env.CF_ZONE_ID; 174 | const email = process.env.CF_EMAIL; 175 | const key = process.env.CF_KEY; 176 | const form = new FormData(); 177 | form.append("script", script); 178 | form.append("metadata", metadataJson); 179 | try { 180 | const response = await fetch(`${cfApi}/zones/${zoneId}/workers/script`, { 181 | method: "PUT", 182 | body: form, 183 | headers: { 184 | "X-Auth-Email": email, 185 | "X-Auth-Key": key 186 | } 187 | }); 188 | if (response.ok) { 189 | console.log(`Worker deployed at ${new Date().toISOString()}`); 190 | } else { 191 | console.error("Worker deploy to CF failed:", response.statusText); 192 | } 193 | } catch (err) { 194 | console.error("Worker deploy to CF failed:", err); 195 | } 196 | } 197 | 198 | function purgeCache() { 199 | const cfApi = "https://api.cloudflare.com/client/v4"; 200 | const zoneId = process.env.CF_ZONE_ID; 201 | const email = process.env.CF_EMAIL; 202 | const key = process.env.CF_KEY; 203 | 204 | fetch(`${cfApi}/zones/${zoneId}/purge_cache`, { 205 | method: "POST", 206 | body: JSON.stringify({ purge_everything: true }), 207 | headers: { 208 | "X-Auth-Email": email, 209 | "X-Auth-Key": key 210 | } 211 | }); 212 | } 213 | -------------------------------------------------------------------------------- /scripts/metadata.js: -------------------------------------------------------------------------------- 1 | module.exports = function metadata(jsFiles = [], cssFiles = []) { 2 | // Build files include a hash in there name for better caching. Because of 3 | // of that we need pass the latest filesnames to the worker, so it can render 4 | // the appropriate script and link tags. 5 | const data = { 6 | body_part: "script", 7 | bindings: [ 8 | { 9 | name: "JS_FILES", 10 | type: "secret_text", 11 | text: jsFiles.join(" ") 12 | }, 13 | { 14 | name: "CSS_FILES", 15 | type: "secret_text", 16 | text: cssFiles.join(" ") 17 | } 18 | ] 19 | }; 20 | 21 | // Namespaces are defined by their name and id, separated by a space. If you 22 | // need to bind multiple KV namespaces, separate the pairs with a comma, 23 | // example: CF_KV_NAMESPACES="NAME_ONE xx679c2zz5e3870yyzzz, NAME_TWO aa899cbbb5e5900yyccc" 24 | const nsEnvVar = process.env.CF_KV_NAMESPACES; 25 | if (nsEnvVar) { 26 | const namespaces = splitKeyVals(nsEnvVar); 27 | data.bindings = data.bindings.concat( 28 | namespaces.map(([name, namespace_id]) => ({ 29 | name, 30 | type: "kv_namespace", 31 | namespace_id 32 | })) 33 | ); 34 | } 35 | 36 | // Similar to namespaces, you can bind any values you like to the worker using 37 | // key values using the CF_WORKER_BINDINGS env var, example: 38 | // CF_WORKER_BINDINGS="KEY_ONE somevalue, KEY_TWO anothervalue" 39 | const bindingEnvVar = process.env.CF_WORKER_BINDINGS; 40 | if (bindingEnvVar) { 41 | const customBindings = splitKeyVals(bindingEnvVar); 42 | data.bindings = data.bindings.concat( 43 | customBindings.map(([name, text]) => ({ 44 | name, 45 | type: "secret_text", 46 | text 47 | })) 48 | ); 49 | } 50 | 51 | return data; 52 | }; 53 | 54 | function splitKeyVals(str) { 55 | return str.split(",").map(pair => pair.split(" ")); 56 | } 57 | -------------------------------------------------------------------------------- /scripts/start.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const http = require("http"); 3 | const fetch = require("node-fetch"); 4 | const getRawBody = require("raw-body"); 5 | const staticMiddleware = require("serve-static"); 6 | const Cloudworker = require("@dollarshaveclub/cloudworker"); 7 | const webpack = require("webpack"); 8 | const webpackDevMiddleware = require("webpack-dev-middleware"); 9 | const clientConfig = require("../webpack.client"); 10 | const workerConfig = require("../webpack.worker"); 11 | const metadata = require("./metadata"); 12 | 13 | // Static assets server and bundler 14 | const staticPort = 3333; 15 | const compiler = webpack([clientConfig(), workerConfig()]); 16 | const serveWebpack = webpackDevMiddleware(compiler, { 17 | logLevel: "warn", 18 | publicPath: "/assets" 19 | }); 20 | const serveStatic = staticMiddleware(path.join(__dirname, "..")); 21 | http 22 | .createServer(assetsHandler(serveError, serveWebpack, serveStatic, notFound)) 23 | .listen(staticPort); 24 | 25 | function assetsHandler(errHandler, ...steps) { 26 | // Calling next send the request on to the next handler, unless an error is 27 | // passed. In that case, we go straight to the errHandler 28 | return (req, res) => { 29 | const queue = steps.slice().reverse(); 30 | const next = err => { 31 | if (err) return errHandler(err, req, res); 32 | const middleware = queue.pop(); 33 | if (middleware) { 34 | middleware(req, res, next); 35 | } 36 | }; 37 | next(); 38 | }; 39 | } 40 | 41 | function serveError(err, req, res) { 42 | res.statusCode = err.status || 500; 43 | res.end(err.stack); 44 | } 45 | 46 | function notFound(req, res) { 47 | res.statusCode = 404; 48 | res.end(`${req.url} - Not found`); 49 | } 50 | 51 | // A local worker handles all requests. First the latest worker script bundle is 52 | // fetched from the static asset server, then a cloudworker is initialized, the 53 | // request is dispatched, and finally, the worker response is sent back. 54 | const workerPort = 3030; 55 | 56 | // Bindings are exposed as variables in the root of the worker script. 57 | const bindings = mapMetadataToBindings(); 58 | http 59 | .createServer(async (req, res) => { 60 | let script; 61 | let body; 62 | try { 63 | [script, body] = await Promise.all([ 64 | getScript(`http://localhost:${staticPort}/assets/worker/worker.js`), 65 | parseBody(req) 66 | ]); 67 | } catch (err) { 68 | return console.error( 69 | "Unable to fetch script or parse request body:", 70 | err 71 | ); 72 | } 73 | 74 | try { 75 | const worker = new Cloudworker(script, { bindings }); 76 | const workerReq = new Cloudworker.Request( 77 | `http://localhost:${staticPort}${req.url}`, 78 | { 79 | method: req.method, 80 | body 81 | } 82 | ); 83 | const workerRes = await worker.dispatch(workerReq); 84 | const workerBuff = await workerRes.buffer(); 85 | res.writeHead( 86 | workerRes.status, 87 | workerRes.statusText, 88 | workerRes.headers.raw() 89 | ); 90 | res.write(workerBuff); 91 | res.end(); 92 | } catch (err) { 93 | console.error(err); 94 | } 95 | }) 96 | .listen(workerPort, err => { 97 | if (err) { 98 | return console.log("Server error", err); 99 | } 100 | console.log(` 101 | ----------------------------------------- 102 | Dev server ready => http://localhost:${workerPort} 103 | ----------------------------------------- 104 | `); 105 | }); 106 | 107 | async function parseBody(req) { 108 | if (req.method === "GET") return; 109 | try { 110 | return await getRawBody(req); 111 | } catch (err) { 112 | console.log("Error parsing body", err.message); 113 | } 114 | } 115 | 116 | async function getScript(url) { 117 | const response = await fetch(url); 118 | if (!response.ok) { 119 | response.statusMessage; 120 | throw new Error(`${res.statusCode}: ${res.statusMessage}`); 121 | } 122 | return response.text(); 123 | } 124 | 125 | function mapMetadataToBindings() { 126 | const output = {}; 127 | const data = metadata(["js/client.js"]); 128 | data.bindings.forEach(bind => { 129 | switch (bind.type) { 130 | case "secret_text": 131 | output[bind.name] = bind.text; 132 | break; 133 | case "kv_namespace": 134 | output[bind.name] = new Cloudworker.KeyValueStore(); 135 | break; 136 | } 137 | }); 138 | return output; 139 | } 140 | -------------------------------------------------------------------------------- /src/@types/triplesec.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | declare namespace triplesec { 3 | const encrypt: ( 4 | options: TripleSecInput, 5 | callback: (err: any, buff: any) => void 6 | ) => void; 7 | const decrypt: ( 8 | options: TripleSecInput, 9 | callback: (err: any, buff: any) => void 10 | ) => void; 11 | const Buffer: any; 12 | } 13 | 14 | interface TripleSecInput { 15 | data: any; 16 | key: any; 17 | progress_hook: (progress: { what: string; i: number; total: number }) => void; 18 | } 19 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import { h, render } from "preact"; 2 | import { render as renderToString } from "preact-render-to-string"; 3 | import { initStore } from "./store"; 4 | import { route, Route } from "./router"; 5 | import { KeyValueStore } from "./worker"; 6 | import { Root } from "./components/root"; 7 | 8 | // Client-side starting point 9 | export function run(): void { 10 | const node = document.getElementById("bootstrap-data"); 11 | if (node && node.textContent) { 12 | const data = JSON.parse(node.textContent); 13 | const store = initStore(data); 14 | const container = document.body; 15 | const content = container.firstElementChild; 16 | if (content != null) { 17 | render(, container, content); 18 | } 19 | } 20 | } 21 | 22 | interface SSRender extends Route { 23 | html: string; 24 | } 25 | 26 | // Used by worker to render page 27 | export async function serverRender( 28 | path: string, 29 | kv: KeyValueStore 30 | ): Promise { 31 | const { status, data } = await route(path, kv); 32 | const store = initStore(data); 33 | return { status, data, html: renderToString() }; 34 | } 35 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import { run } from "./app"; 2 | 3 | run(); 4 | -------------------------------------------------------------------------------- /src/components/app.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --ui-color: #cecece; 3 | --ui-disabled: #4e4a56; 4 | --action-color: #23af85; 5 | --error-color: #cee242; 6 | } 7 | 8 | html { 9 | box-sizing: border-box; 10 | font-size: 62.5%; 11 | } 12 | 13 | *, 14 | *:before, 15 | *:after { 16 | box-sizing: inherit; 17 | } 18 | 19 | body, 20 | h1, 21 | h2, 22 | h3, 23 | h4, 24 | h5, 25 | h6, 26 | p, 27 | ol, 28 | ul { 29 | margin: 0; 30 | padding: 0; 31 | font-weight: normal; 32 | } 33 | 34 | ol, 35 | ul { 36 | list-style: none; 37 | } 38 | 39 | img { 40 | max-width: 100%; 41 | height: auto; 42 | } 43 | 44 | body { 45 | color: var(--ui-color); 46 | background: #262534; 47 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, 48 | Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; 49 | } 50 | 51 | a { 52 | color: #a0e8d2; 53 | text-decoration: none; 54 | } 55 | 56 | a:hover { 57 | text-decoration: underline; 58 | } 59 | 60 | a:active { 61 | color: #bcffea; 62 | } 63 | 64 | a:visited { 65 | color: #a0e8d2; 66 | } 67 | 68 | .wrapper { 69 | max-width: 600px; 70 | padding: 2rem; 71 | margin: 0 auto; 72 | } 73 | 74 | .write-group { 75 | position: relative; 76 | margin-bottom: 1.5rem; 77 | border-bottom: 1px solid #534a54; 78 | } 79 | 80 | .msg-input-label { 81 | display: block; 82 | margin-bottom: 2rem; 83 | font-size: 1.2rem; 84 | } 85 | 86 | #msg-input, 87 | .view-msg { 88 | display: block; 89 | width: 100%; 90 | padding: 1rem 0; 91 | border: none; 92 | font-size: 1.6rem; 93 | line-height: 1.5; 94 | background-color: transparent; 95 | font-family: Monaco, Consolas, monospace; 96 | color: #fff; 97 | } 98 | 99 | #msg-input:focus { 100 | outline: none; 101 | caret-color: var(--action-color); 102 | } 103 | 104 | #msg-input:disabled { 105 | color: var(--ui-color); 106 | } 107 | 108 | .encrypt-inputs-wrapper { 109 | display: flex; 110 | } 111 | 112 | .progress-bar { 113 | position: absolute; 114 | width: 100%; 115 | bottom: -1px; 116 | left: 0; 117 | height: 1px; 118 | background-color: var(--action-color); 119 | transform-origin: 0; 120 | transform: scaleX(0); 121 | } 122 | 123 | .encrypt-inputs { 124 | display: flex; 125 | flex: 1; 126 | flex-wrap: wrap; 127 | align-items: baseline; 128 | } 129 | 130 | .unlock-icon { 131 | flex: 0 0 24px; 132 | margin-top: 24px; 133 | margin-left: -4px; 134 | margin-right: 1rem; 135 | } 136 | 137 | .pass-group { 138 | width: 100%; 139 | margin-bottom: 1rem; 140 | } 141 | 142 | .pass-label { 143 | display: block; 144 | margin-bottom: 0.5rem; 145 | font-size: 1.2rem; 146 | } 147 | 148 | #pass-input { 149 | width: 100%; 150 | padding: 1rem; 151 | border: none; 152 | border-radius: 3px; 153 | background-color: var(--ui-disabled); 154 | color: #ccc; 155 | font-size: 1.6rem; 156 | } 157 | 158 | #pass-input:focus { 159 | outline: none; 160 | background-color: #fff; 161 | color: #000; 162 | box-shadow: 0 0 0 2px var(--action-color); 163 | } 164 | 165 | #pass-input.error { 166 | box-shadow: 0 0 0 2px var(--error-color); 167 | } 168 | 169 | .select-group { 170 | flex: 1; 171 | } 172 | 173 | .expire-select { 174 | font-size: 1.4rem; 175 | } 176 | 177 | .expire-select:focus { 178 | outline: none; 179 | box-shadow: 0 0 0 2px var(--action-color); 180 | } 181 | 182 | .submit-group { 183 | flex: 0; 184 | } 185 | 186 | .decrypt-inputs { 187 | display: flex; 188 | align-items: flex-end; 189 | } 190 | 191 | .decrypt-inputs .lock-icon { 192 | margin-top: 0; 193 | margin-bottom: 9px; 194 | } 195 | 196 | .decrypt-inputs .pass-group { 197 | margin-bottom: 0; 198 | margin-right: 1rem; 199 | } 200 | 201 | .decrypt-inputs .submit-btn { 202 | padding: 1.05rem 1.5rem; 203 | } 204 | 205 | .decrypt-error { 206 | padding: 1rem 2.8rem; 207 | font-size: 1.8rem; 208 | font-style: italic; 209 | color: var(--error-color); 210 | } 211 | 212 | .btn { 213 | display: block; 214 | padding: 0.8rem 1.5rem; 215 | border: none; 216 | border-radius: 3px; 217 | font-size: 1.4rem; 218 | font-weight: bold; 219 | background-color: var(--action-color); 220 | color: #fff; 221 | } 222 | 223 | .btn:focus { 224 | outline: none; 225 | box-shadow: 0 0 0 2px #fff; 226 | } 227 | 228 | .btn:disabled { 229 | background-color: var(--ui-disabled); 230 | color: #262534; 231 | } 232 | 233 | .share-overlay { 234 | display: flex; 235 | align-items: center; 236 | } 237 | 238 | .lock-icon { 239 | flex: 0 0 24px; 240 | margin-top: -8px; 241 | margin-left: -4px; 242 | margin-right: 1rem; 243 | } 244 | 245 | .share-info { 246 | flex: 1; 247 | } 248 | 249 | .share-date { 250 | display: block; 251 | margin-bottom: 0.5rem; 252 | font-size: 1.3rem; 253 | line-height: 1; 254 | } 255 | 256 | .share-link { 257 | display: block; 258 | margin-bottom: 0.5rem; 259 | font-size: 1.8rem; 260 | line-height: 1; 261 | } 262 | 263 | .share-expire { 264 | font-size: 1.4rem; 265 | font-style: italic; 266 | } 267 | 268 | .view-msg { 269 | position: relative; 270 | color: var(--ui-color); 271 | padding-bottom: 1.5em; 272 | margin-bottom: 1.5em; 273 | border-bottom: 1px solid #534a54; 274 | } 275 | 276 | .view-msg.encrypted { 277 | word-wrap: break-word; 278 | } 279 | 280 | .meta { 281 | display: flex; 282 | } 283 | 284 | .meta .share-date, 285 | .meta .share-expire { 286 | font-size: 1.4rem; 287 | flex: 1; 288 | } 289 | 290 | .meta .share-expire { 291 | text-align: right; 292 | } 293 | 294 | .not-found { 295 | margin-top: 3rem; 296 | margin-right: 24rem; 297 | } 298 | 299 | .not-found h1 { 300 | font-size: 2.4rem; 301 | margin-bottom: 1.2rem; 302 | color: #fff; 303 | } 304 | 305 | .not-found p { 306 | font-size: 1.8rem; 307 | line-height: 1.4; 308 | margin-bottom: 1rem; 309 | } 310 | 311 | .not-found a { 312 | font-size: 1.6rem; 313 | } 314 | 315 | .footer { 316 | padding: 16rem 0 8rem; 317 | display: flex; 318 | flex-direction: column; 319 | align-items: center; 320 | flex-shrink: 0; 321 | } 322 | 323 | .footer p { 324 | margin: 0; 325 | color: #828282; 326 | font-size: 1.6rem; 327 | line-height: 2; 328 | } 329 | -------------------------------------------------------------------------------- /src/components/encrypt-inputs.tsx: -------------------------------------------------------------------------------- 1 | import { h, FunctionalComponent, VNode } from "preact"; 2 | import { UnlockIcon } from "./icons"; 3 | 4 | interface Props { 5 | passphrase: string; 6 | expiration: number; 7 | onPassChange: (update: string) => void; 8 | onExpireChange: (update: number) => void; 9 | } 10 | 11 | export const EncryptInputs: FunctionalComponent = ({ 12 | passphrase, 13 | expiration, 14 | onPassChange, 15 | onExpireChange 16 | }): VNode => ( 17 |
18 | 19 |
20 |
21 | 24 | 30 | onPassChange((e.target as HTMLFormElement).value) 31 | } 32 | /> 33 |
34 |
35 | 48 |
49 |
50 | 56 |
57 |
58 |
59 | ); 60 | -------------------------------------------------------------------------------- /src/components/icons.tsx: -------------------------------------------------------------------------------- 1 | import { h, FunctionalComponent, VNode } from "preact"; 2 | 3 | interface IconProps { 4 | class?: string; 5 | scale?: number; 6 | } 7 | 8 | export const LockIcon: FunctionalComponent = ({ 9 | class: className = "", 10 | scale = 1 11 | }): VNode => ( 12 | 19 | 20 | 24 | 25 | ); 26 | 27 | export const UnlockIcon: FunctionalComponent = ({ 28 | class: className = "", 29 | scale = 1 30 | }): VNode => ( 31 | 38 | 39 | 40 | 44 | 45 | 46 | ); 47 | -------------------------------------------------------------------------------- /src/components/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { h, VNode } from "preact"; 2 | import { Wrapper } from "./wrapper"; 3 | 4 | export const NotFound = (): VNode => ( 5 | 6 |
7 |

Missing message

8 |

9 | If you're sure this link is correct, there's a chance this 10 | message has expired. 11 |

12 |

13 | Write new message → 14 |

15 |
16 |
17 | ); 18 | -------------------------------------------------------------------------------- /src/components/root.tsx: -------------------------------------------------------------------------------- 1 | import { h, FunctionalComponent, VNode } from "preact"; 2 | import { Store } from "unistore"; 3 | import { Provider, connect } from "unistore/preact"; 4 | import { SecretState } from "../store"; 5 | import { Page } from "../router"; 6 | import { Write } from "./write"; 7 | import { View } from "./view"; 8 | import { NotFound } from "./not-found"; 9 | import "./app.css"; 10 | 11 | interface RootProps { 12 | store: Store; 13 | } 14 | 15 | export const Root: FunctionalComponent = ({ store }): VNode => ( 16 | 17 | 18 | 19 | ); 20 | 21 | const routes = { 22 | [Page.Write]: , 23 | [Page.View]: , 24 | [Page.NotFound]: 25 | }; 26 | 27 | const Content = connect<{}, {}, SecretState, SecretState>(["page"])( 28 | ({ page }) => routes[page] 29 | ); 30 | -------------------------------------------------------------------------------- /src/components/share-overlay.tsx: -------------------------------------------------------------------------------- 1 | import { h, FunctionalComponent, VNode } from "preact"; 2 | import * as copy from "copy-to-clipboard"; 3 | import { formatDate, formatExpiration } from "../lib/time"; 4 | import { LockIcon } from "./icons"; 5 | 6 | interface Props { 7 | id: string; 8 | timestamp: number; 9 | expireTime: number; 10 | } 11 | 12 | export const ShareOverlay: FunctionalComponent = ({ 13 | id, 14 | timestamp, 15 | expireTime, 16 | }): VNode => ( 17 | 35 | ); 36 | 37 | function copier(link: string): (e: Event) => void { 38 | return (e: Event) => { 39 | e.preventDefault(); 40 | gtag("event", "copy_link"); 41 | copy(link); 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /src/components/view.tsx: -------------------------------------------------------------------------------- 1 | import { h, Component, ComponentChild } from "preact"; 2 | import { connect } from "unistore/preact"; 3 | import { actions, SecretState, MsgEnvelope } from "../store"; 4 | import { formatDate, formatExpiration } from "../lib/time"; 5 | import { Wrapper } from "./wrapper"; 6 | import { LockIcon } from "./icons"; 7 | 8 | interface Props { 9 | envelope: MsgEnvelope; 10 | decrypted?: string; 11 | decryptError?: string; 12 | decryptMessage: ( 13 | passphrase: string 14 | ) => Promise>; 15 | progress: number; 16 | } 17 | 18 | interface State { 19 | passphrase: string; 20 | } 21 | 22 | class ViewComp extends Component { 23 | public readonly state = { 24 | passphrase: "", 25 | }; 26 | 27 | private handlePassChange = (e: Event) => { 28 | this.setState({ passphrase: (e.target as HTMLFormElement).value }); 29 | }; 30 | 31 | private handleSubmit = (e: Event) => { 32 | e.preventDefault(); 33 | this.props.decryptMessage(this.state.passphrase); 34 | gtag("event", "decrypt_message"); 35 | }; 36 | 37 | public render(): ComponentChild { 38 | const { passphrase } = this.state; 39 | const { decrypted, envelope, progress, decryptError } = this.props; 40 | return ( 41 | 42 | {decrypted ? ( 43 | 44 | ← Write new message 45 | 46 | ) : ( 47 |
48 | 49 |
50 | 53 | 62 |
63 |
64 | 70 |
71 | 72 | )} 73 | {decryptError &&
{decryptError}
} 74 |
75 | {decrypted ? decrypted : envelope.encrypted} 76 | {!decrypted && ( 77 |
81 | )} 82 |
83 |
84 | 85 | 86 |
87 | 88 | ); 89 | } 90 | } 91 | 92 | export const View = connect<{}, {}, SecretState, Props>( 93 | ["envelope", "decrypted", "decryptError", "progress"], 94 | actions 95 | )(ViewComp); 96 | -------------------------------------------------------------------------------- /src/components/wrapper.tsx: -------------------------------------------------------------------------------- 1 | import { h, FunctionalComponent, ComponentChildren, VNode } from "preact"; 2 | 3 | interface Props { 4 | children?: ComponentChildren; 5 | } 6 | 7 | export const Wrapper: FunctionalComponent = ({ children }): VNode => ( 8 |
9 |
{children}
10 | 32 |
33 | ); 34 | -------------------------------------------------------------------------------- /src/components/write.tsx: -------------------------------------------------------------------------------- 1 | import { h, Component, ComponentChild } from "preact"; 2 | import { connect } from "unistore/preact"; 3 | import { actions, SecretState, MsgPayload, MsgEnvelope } from "../store"; 4 | import { Wrapper } from "./wrapper"; 5 | import { EncryptInputs } from "./encrypt-inputs"; 6 | import { ShareOverlay } from "./share-overlay"; 7 | 8 | interface Props { 9 | saveMessage: (payload: MsgPayload) => void; 10 | clearMessage: () => void; 11 | envelope?: MsgEnvelope; 12 | progress: number; 13 | } 14 | 15 | interface State { 16 | message: string; 17 | passphrase: string; 18 | expiration: number; 19 | } 20 | 21 | class WriteComp extends Component { 22 | public readonly state = { 23 | message: "", 24 | passphrase: "", 25 | expiration: 0, 26 | }; 27 | 28 | private handleInput = (e: Event) => { 29 | if (e.target) { 30 | this.setState({ message: (e.target as HTMLFormElement).value }); 31 | } 32 | }; 33 | 34 | private handlePassChange = (passphrase: string) => { 35 | this.setState({ passphrase }); 36 | }; 37 | 38 | private handleExpireChange = (expiration: number) => { 39 | this.setState({ expiration }); 40 | gtag("event", "change_expiration"); 41 | }; 42 | 43 | private handleSubmit = (e: Event) => { 44 | e.preventDefault(); 45 | gtag("event", "encrypt_message"); 46 | this.props.saveMessage({ 47 | message: this.state.message, 48 | passphrase: this.state.passphrase, 49 | ttlHours: this.state.expiration, 50 | }); 51 | }; 52 | 53 | private handleClear = (e: Event) => { 54 | e.preventDefault(); 55 | this.setState({ message: "", passphrase: "", expiration: 0 }); 56 | this.props.clearMessage(); 57 | }; 58 | 59 | public render(): ComponentChild { 60 | const { envelope, progress } = this.props; 61 | const { message, passphrase, expiration } = this.state; 62 | return ( 63 | 64 |
65 | 74 |
75 |