├── .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 | 22 | Passphrase 23 | 24 | 30 | onPassChange((e.target as HTMLFormElement).value) 31 | } 32 | /> 33 | 34 | 35 | 39 | onExpireChange(parseInt((e.target as HTMLFormElement).value)) 40 | } 41 | > 42 | Never expires 43 | Expires in 24 hours 44 | Expires in 1 week 45 | Expires in 30 days 46 | Expires in 1 year 47 | 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 | 18 | 19 | 20 | {formatDate(timestamp)} 21 | gtag("event", "share_link")} 25 | >{`secretmsg.app/${id}`} 26 | {formatExpiration(expireTime)} 27 | 28 | 32 | Copy link 33 | 34 | 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 | 51 | Passphrase 52 | 53 | 62 | 63 | 64 | 70 | 71 | 72 | )} 73 | {decryptError && {decryptError}} 74 | 75 | {decrypted ? decrypted : envelope.encrypted} 76 | {!decrypted && ( 77 | 81 | )} 82 | 83 | 84 | {formatDate(envelope.created)} 85 | {formatExpiration(envelope.expires)} 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 | 66 | {!envelope ? ( 67 | "Message" 68 | ) : ( 69 | 70 | ← Write new message 71 | 72 | )} 73 | 74 | 75 | 83 | {!envelope && ( 84 | 88 | )} 89 | 90 | {!envelope ? ( 91 | 97 | ) : ( 98 | 103 | )} 104 | 105 | 106 | ); 107 | } 108 | } 109 | 110 | export const Write = connect<{}, State, SecretState, Props>( 111 | ["envelope", "progress"], 112 | actions 113 | )(WriteComp); 114 | -------------------------------------------------------------------------------- /src/lib/crypto.ts: -------------------------------------------------------------------------------- 1 | const steps = [ 2 | "pbkdf2 (pass 1)", 3 | "scrypt", 4 | "pbkdf2 (pass 2)", 5 | "salsa20", 6 | "twofish", 7 | "aes", 8 | "HMAC-SHA512-SHA3" 9 | ]; 10 | 11 | export function encrypt( 12 | message: string, 13 | passphrase: string, 14 | progress: (percent: number) => void = () => {} 15 | ): Promise { 16 | return new Promise((resolve, reject) => { 17 | triplesec.encrypt( 18 | { 19 | data: new triplesec.Buffer(message), 20 | key: new triplesec.Buffer(passphrase), 21 | progress_hook({ what, i, total }) { 22 | const completed = steps.indexOf(what) / steps.length; 23 | const stepProgress = (i / total) * (1 / steps.length); 24 | progress(completed + stepProgress); 25 | } 26 | }, 27 | (err, buff) => { 28 | if (err) return reject(err); 29 | resolve(buff.toString("base64")); 30 | } 31 | ); 32 | }); 33 | } 34 | 35 | export function decrypt( 36 | data: string, 37 | passphrase: string, 38 | progress: (percent: number) => void = () => {} 39 | ): Promise { 40 | return new Promise((resolve, reject) => { 41 | triplesec.decrypt( 42 | { 43 | data: new triplesec.Buffer(data, "base64"), 44 | key: new triplesec.Buffer(passphrase), 45 | progress_hook({ what, i, total }) { 46 | const completed = steps.indexOf(what) / steps.length; 47 | const stepProgress = (i / total) * (1 / steps.length); 48 | progress(completed + stepProgress); 49 | } 50 | }, 51 | (err, buff) => { 52 | if (err) return reject(err); 53 | resolve(buff.toString()); 54 | } 55 | ); 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /src/lib/kv.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { KeyValueStore } from "../worker"; 3 | import { MsgEnvelope } from "../store"; 4 | 5 | // Send encrypted message from client to worker, where it is saved to KV 6 | export async function writeVal(key: string, val: any): Promise { 7 | const res = await fetch(`/save/${key}`, { 8 | method: "POST", 9 | headers: { 10 | "Content-Type": "application/json" 11 | }, 12 | body: JSON.stringify(val) 13 | }); 14 | if (res.ok) return res; 15 | throw new Error(`KV write error - ${res.status} ${res.statusText}`); 16 | } 17 | 18 | // Message is retrieved directly from KV in the worker 19 | export async function getMessage( 20 | kv: KeyValueStore, 21 | key: string 22 | ): Promise { 23 | const envelope = (await kv.get(key, "json")) as MsgEnvelope; 24 | if (envelope == null) return null; 25 | if (envelope.expires !== 0 && envelope.expires < Date.now()) { 26 | kv.delete(key); 27 | return null; 28 | } 29 | return envelope; 30 | } 31 | -------------------------------------------------------------------------------- /src/lib/request-match.test.ts: -------------------------------------------------------------------------------- 1 | import { match } from "./request-match"; 2 | 3 | class MockRequest { 4 | public url: string; 5 | public method: string; 6 | 7 | public constructor(url: string, options: { method: string }) { 8 | this.url = url; 9 | this.method = options.method; 10 | } 11 | } 12 | 13 | test("match on path and method", () => { 14 | const request = new MockRequest("https://postlight.com/favicon.ico", { 15 | method: "GET" 16 | }); 17 | const route0 = match(request as Request, "*", "/favicon.ico"); 18 | expect(route0).toBeTruthy(); 19 | const route1 = match(request as Request, "GET", "/favicon.ico"); 20 | expect(route1).toBeTruthy(); 21 | const route2 = match(request as Request, "OPTIONS", "/favicon.ico"); 22 | expect(route2).toBeUndefined(); 23 | const route3 = match(request as Request, "GET", "/"); 24 | expect(route3).toBeUndefined(); 25 | }); 26 | 27 | test("parse url params", () => { 28 | const request = new MockRequest("https://postlight.com/user/123xyz", { 29 | method: "GET" 30 | }); 31 | const route = match(request as Request, "get", "/user/:id"); 32 | if (route) { 33 | expect(route.params.id).toBe("123xyz"); 34 | } else { 35 | throw new Error("No match found"); 36 | } 37 | }); 38 | -------------------------------------------------------------------------------- /src/lib/request-match.ts: -------------------------------------------------------------------------------- 1 | import * as UrlPattern from "url-pattern"; 2 | 3 | interface Result { 4 | params: { [key: string]: string }; 5 | url: URL; 6 | } 7 | 8 | export function match( 9 | request: Request, 10 | methods: string, 11 | pattern: string 12 | ): Result | void { 13 | const reqMethod = request.method.toLowerCase(); 14 | const methodList = methods.toLowerCase().split(","); 15 | if (methods !== "*" && !methodList.includes(reqMethod)) { 16 | return; 17 | } 18 | 19 | const url = new URL(request.url); 20 | const patternMatcher = new UrlPattern(pattern); 21 | const params = patternMatcher.match(url.pathname); 22 | if (params == null) { 23 | return; 24 | } 25 | return { params, url }; 26 | } 27 | -------------------------------------------------------------------------------- /src/lib/time.ts: -------------------------------------------------------------------------------- 1 | export function formatDate(timestamp: number): string { 2 | const d = new Date(timestamp); 3 | return d.toLocaleString("en-gb", { 4 | month: "short", 5 | day: "numeric", 6 | year: "numeric" 7 | }); 8 | } 9 | 10 | export function formatExpiration(timestamp: number): string { 11 | if (timestamp === 0) return "Never expires"; 12 | const secDiff = (timestamp - Date.now()) / 1000; 13 | if (secDiff < 7200) { 14 | return `${Math.round(secDiff / 60)} minutes until message expires`; 15 | } else if (secDiff / 3600 < 48) { 16 | return `${Math.round(secDiff / 3600)} hours until message expires`; 17 | } 18 | return `${Math.round(secDiff / 86400)} days until message expires`; 19 | } 20 | -------------------------------------------------------------------------------- /src/page.ts: -------------------------------------------------------------------------------- 1 | interface PageInit { 2 | title: string; 3 | content: string; 4 | scripts?: string[]; 5 | stylesheets?: string[]; 6 | json?: string; 7 | } 8 | 9 | export function page({ 10 | title, 11 | content, 12 | scripts = [], 13 | stylesheets = [], 14 | json = "", 15 | }: PageInit): string { 16 | const scriptTags = scripts 17 | .map((script) => ``) 18 | .join("\n"); 19 | const linkTags = stylesheets 20 | .map( 21 | (sheet) => 22 | `` 23 | ) 24 | .join("\n"); 25 | 26 | return ` 27 | 28 | 29 | 30 | 31 | 32 | ${title} 33 | 34 | ${scriptTags} 35 | 36 | ${linkTags} 37 | 38 | 39 | 45 | 46 | ${content} 47 | `; 48 | } 49 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | import { SecretState } from "./store"; 2 | import { KeyValueStore } from "./worker"; 3 | import { getMessage } from "./lib/kv"; 4 | 5 | export enum Page { 6 | Write = "WRITE", 7 | View = "VIEW", 8 | NotFound = "NOTFOUND" 9 | } 10 | 11 | export interface Route { 12 | status: number; 13 | data: Pick; 14 | } 15 | 16 | export async function route(path: string, kv: KeyValueStore): Promise { 17 | const segment = path.split("/"); 18 | if (segment[1] && segment[1].length > 0) { 19 | // View /:id 20 | const envelope = await getMessage(kv, segment[1]); 21 | if (envelope != null) { 22 | return { 23 | status: 200, 24 | data: { 25 | page: Page.View, 26 | pageId: segment[1], 27 | envelope 28 | } 29 | }; 30 | } 31 | // No message found 32 | return { 33 | status: 404, 34 | data: { page: Page.NotFound } 35 | }; 36 | } else if (segment[1] == "") { 37 | // Home / 38 | return { 39 | status: 200, 40 | data: { page: Page.Write } 41 | }; 42 | } 43 | 44 | return { 45 | status: 404, 46 | data: { page: Page.NotFound } 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import createStore, { Store } from "unistore"; 2 | import Hashids from "hashids"; 3 | import { Page } from "./router"; 4 | import { encrypt, decrypt } from "./lib/crypto"; 5 | import { writeVal } from "./lib/kv"; 6 | 7 | export interface SecretState { 8 | page: Page; 9 | pageId?: string; 10 | envelope?: MsgEnvelope; 11 | progress: number; 12 | saveError?: string; 13 | decrypted?: string; 14 | decryptError?: string; 15 | } 16 | 17 | export interface MsgEnvelope { 18 | created: number; 19 | encrypted: string; 20 | expires: number; 21 | id: string; 22 | } 23 | 24 | let store; 25 | export function initStore(state: Partial): Store { 26 | store = createStore({ 27 | page: Page.Write, 28 | progress: 0, 29 | ...state 30 | }); 31 | return store; 32 | } 33 | 34 | export interface MsgPayload { 35 | message: string; 36 | passphrase: string; 37 | ttlHours: number; 38 | } 39 | 40 | interface Actions { 41 | saveMessage: ( 42 | state: SecretState, 43 | payload: MsgPayload 44 | ) => Promise>; 45 | decryptMessage: ( 46 | state: SecretState, 47 | passphrase: string 48 | ) => Promise>; 49 | clearMessage: () => void; 50 | } 51 | 52 | const hashids = new Hashids(`${Math.random()}`); 53 | 54 | export function actions(store: Store): Actions { 55 | return { 56 | async saveMessage(state, { message, passphrase, ttlHours }) { 57 | store.setState({ progress: 0 }); 58 | const encrypted = await encrypt(message, passphrase, percent => 59 | store.setState({ progress: percent }) 60 | ); 61 | let expires = 0; 62 | if (ttlHours > 0) { 63 | const d = new Date(); 64 | d.setHours(d.getHours() + ttlHours); 65 | expires = d.getTime(); 66 | } 67 | const envelope = { 68 | created: Date.now(), 69 | encrypted, 70 | expires, 71 | id: hashids.encode(Date.now()) 72 | }; 73 | 74 | try { 75 | await writeVal(envelope.id, envelope); 76 | } catch (err) { 77 | console.error(err); 78 | return { 79 | saveError: 80 | "There was a problem saving your message, please try again." 81 | }; 82 | } 83 | return { envelope }; 84 | }, 85 | 86 | async decryptMessage(state, passphrase) { 87 | if (!state.envelope) { 88 | return { decrypted: undefined }; 89 | } 90 | store.setState({ progress: 0 }); 91 | try { 92 | const decrypted = await decrypt( 93 | state.envelope.encrypted, 94 | passphrase, 95 | percent => store.setState({ progress: percent }) 96 | ); 97 | return { decrypted, decryptError: undefined }; 98 | } catch (err) { 99 | console.error(err); 100 | return { decryptError: "Wrong passphrase" }; 101 | } 102 | }, 103 | 104 | clearMessage() { 105 | return { 106 | envelope: undefined, 107 | progress: 0, 108 | saveError: undefined 109 | }; 110 | } 111 | }; 112 | } 113 | -------------------------------------------------------------------------------- /src/worker.ts: -------------------------------------------------------------------------------- 1 | import { page } from "./page"; 2 | import { match } from "./lib/request-match"; 3 | import { serverRender } from "./app"; 4 | 5 | // Worker bindings defined in metadata via build or env vars 6 | /* eslint-disable @typescript-eslint/ban-types */ 7 | declare const JS_FILES: string | undefined; 8 | declare const CSS_FILES: string | undefined; 9 | declare const MSG_STORE: KeyValueStore | undefined; 10 | type ValidType = "text" | "json" | "arrayBuffer" | "stream"; 11 | export declare class KeyValueStore { 12 | public constructor(); 13 | public get( 14 | key: string, 15 | type?: ValidType 16 | ): Promise; 17 | public put( 18 | key: string, 19 | value: string | ReadableStream | ArrayBuffer | FormData 20 | ): Promise; 21 | public delete(key: string): Promise; 22 | } 23 | /* eslint-enable @typescript-eslint/ban-types */ 24 | 25 | // CF Worker's version of CacheStorage is a little different 26 | interface CfCacheStorage extends CacheStorage { 27 | default: CacheStorage; 28 | put: (request: Request, response: Response) => void; 29 | } 30 | 31 | // Handle all requests hitting the worker 32 | addEventListener("fetch", (e: Event) => { 33 | const fe = e as FetchEvent; 34 | fe.respondWith(handleFetch(fe.request)); 35 | }); 36 | 37 | async function handleFetch(request: Request): Promise { 38 | if (MSG_STORE == null) { 39 | return new Response("No KV store bound to worker", { status: 500 }); 40 | } 41 | 42 | // Check if request is for static asset. If so, send request on to origin, 43 | // then add a cache header to the response. 44 | const staticRoute = match(request, "get", "/assets/*"); 45 | if (staticRoute) { 46 | try { 47 | const assetRes = await fetch(request); 48 | const response = new Response(assetRes.body, assetRes); 49 | response.headers.set("cache-control", "public, max-age=31536000"); 50 | return response; 51 | } catch (err) { 52 | return errorResponse(err); 53 | } 54 | } 55 | 56 | // Check for favicon request and fetch from static assets 57 | const faviconRoute = match(request, "get", "/favicon.ico"); 58 | if (faviconRoute) { 59 | faviconRoute.url.pathname = "/assets/images/favicon.ico"; 60 | return fetch(faviconRoute.url.toString()); 61 | } 62 | 63 | // Check if saving message -- /save/:key 64 | const saveMsgRoute = match(request, "POST", "/save/:key"); 65 | if (saveMsgRoute) { 66 | try { 67 | const data = await request.text(); 68 | await MSG_STORE.put(saveMsgRoute.params.key, data); 69 | return new Response("OK"); 70 | } catch (err) { 71 | return new Response("Error in post data", { status: 400 }); 72 | } 73 | } 74 | 75 | // Render page 76 | try { 77 | const cache = (caches as CfCacheStorage).default; 78 | const cachedResponse = await cache.match(request); 79 | if (cachedResponse) { 80 | return cachedResponse; 81 | } 82 | 83 | let scripts; 84 | let stylesheets; 85 | const url = new URL(request.url); 86 | const { status, data, html } = await serverRender(url.pathname, MSG_STORE); 87 | if (JS_FILES) { 88 | scripts = JS_FILES.split(" "); 89 | } 90 | if (CSS_FILES) { 91 | stylesheets = CSS_FILES.split(" "); 92 | } 93 | 94 | const renderedPage = page({ 95 | title: "secretmsg", 96 | content: html, 97 | scripts, 98 | stylesheets, 99 | json: JSON.stringify(data) 100 | }); 101 | const response = new Response(renderedPage, { 102 | status, 103 | headers: { 104 | "content-type": "text/html; charset=utf-8" 105 | } 106 | }); 107 | (cache as CfCacheStorage).put(request, response.clone()); 108 | return response; 109 | } catch (err) { 110 | return errorResponse(err); 111 | } 112 | } 113 | 114 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 115 | function errorResponse(err: any): Response { 116 | return new Response(err.stack || err, { status: 500 }); 117 | } 118 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true, 5 | "jsx": "react", 6 | "jsxFactory": "h", 7 | "lib": ["es5", "es6", "es7", "dom", "webworker"], 8 | "module": "commonjs", 9 | "moduleResolution": "node", 10 | "noFallthroughCasesInSwitch": true, 11 | "noImplicitReturns": true, 12 | "noUnusedLocals": true, 13 | "pretty": true, 14 | "strict": true, 15 | "sourceMap": true, 16 | "target": "es5", 17 | "typeRoots": ["node_modules/@types", "src/@types"] 18 | }, 19 | "include": ["src/**/*"] 20 | } 21 | -------------------------------------------------------------------------------- /webpack.client.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 3 | 4 | module.exports = isProduction => ({ 5 | devtool: isProduction ? "source-map" : "eval", 6 | mode: isProduction ? "production" : "development", 7 | entry: { 8 | client: path.resolve(__dirname, "src/client.ts") 9 | }, 10 | output: { 11 | path: path.resolve(__dirname, "dist"), 12 | publicPath: "/assets/", 13 | filename: isProduction ? "js/[name].[chunkhash:10].js" : "js/[name].js" 14 | }, 15 | plugins: isProduction && [ 16 | new MiniCssExtractPlugin({ 17 | filename: "css/[name].[contenthash:10].css" 18 | }) 19 | ], 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.tsx?$/, 24 | include: [path.resolve(__dirname, "src")], 25 | use: ["ts-loader"] 26 | }, 27 | { 28 | test: /\.css$/, 29 | include: [path.resolve(__dirname, "src")], 30 | use: [ 31 | isProduction ? MiniCssExtractPlugin.loader : "style-loader", 32 | "css-loader" 33 | ] 34 | } 35 | ] 36 | }, 37 | resolve: { 38 | extensions: [".tsx", ".ts", ".js"] 39 | } 40 | }); 41 | -------------------------------------------------------------------------------- /webpack.worker.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = isProduction => ({ 4 | mode: isProduction ? "production" : "development", 5 | entry: { 6 | worker: path.resolve(__dirname, "src/worker.ts") 7 | }, 8 | output: { 9 | path: path.resolve(__dirname, "dist"), 10 | filename: "worker/[name].js" 11 | }, 12 | module: { 13 | rules: [ 14 | { 15 | test: /\.tsx?$/, 16 | include: [path.resolve(__dirname, "src")], 17 | use: ["ts-loader"] 18 | }, 19 | { 20 | test: /\.css$/, 21 | include: [path.resolve(__dirname, "src")], 22 | use: ["null-loader"] 23 | } 24 | ] 25 | }, 26 | resolve: { 27 | extensions: [".tsx", ".ts", ".js"] 28 | } 29 | }); 30 | --------------------------------------------------------------------------------
9 | If you're sure this link is correct, there's a chance this 10 | message has expired. 11 |
13 | Write new message → 14 |