├── .eslintrc.json ├── .gitignore ├── .glitch-assets ├── LICENSE ├── README.md ├── app.js ├── controllers ├── api.js ├── dashboard.js ├── embed.js ├── index.js └── subscriptions.js ├── favicon.ico ├── models ├── subscribers.js └── users.js ├── package-lock.json ├── package.json ├── public └── assets │ ├── css │ ├── custom.css │ ├── dashboard.css │ ├── embed.css │ ├── gettingstarted.css │ ├── normalize.css │ └── skeleton.css │ └── images │ ├── icon-github.svg │ ├── icon-glitch.svg │ ├── icon-twitter.svg │ ├── logo-s-overprint.svg │ ├── logo-s.svg │ ├── opengraph-substation.png │ ├── screenshot-1-login.png │ ├── screenshot-2-email.png │ ├── screenshot-3-dashboard.png │ ├── screenshot-4-compose.png │ ├── slashes-overprint.svg │ ├── slashes.svg │ └── wordmark-overprint.svg ├── shrinkwrap.yaml ├── utility ├── auth.js ├── database.js ├── messaging.js └── server.js └── views ├── dashboard.html ├── docs └── gettingstarted.html ├── email.html ├── embed.html ├── export.html ├── mailing.html ├── messages ├── login.html ├── unsubscribe.html └── welcome.html └── unsubscribe.html /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2020": true 5 | }, 6 | "extends": ["airbnb-base", "plugin:prettier/recommended"], 7 | "parserOptions": { 8 | "ecmaVersion": 11, 9 | "sourceType": "module" 10 | }, 11 | "plugins": ["prettier","html"], 12 | "rules": { 13 | "prettier/prettier": "error" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .data 3 | public/.dev/ 4 | .env 5 | node_modules/ 6 | config/*.pem -------------------------------------------------------------------------------- /.glitch-assets: -------------------------------------------------------------------------------- 1 | {"name":"drag-in-files.svg","date":"2016-10-22T16:17:49.954Z","url":"https://cdn.hyperdev.com/drag-in-files.svg","type":"image/svg","size":7646,"imageWidth":276,"imageHeight":276,"thumbnail":"https://cdn.hyperdev.com/drag-in-files.svg","thumbnailWidth":276,"thumbnailHeight":276,"dominantColor":"rgb(102, 153, 205)","uuid":"adSBq97hhhpFNUna"} 2 | {"name":"click-me.svg","date":"2016-10-23T16:17:49.954Z","url":"https://cdn.hyperdev.com/click-me.svg","type":"image/svg","size":7116,"imageWidth":276,"imageHeight":276,"thumbnail":"https://cdn.hyperdev.com/click-me.svg","thumbnailWidth":276,"thumbnailHeight":276,"dominantColor":"rgb(243, 185, 186)","uuid":"adSBq97hhhpFNUnb"} 3 | {"name":"paste-me.svg","date":"2016-10-24T16:17:49.954Z","url":"https://cdn.hyperdev.com/paste-me.svg","type":"image/svg","size":7242,"imageWidth":276,"imageHeight":276,"thumbnail":"https://cdn.hyperdev.com/paste-me.svg","thumbnailWidth":276,"thumbnailHeight":276,"dominantColor":"rgb(42, 179, 185)","uuid":"adSBq97hhhpFNUnc"} 4 | {"name":"1.jpg","date":"2019-05-31T14:54:36.460Z","url":"https://cdn.glitch.com/38a30f29-aa18-44b4-a077-cecc038350a9%2F1.jpg","type":"image/jpeg","size":83172,"imageWidth":1437,"imageHeight":792,"thumbnail":"https://cdn.glitch.com/38a30f29-aa18-44b4-a077-cecc038350a9%2Fthumbnails%2F1.jpg","thumbnailWidth":330,"thumbnailHeight":182,"uuid":"7oXCI053ou9PUzyl"} 5 | {"uuid":"7oXCI053ou9PUzyl","deleted":true} 6 | {"name":"substation-ico.png","date":"2019-07-23T17:57:04.840Z","url":"https://cdn.glitch.com/38a30f29-aa18-44b4-a077-cecc038350a9%2Fsubstation-ico.png","type":"image/png","size":20840,"imageWidth":276,"imageHeight":276,"thumbnail":"https://cdn.glitch.com/38a30f29-aa18-44b4-a077-cecc038350a9%2Fsubstation-ico.png","thumbnailWidth":276,"thumbnailHeight":276,"uuid":"SiglxM7idkL0gw1D"} 7 | {"name":"glitched-glitch.png","date":"2019-11-13T18:35:00.233Z","url":"https://cdn.glitch.com/38a30f29-aa18-44b4-a077-cecc038350a9%2Fglitched-glitch.png","type":"image/png","size":21337,"imageWidth":200,"imageHeight":200,"thumbnail":"https://cdn.glitch.com/38a30f29-aa18-44b4-a077-cecc038350a9%2Fglitched-glitch.png","thumbnailWidth":200,"thumbnailHeight":200,"uuid":"YO1g1eszQXudfqJ9"} 8 | {"name":"logo-bw-email.png","date":"2020-01-16T18:01:29.402Z","url":"https://cdn.glitch.com/9f35da85-6537-4c08-8c75-92364571d412%2Flogo-bw-email.png","type":"image/png","size":22016,"imageWidth":264,"imageHeight":264,"thumbnail":"https://cdn.glitch.com/9f35da85-6537-4c08-8c75-92364571d412%2Flogo-bw-email.png","thumbnailWidth":264,"thumbnailHeight":264,"uuid":"qi8EZ7fEpXwZzZBA"} 9 | {"name":"hero.jpg","date":"2020-02-05T17:03:42.054Z","url":"https://cdn.glitch.com/9f35da85-6537-4c08-8c75-92364571d412%2Fhero.jpg","type":"image/jpeg","size":44958,"imageWidth":800,"imageHeight":495,"thumbnail":"https://cdn.glitch.com/9f35da85-6537-4c08-8c75-92364571d412%2Fthumbnails%2Fhero.jpg","thumbnailWidth":330,"thumbnailHeight":205,"uuid":"XdJ0E3h4aA4czJ70"} 10 | {"uuid":"XdJ0E3h4aA4czJ70","deleted":true} 11 | {"name":"hero.jpg","date":"2020-02-05T17:09:36.463Z","url":"https://cdn.glitch.com/9f35da85-6537-4c08-8c75-92364571d412%2Fhero.jpg","type":"image/jpeg","size":39004,"imageWidth":760,"imageHeight":495,"thumbnail":"https://cdn.glitch.com/9f35da85-6537-4c08-8c75-92364571d412%2Fthumbnails%2Fhero.jpg","thumbnailWidth":330,"thumbnailHeight":215,"uuid":"2WjqtCseah40kFTD"} 12 | {"name":"github-logo.png","date":"2020-02-06T02:12:20.729Z","url":"https://cdn.glitch.com/9f35da85-6537-4c08-8c75-92364571d412%2Fgithub-logo.png","type":"image/png","size":3008,"imageWidth":120,"imageHeight":120,"thumbnail":"https://cdn.glitch.com/9f35da85-6537-4c08-8c75-92364571d412%2Fgithub-logo.png","thumbnailWidth":120,"thumbnailHeight":120,"uuid":"ilYKF5R9D4KsdhcR"} 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright ©2019 Jesse von Doom 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 4 | software and associated documentation files (the "Software"), to deal in the Software 5 | without restriction, including without limitation the rights to use, copy, modify, 6 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so, subject to the following 8 | conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all copies 11 | or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 14 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 15 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 16 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 17 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE 18 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Substation DIY 2 | ============== 3 | Substation lets you make a club or newsletter using your own accounts on services like Braintree and 4 | Mailgun. It's free, open-source, and easy to set up. 5 | 6 | Getting started 7 | --------------- 8 | Once you have your accounts at Braintree and Mailgun you just need to edit the `.env` file in your Glitch project. 9 | 10 | Here it is, step-by-step: 11 | 12 | - Add an admin email and set the title and main URL (your glitch URL) 13 | - `ADMIN_EMAIL=hello@substation.dev` 14 | - `TITLE=Substation` 15 | - `URL=https://substation.glitch.me/` 16 | - Set the security secret. This can be anything no one will guess. Mash the keyboard even! 17 | - `SECURITY_SECRET=what3verYouwant!keepitSECRE7` 18 | - Finally, you need to add information from Braintree and Mailgun. Both need API keys and 19 | secrets. Mailgun needs a 'FROM' email address, and Braintree needs you to set up a 20 | subscription and add its ID, set the environment as `Sandbox` or `Production`, 21 | and give a minimum amount for your monthly membership fee. 22 | 23 | Logging In 24 | ---------- 25 | The admin dashboard is located at your.site/dashboard — login there to see 26 | current subscriber stats, get your embed code, export a member list, or send email. 27 | 28 | Customizing the page and embed 29 | ------------------------------ 30 | You can add fully custom templates and CSS for the embed, emails, and the main index 31 | page. They each have corresponding files in the `/views` folder, but make a copy in your 32 | `/config` folder so you'll have all your changes in one place. Substation will use your 33 | files instead of the defaults, and later if you upgrade of move to a new webhost you 34 | only have to copy your `.env` file and the `/config` folder. 35 | 36 | Create a folder called `/config` at the top level of the app 37 | - For a custom homepage, create a file called `/config/views/index.html` — 38 | this will be shown instead of the default page 39 | - To add CSS create `/config/public/custom.css` — 40 | you can access this file as `/customcss` from the domain root to include it in an html file 41 | - You can also customize the email templates — 42 | - `/config/views/email.html` for the main email template 43 | - `/config/views/messages/login.html`, unsubscribe.html, and welcome.html 44 | (For these we suggest starting with a copy of the default templates 45 | that are located in the main /views folder) 46 | 47 | Project structure 48 | ----------------- 49 | Substation on Glitch is pretty minimal: a basic Node+Express setup, Braintree for payment 50 | processing and storing subscriber details, Mailgun for email, and our own Lodge UI kit 51 | for the overlays and checkout flow. 52 | 53 | - [Braintree](https://developers.braintreepayments.com/) 54 | - [Mailgun](https://documentation.mailgun.com/en/latest/) 55 | - [Lodge](https://lodge.substation.dev/) 56 | 57 | Most of the project settings are in the .env file — the idea being that anyone who wants to 58 | remix Substation needs only edit the .env with their own Braintree/Mailgun keys and a few 59 | details to get started. 60 | 61 | The package.json file sets up the server environment, installs NPM modules, etc. The actual 62 | server lives in server.js — a mostly simple setup and simple in structure. In time we probably 63 | want to refactor with a few external objects but as of now it's working as a single script. 64 | All views are kept in the views/ folder, while static public files are hosted in the public/ 65 | folder. (They resolve to root, so "public/sample.css" is referenced just as "/sample.css" in 66 | any view/HTML.) 67 | 68 | Lastly, the overlays all live as part of the Lodge library. They're made to be responsive and 69 | secure cross-domain, so ultimately we'll host stable versions of Lodge on a CDN. 70 | 71 | Updating 72 | -------- 73 | As long as you've used the config/ folder for all customizations, as covered in the Getting 74 | Started docs, you can easily update to the latest version of Substation on Glitch. Just click 75 | the "Tools ^" button in the lower left corner of the Glitch editor, now select "Git, Import, 76 | and Export", then "Import from Github", and use the repo "substation-me/substation-diy" when 77 | prompted. 78 | 79 | Collaborating on Glitch 80 | ----------------------- 81 | Glitch, while based on git, is different from a traditional git development environment. The 82 | editor works more like Google Docs. To contribute to the project, for now, you must be a member 83 | of the Substation team on Glitch. Any work done to the main project is saved and redeployed in 84 | real-time as we work on it. 85 | 86 | Nutshell: it'll take some getting used to, but it's great for a first sprint. We're working on 87 | setting up a github account for two-way sync and storing release candidates. 88 | 89 | Misc details: 90 | - Tools are a little hard to find, but look below the file list to the left. The "Tools ^" 91 | button is your friend. You'll find the server logs, debugger, and more 92 | - The << rewind button to the left of Tools will help you get back to a previous state 93 | if something goes wrong — literally browse through git commits 94 | - The server is in a constant state of commit-and-redeploy. It's weird at first, but just 95 | means that the test domain, substation.glitch.me is constantly at the latest code level 96 | - If you're working in the same file as someone else you won't overwrite their work unless 97 | you're on the same lines. Be good, talk on Slack, and all will be well 98 | 99 | Development 100 | ----------- 101 | The make development easier you can run substation DIY locally, but there are a few steps. 102 | First, you'll need a .env file set up and ready, with the values from the .env teamplate. With 103 | that in place, things will still break. You'll need to be running ssl on localhost. We've taken 104 | care of a lot of that, but you'll need to add a self-signed certificate and know a couple 105 | tricks on the command line. Assuming mac/linux, open up a shell in the repo and run: 106 | 107 | - `mkdir ./config` then `cd ./config` 108 | - `openssl req -x509 -newkey rsa:2048 -keyout tmp.pem -out cert.pem -days 365` 109 | - `openssl rsa -in tmp.pem -out key.pem` 110 | - `rm tmp.pem` 111 | 112 | Nutshell: we do a lot of work in the config/ folder. It's not tracked in the repo because 113 | everything in it is custom. If you have a config folder already you don't need to recreate it. 114 | Those two openssl commands create a self-signed ssl certificate substation can use to serve 115 | pages via https right away. Once those files are in place just head back to the main repo folder 116 | and type `npm run dev`. 117 | 118 | You'll get a warning error in your browser. Just tell it to trust you. If you're using Chrome 119 | you might want to try entering this in the url bar: `chrome://flags/#allow-insecure-localhost`. 120 | From there you can set Chrome to allow insecure content. It's not always enough, so if you still 121 | see a warning page saying ERR_CERT_INVALID you might need to do the silliest thing to get past 122 | the warning. I kid you not: click in the browser window, type "thisisunsafe" and hit enter. 123 | 124 | LOL yeah it's real. 125 | 126 | Credits 127 | ------- 128 | [@jessevondoom](https://twitter.com/jessevondoom), [@alanmoo](https://github.com/alanmoo), [@gmiddleb](https://github.com/gmiddleb) 129 | 130 | 131 | 132 | ¯\\_(ツ)_/¯ 133 | ----------- 134 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | /********************************************************************* 2 | * 3 | * SET UP OBJECTS AND INITIALIZE SERVER 4 | * All pretty standard. Set up our core objects. Initialize stuff 5 | * from the package.json file. Force SSL and other server environment 6 | * bits. Connect to Braintree. 7 | * 8 | *********************************************************************/ 9 | 10 | // load the .env variables if not found (aka: local development) 11 | if (!process.env.SECURITY_SECRET) { 12 | require('dotenv').config(); 13 | } 14 | 15 | // load server config and database initialization scripts 16 | var app = require('./utility/server.js'); 17 | var db = require('./utility/database.js'); 18 | 19 | // handle routing 20 | require('./controllers/index.js')(app,db); 21 | 22 | // start up the server — different from local to live on glitch, elsewhere 23 | if (process.argv.includes('dev')) { 24 | const https = require('https'); 25 | const fs = require('fs'); 26 | const key = fs.readFileSync('./config/key.pem'); 27 | const cert = fs.readFileSync('./config/cert.pem'); 28 | const ssl = https.createServer({key: key, cert: cert }, app); 29 | ssl.listen(process.env.PORT || 8080, () => 30 | console.log(`△△ substation: listening on port ${process.env.PORT || 8080} (ssl)`) 31 | ); 32 | } else { 33 | app.listen(process.env.PORT || 8080, () => 34 | console.log(`△△ substation: listening on port ${process.env.PORT || 8080}`) 35 | ); 36 | } -------------------------------------------------------------------------------- /controllers/api.js: -------------------------------------------------------------------------------- 1 | /******************************************************************** 2 | * 3 | * CONTROLLERS: api.js /// 4 | * 5 | * Handles the API interface, issuing only JSON responses. The API 6 | * itself is not complex, so in the interest of security each API 7 | * token only has a 5 minute lifetime. The tokens are formatted as 8 | * JSON web tokens with the expectation that a new token will be 9 | * requested for each new request. Those tokens are passed back to 10 | * the API in an x-access-token header which is verified by a 11 | * middleware which lives in auth.js 12 | * 13 | * The API itself serves a few core use cases around validating 14 | * users as being in good standing or initiating a new user login 15 | * with a special redirect parameter added to pass the login status 16 | * (with a verification step) on to an external client. 17 | * 18 | * Each API call includes a version number. These are not currently 19 | * doing anything, but will allow us to make changes down the road 20 | * while supporting old versions, etc. 21 | * 22 | *******************************************************************/ 23 | var auth = require(__dirname + "/../utility/auth.js"); 24 | var jwt = require('jsonwebtoken'); 25 | 26 | module.exports = function(app, db) { 27 | /****** ROUTE: /metadata (GET) ***********************************/ 28 | // A basic route that gives the title of this Substation instance 29 | // and parrots the version number requested in the URI. 30 | app.get("/api/v:version/metadata", auth.validateAPIToken, function(request, response) { 31 | response.status(200).send({ owner: process.env.TITLE, version: request.params.version }); 32 | }); 33 | 34 | /****** ROUTE: /token (GET) **************************************/ 35 | // Takes an email and secret FROM A SECURE CLIENT — do not use 36 | // this in a front-end script as it will reveal your password and 37 | // make you have to change your security secret and generally be 38 | // really dumb and bad. 39 | // 40 | // The JSON response will attach the token to its token parameter 41 | // (Duh.) and the client should then pass it as the value of the 42 | // x-access-token HTTP header as it makes a full API request. 43 | // 44 | // Note: this route DOES NOT use the token validation middleware 45 | // because there is no token yet. Obvs. 46 | app.get("/api/v:version/token", function(request, response) { 47 | var users = require(__dirname + "/../models/users.js"); 48 | if(!request.query.email || !request.query.secret || !users.isAdmin(request.query.email)) { 49 | response.status(401).send({ auth: false, message: 'Unauthorized.' }); 50 | } else { 51 | var secrets = auth.getAPISecrets(); 52 | var secret = secrets[request.query.email]; 53 | console.log(request.query.secret); 54 | if (request.query.secret !== secret) { 55 | response.status(401).send({ auth: false, message: 'Unauthorized.' }); 56 | } else { 57 | var token = jwt.sign({"email":request.query.email}, process.env.SECURITY_SECRET, { 58 | expiresIn: 300 // expires in 5 minutes 59 | }); 60 | response.status(200).send({"auth":true,"token": token}); 61 | } 62 | } 63 | }); 64 | 65 | /****** ROUTE: /login (GET) **************************************/ 66 | // Takes an email and secret FROM A SECURE CLIENT — do not use 67 | // this in a front-end script as it will reveal your password and 68 | // make you have to change your security secret and generally be 69 | // really dumb and bad. 70 | app.get("/api/v:version/login", auth.validateAPIToken, function(request, response) { 71 | if(!request.query.email || !request.query.redirect) { 72 | response.status(400).send({message: 'Bad request.'}); 73 | } else { 74 | /* 75 | FULL LOGIN FLOW (ISH) - DOCUMENT THIS SHIT BETTER 76 | 1. API client requests token, uses it to request a login for a member 77 | 2. The /login endpoint takes member email, a login message to be included 78 | in the login email, and a redirect URL back to teh API client 79 | 3. The login email is sent out as usual, but with an extra parameter on 80 | the button URL that includes the redirect URL from the API client 81 | 4. The API authorizes the login, and if good generates a second nonce for the 82 | email address, then passes both the email address and the nonce to 83 | the redirect URL specified in the original call 84 | 5. The API client can then use it's API secret to request a new 85 | API token, trades the email and nonce back to the API server for 86 | confirmation of successful login 87 | */ 88 | var subscribers = require(__dirname + "/../models/subscribers.js"); 89 | 90 | // first we check to ensure the subscriber is in good standing 91 | subscribers.getStatus(request.query.email,function(err, member) { 92 | if (err) { 93 | response.status(500).send({active: false, message: 'Error retreiving member data.'}); 94 | } else { 95 | if (!member) { 96 | // send a nope 97 | response.status(401).send({message: 'Unauthorized.',login:false}); 98 | } else { 99 | var mailer = require(__dirname + "/../utility/messaging.js"); 100 | var url = require('url'); 101 | var redirectURL = new URL(request.query.redirect); 102 | mailer.sendMessage( 103 | app, 104 | request.query.email, 105 | "Log in to " + process.env.TITLE, 106 | "Just click to login. You will be redirected to " + redirectURL.hostname + " after your login is complete.", 107 | "login", 108 | "Log in now", 109 | process.env.URL + "api/v" + request.params.version + "/login/finalize", 110 | encodeURI(request.query.redirect) 111 | ); 112 | response.status(200).send({loginRequested:true}); 113 | } 114 | } 115 | }); 116 | } 117 | }); 118 | 119 | /****** ROUTE: /login/finalize (GET) *****************************/ 120 | // The finalize route looks for an email and a nonce sent to the 121 | // user and included in a confirmation link in that email. The 122 | // user is prompted to log in and told they will be redirected 123 | // to an external site as set by the client upon login request. 124 | // 125 | // If the email and nonce validate properly then the user will 126 | // be redirected with their email and a new verification nonce to 127 | // the redirect URL, which will then verify that email+nonce 128 | // combination. If they do not validate 129 | // 130 | // Note: this route DOES NOT use the token validation middleware 131 | // because it is a return from an in-email link. 132 | app.get("/api/v:version/login/finalize", function(request, response) { 133 | if(!request.query.email || !request.query.nonce || !request.query.redirect) { 134 | response.status(400).send({message: 'Bad request.'}); 135 | } else { 136 | auth.validateNonce( 137 | request.query.email, 138 | request.query.nonce, 139 | function(err, data) { 140 | if (data) { 141 | // TODO: CENTRALIZE NONCE GEN IN auth.js 142 | // we're generating a second nonce to use as verification of this request 143 | // the client will get the email/nonce and a true result from /api/verify 144 | // will verify that it was a true request processed by substation 145 | var { v1: uuidv1 } = require('uuid'); 146 | var db = require(__dirname + "/../utility/database.js"); 147 | // quickly generate a nonce 148 | var nonce = uuidv1(); 149 | // assume we need the return, so store it in db 150 | db.serialize(function() { 151 | db.run( 152 | 'INSERT INTO Nonces (email, nonce) VALUES ("' + 153 | request.query.email + 154 | '","' + 155 | nonce + 156 | '")' 157 | ); 158 | }); 159 | // pass email and nonce for verification 160 | response.redirect(request.query.redirect + '?substation-email=' + request.query.email + '&substation-nonce=' + nonce); 161 | } else { 162 | // pass just an email on failure. without the nonce verification 163 | // will fail but the client will still know that the API->email->client-site 164 | // journey has been completed 165 | response.redirect(request.query.redirect + '?substation-email=' + request.query.email); 166 | } 167 | } 168 | ); 169 | } 170 | }); 171 | 172 | /****** ROUTE: /login/verify (GET) *******************************/ 173 | // This simple method will take the email address and nonce passed 174 | // to the redirect URL a client sets when asking for the initial 175 | // login requets. If the email+nonce matches we know the request 176 | // was valid/secure as sent in the API->email->client-site 177 | // journey. Without this additional step we'd be asking API 178 | // clients to blindly accept easily spoofed querystrings. Using a 179 | // nonce allows us an extra layer of confidence in this status. 180 | // 181 | // Returns a boolean "login" value for the given email address. 182 | app.get("/api/v:version/login/verify", auth.validateAPIToken, function(request, response) { 183 | if(!request.query.email || !request.query.nonce) { 184 | response.status(400).send({message: 'Bad request.'}); 185 | } else { 186 | auth.validateNonce( 187 | request.query.email, 188 | request.query.nonce, 189 | function(err, data) { 190 | if (data) { 191 | response.status(200).send({message: 'Success.',login:true}); 192 | } else { 193 | response.status(401).send({message: 'Unauthorized.',login:false}); 194 | } 195 | } 196 | ); 197 | } 198 | }); 199 | 200 | /****** ROUTE: /login/member (GET) *******************************/ 201 | // Gets member data/status for a given member (email address.) 202 | // This will return nothing but an "active: false" if the user 203 | // is no longer active/in good standing, but if the member is 204 | // active it will give firstName, lastName, email, and active 205 | app.get("/api/v:version/member", auth.validateAPIToken, function(request, response) { 206 | var subscribers = require(__dirname + "/../models/subscribers.js"); 207 | 208 | if(!request.query.email) { 209 | response.status(400).send({message: 'Bad request.'}); 210 | } else { 211 | // the "true" for getStatus() is an object with first name, last name, 212 | // active status, and the vendor subscription id 213 | subscribers.getStatus(request.query.email,function(err, member) { 214 | if (err) { 215 | response.status(500).send({active: false, message: 'Error retreiving member data.'}); 216 | } else { 217 | if (!member) { 218 | // member is not active, so we don't send anything over API 219 | response.status(200).send({active:false}); 220 | } else { 221 | // active is set to true for any members 222 | // we're sending all basic data in this reponse, but only for active members 223 | response.status(200).send(member); 224 | } 225 | } 226 | }); 227 | } 228 | }); 229 | 230 | }; 231 | -------------------------------------------------------------------------------- /controllers/dashboard.js: -------------------------------------------------------------------------------- 1 | /******************************************************************** 2 | * 3 | * CONTROLLERS: dashboard.js /// 4 | * 5 | * This controller handles all of routes for the dashboard and all 6 | * admin functionality. This includes real-time stats, the mailings 7 | * interface, and member list exports. 8 | * 9 | *******************************************************************/ 10 | 11 | module.exports = function(app, db) { 12 | /****** ROUTE: /dashboard (GET) **********************************/ 13 | // This route does the main admin page, which has a few moving 14 | // parts. It requires a logged-in user so there's that gateway, 15 | // then also we need to collect stats after a user completes a 16 | // login loop. So there are a few layers. 17 | app.get("/dashboard", function(request, response) { 18 | var users = require(__dirname + "/../models/users.js"); 19 | request.details = { 20 | copy: { 21 | title: process.env.TITLE, 22 | description: process.env.DESCRIPTION 23 | }, 24 | substationURL: process.env.URL 25 | }; 26 | request.details.showadmin = false; 27 | 28 | // this is some clumsy bullshit to cache the totals we get from 29 | // the subscribers model. it's...not great but it works 30 | var timestamp = Date.now(); 31 | var pollTotals = true; 32 | if (request.session.subtotals) { 33 | if (request.session.subtotals.time + 1200000 > timestamp) { 34 | request.details.subscription = request.session.subtotals; 35 | var pollTotals = false; 36 | } 37 | } 38 | 39 | // now let's check for admin login status 40 | if (request.session.administrator) { 41 | request.details.showadmin = true; 42 | // this is repeated below. TODO: refactor so it's not dumb. 43 | if (pollTotals) { 44 | var subscribers = require(__dirname + "/../models/subscribers.js"); 45 | subscribers.getTotals(function(err, sub) { 46 | if (sub) { 47 | sub.time = timestamp; 48 | request.details.subscription = sub; 49 | request.session.subtotals = sub; 50 | } 51 | response.render("dashboard", request.details); 52 | }); 53 | } else { 54 | response.render("dashboard", request.details); 55 | } 56 | } else { 57 | // not currently logged in as an admin, so check for form actions 58 | // first make sure the email is set and has admin permissions 59 | if (request.query.email && users.isAdmin(request.query.email)) { 60 | // if the nonce is set we're returning from a login email 61 | if (request.query.nonce) { 62 | //console.log("admin login attempt: " + request.query.nonce); 63 | var auth = require(__dirname + "/../utility/auth.js"); 64 | // check the one-time nonce against the email it was sent to 65 | auth.validateNonce( 66 | request.query.email, 67 | request.query.nonce, 68 | function(err, data) { 69 | if (data) { 70 | request.session.administrator = true; 71 | request.details.showadmin = true; 72 | } 73 | request.details.showadmin = true; 74 | // this is repeated above. TODO: refactor so it's not dumb. 75 | if (pollTotals) { 76 | var subscribers = require(__dirname + "/../models/subscribers.js"); 77 | subscribers.getTotals(function(err, sub) { 78 | if (sub) { 79 | sub.time = timestamp; 80 | request.details.subscription = sub; 81 | request.session.subtotals = sub; 82 | } 83 | response.render("dashboard", request.details); 84 | }); 85 | } else { 86 | response.render("dashboard", request.details); 87 | } 88 | } 89 | ); 90 | } else { 91 | // there's no nonce present, which means this is a request 92 | // for a new one to be sent to the admin 93 | var mailer = require(__dirname + "/../utility/messaging.js"); 94 | 95 | // way back up in this block's if statement we check to make 96 | // sure the email has admin permissions. since we're here, we 97 | // know it does, so we hand it all over to the messaging 98 | // script to create, store, and send out the nonce. 99 | mailer.sendMessage( 100 | app, 101 | request.query.email, 102 | "Log in to " + process.env.TITLE, 103 | "Just click to login.", 104 | "login", 105 | "Log in now", 106 | process.env.URL + "dashboard" 107 | ); 108 | 109 | request.details.postsend = true; 110 | response.render("dashboard", request.details); 111 | } 112 | } else { 113 | response.render("dashboard", request.details); 114 | } 115 | } 116 | }); 117 | 118 | /****** ROUTE: /export *******************************************/ 119 | // Pulls a list of all current subscribers and exports a CSV with 120 | // first name, last name, and email addresses for everyone. 121 | app.get("/export", function(request, response) { 122 | request.details = { 123 | copy: { 124 | title: process.env.TITLE, 125 | description: process.env.DESCRIPTION 126 | } 127 | }; 128 | 129 | if (request.session.administrator) { 130 | // a quick call to the Braintree API to get active members 131 | // (non-canceled/expired users in good standing.) 132 | var subscribers = require(__dirname + "/../models/subscribers.js"); 133 | subscribers.getActive(function(err, subs) { 134 | if (err) { 135 | console.log("export error"); 136 | request.details.error = "Braintree error message: " + err.message; 137 | response.render("dashboard", request.details); 138 | } else { 139 | // generate the CSV and set the appropriate headers 140 | console.log("export download initiated"); 141 | response.setHeader("Content-Type", "text/csv"); 142 | response.setHeader( 143 | "Content-Disposition", 144 | 'attachment; filename="' + "download-" + Date.now() + '.csv"' 145 | ); 146 | request.details.users = subs; 147 | // note that this maps to /views/export.html —> which is 148 | // pretty dumb since it's CSV but the rendering engine picks 149 | // it up automatically with the html extension so here we 150 | // are. edit that to edit the CSV. [DEAL_WITH_IT.GIF] 151 | response.render("export", request.details); 152 | } 153 | }); 154 | } else { 155 | console.log("export error — not logged in"); 156 | response.render("dashboard", request.details); 157 | } 158 | }); 159 | 160 | /****** ROUTE: /mailing (GET) ************************************/ 161 | // Renders the mailing form in the admin 162 | app.get("/mailing", function(request, response) { 163 | var users = require(__dirname + "/../models/users.js"); 164 | request.details = { 165 | copy: { 166 | title: process.env.TITLE, 167 | description: process.env.DESCRIPTION 168 | }, 169 | scriptNonce: app.scriptNonce 170 | }; 171 | // this one feels like it should be complicated but there's 172 | // not much to do in showing the basic composer form 173 | request.details.showadmin = false; 174 | if (request.session.administrator) { 175 | request.details.showadmin = true; 176 | response.render("mailing", request.details); 177 | } else { 178 | response.render("mailing", request.details); 179 | } 180 | }); 181 | 182 | /****** ROUTE: /mailing (POST) ***********************************/ 183 | // POST endpoint for sending mass mailings (and tests) 184 | app.post("/mailing", function(request, response) { 185 | if (request.session.administrator) { 186 | if (request.body.subject && request.body.contents) { 187 | var sendToAll = false; 188 | if (!request.body.sending) { 189 | request.body.subject += ' [TEST]'; 190 | } else { 191 | var sendToAll = true; 192 | } 193 | 194 | // we have all the data/content we need so we call in the 195 | // messaging script to start the mailing 196 | var messaging = require(__dirname + "/../utility/messaging.js"); 197 | 198 | // this passes the job on to messaging for a mass-mail send 199 | messaging.sendMailing( 200 | app, 201 | request.body.subject, 202 | request.body.contents, 203 | request.body.sending 204 | ); 205 | 206 | response.sendStatus(200); 207 | } else { 208 | response.sendStatus(404); 209 | } 210 | } else { 211 | response.sendStatus(403); 212 | } 213 | }); 214 | }; 215 | -------------------------------------------------------------------------------- /controllers/embed.js: -------------------------------------------------------------------------------- 1 | /******************************************************************** 2 | * 3 | * CONTROLLERS: embed.js /// 4 | * 5 | * Just the one endpoint for now, this controller renders the 6 | * interface for the sign-up/payment/cancel widget. Call this 7 | * endpoint and it renders out the HTML ready for an iframe, and 8 | * including lodge embed code — so that iframe will resize 9 | * dynamically inside of a container on the embedding page if it 10 | * uses lodge too. 11 | * 12 | *******************************************************************/ 13 | 14 | module.exports = function (app, db) { 15 | /****** ROUTE: /embed ********************************************/ 16 | app.get("/embed", function(request, response) { 17 | // set up braintree token for frontend javascript 18 | var braintree = require("braintree"); 19 | var gateway = braintree.connect({ 20 | environment: braintree.Environment[process.env.BRAINTREE_ENVIRONMENT], 21 | merchantId: process.env.BRAINTREE_MERCHANT_ID, 22 | publicKey: process.env.BRAINTREE_PUBLIC_KEY, 23 | privateKey: process.env.BRAINTREE_PRIVATE_KEY 24 | }); 25 | // get a token, wait, then render the page 26 | gateway.clientToken.generate({ version: 3 }, function(err, res) { 27 | if (err) { 28 | console.log(err); 29 | } else { 30 | // got the token so now we just need to set a few parameters 31 | 32 | var sandboxed = false; // default to live environment 33 | if (process.env.BRAINTREE_ENVIRONMENT == "Sandbox") { 34 | // sandbox has been set to true in the .env file so 35 | // we set it true here 36 | sandboxed = true; 37 | } 38 | // the rest is just filling in details for the view 39 | request.details = { 40 | braintree: { 41 | clientToken: res.clientToken, 42 | planId: process.env.BRAINTREE_PLAN_ID, 43 | minimumCost: process.env.BRAINTREE_MINIMUM_COST 44 | }, 45 | copy: { 46 | title: process.env.TITLE 47 | }, 48 | scriptNonce: app.scriptNonce, 49 | sandboxed: sandboxed 50 | }; 51 | } 52 | response.render("embed", request.details); 53 | }); 54 | }); 55 | } -------------------------------------------------------------------------------- /controllers/index.js: -------------------------------------------------------------------------------- 1 | /******************************************************************** 2 | * 3 | * CONTROLLERS: index.js /// 4 | * 5 | * This is the main controller file called in app.js. It includes 6 | * the other controllers and handles the main index file as well 7 | * as the /cusomcss route that forwards user-created CSS from 8 | * the optional config directory. 9 | * 10 | *******************************************************************/ 11 | 12 | // we're going to need this later to detect the optional files 13 | var fs = require('fs'); 14 | 15 | module.exports = function (app, db) { 16 | require(__dirname + '/api.js')(app,db); 17 | require(__dirname + '/dashboard.js')(app,db); 18 | require(__dirname + '/embed.js')(app,db); 19 | require(__dirname + '/subscriptions.js')(app,db); 20 | 21 | /****** ROUTE: / *************************************************/ 22 | // The main page. Will show user customized pages if present at 23 | // /config/views/index.html and if not it will render the default 24 | // page found at /views/index.html 25 | app.get(["/","/docs/gettingstarted"], function(request, response) { 26 | request.details = { 27 | substationURL: process.env.URL, 28 | copy: { 29 | title: process.env.TITLE 30 | } 31 | }; 32 | 33 | var file = __dirname + '/../config/views/index.html'; 34 | 35 | // we're looking for the /config/views/index.html file — if 36 | // it's present we use that as our main view, if not we fall 37 | // back to the default index.html in the /views folder 38 | try { 39 | if (fs.existsSync(file) && request.originalUrl == '/') { 40 | response.render(file, request.details); 41 | } else { 42 | response.render("docs/gettingstarted", request.details); 43 | } 44 | } catch(err) { 45 | console.error(err); 46 | response.render("docs/gettingstarted", request.details); 47 | } 48 | }); 49 | 50 | /****** ROUTE: /customcss *****************************************/ 51 | // Relays the contents of /config/public/css/custom.css (or blank 52 | // if the file is not present.) This allows a user to place CSS 53 | // outsite of the app folders and in the /config folder like the 54 | // custom homepage. Ultimately they just need to include a style 55 | // tag pointed at the /customcss endpoint and their file will show. 56 | app.get("/customcss", function(request, response) { 57 | var file = __dirname + '/../config/public/css/custom.css'; 58 | 59 | // similar to the above, we're looking for the custom.css file 60 | // in the user's /config/public/css folder. if present we 61 | // stream the contents, if not we return an empty file. 62 | try { 63 | if (fs.existsSync(file)) { 64 | var stat = fs.statSync(file); 65 | 66 | response.writeHead(200, { 67 | 'Content-Type': 'text/css', 68 | 'Content-Length': stat.size 69 | }); 70 | 71 | var readStream = fs.createReadStream(file); 72 | readStream.pipe(response); 73 | } else { 74 | response.writeHead(200, { 75 | 'Content-Type': 'text/css' 76 | }); 77 | response.end(); 78 | } 79 | } catch(err) { 80 | console.error(err); 81 | response.end(); 82 | } 83 | }); 84 | } -------------------------------------------------------------------------------- /controllers/subscriptions.js: -------------------------------------------------------------------------------- 1 | /******************************************************************** 2 | * 3 | * CONTROLLERS: subscriptions.js /// 4 | * 5 | * Handles the subscribe and unsubsribe pages, and will expand to 6 | * deal with any more complex subscription management pages. 7 | * 8 | *******************************************************************/ 9 | 10 | module.exports = function (app, db) { 11 | /****** ROUTE: /unsubscribe (POST) *******************************/ 12 | // The POST route for unsubscribe handles the form that's hosted 13 | // on the GET veresion of the route. Basically sends out an 14 | // unsubscribe nonce to the email address as requested. 15 | app.post("/unsubscribe", function(request, response) { 16 | // mostly relies on the messaging script to handle the heavy 17 | // lifting, then renders a "goodbye and check your inbox." 18 | var mailer = require(__dirname + "/../utility/messaging.js"); 19 | mailer.sendMessage( 20 | app, 21 | request.body.email, 22 | "Cancel " + process.env.TITLE + " membership", 23 | "We're sorry to see you go.", 24 | "unsubscribe", 25 | "Cancel payments", 26 | process.env.URL + "unsubscribe" 27 | ); 28 | 29 | request.details = { 30 | copy: { 31 | title: process.env.TITLE, 32 | description: process.env.DESCRIPTION 33 | }, 34 | showgoodbye: true, 35 | justrequested: true 36 | }; 37 | response.render("unsubscribe", request.details); 38 | }); 39 | 40 | /****** ROUTE: /unsubscribe (GET) ********************************/ 41 | // The GET /unsubscribe route does double duty in presenting the 42 | // actual ubsubscribe form as well as handling the return trip 43 | // from a sent-out nonce. 44 | app.get("/unsubscribe", function(request, response) { 45 | // set up variables we'll use when we're done here 46 | request.details = { 47 | copy: { 48 | title: process.env.TITLE, 49 | description: process.env.DESCRIPTION 50 | }, 51 | showgoodbye: false 52 | }; 53 | 54 | // do we have an email and a nonce? GO TIME 55 | if (request.query.nonce && request.query.email) { 56 | // first validate the email and nonce combo 57 | var auth = require(__dirname + "/../utility/auth.js"); 58 | auth.validateNonce(request.query.email, request.query.nonce, function( 59 | err, 60 | data 61 | ) { 62 | // if data = true then do unsubscribe 63 | if (data) { 64 | // call in the subscribers script to handle the Braintree API 65 | var subscribers = require(__dirname + "/../models/subscribers.js"); 66 | subscribers.remove(request.query.email, function(err, status) { 67 | if (err) { 68 | // error defaults to true in the view — no additional details needed 69 | console.log("Braintree error: " + err); 70 | response.render("unsubscribe", request.details); 71 | } else { 72 | request.details.showgoodbye = true; 73 | response.render("unsubscribe", request.details); 74 | } 75 | }); 76 | } else { 77 | // data (nonce validation) came back false, so... oops? 78 | console.log("Email address and nonce not a valid combination."); 79 | response.render("unsubscribe", request.details); 80 | } 81 | }); 82 | } else { 83 | console.log("Email address and/or nonce not set in url."); 84 | response.render("unsubscribe", request.details); 85 | } 86 | }); 87 | 88 | /****** ROUTE: /subscribe (POST) *********************************/ 89 | app.post("/subscribe", function(request, response) { 90 | if (request.body.email) { 91 | // most of the real work happens in the subscribers script 92 | var subscribers = require(__dirname + "/../models/subscribers.js"); 93 | // this looks light on security, but the important detail here 94 | // is that the nonce in question below isn't one of ours, but 95 | // the one generated by Braintree after processing the secure 96 | // checkout form. if that doesn't match Braintree's records 97 | // then this fails quickly (and harmlessly) inside of the 98 | // subscribers.add() call. 99 | subscribers.add( 100 | request.body.email, 101 | request.body.firstName, 102 | request.body.lastName, 103 | request.body.nonce, 104 | request.body.amount, 105 | function(err, status) { 106 | if (err) { 107 | // sends a 402 (or other error) from the add function 108 | response.sendStatus(err); 109 | } else { 110 | // it worked so we send a welcome message 111 | var mailer = require(__dirname + "/../utility/messaging.js"); 112 | mailer.sendMessage( 113 | app, 114 | request.body.email, 115 | "Welcome to " + process.env.TITLE, 116 | "Hi there.", 117 | "welcome" 118 | ); 119 | // sends 200 or current status 120 | response.sendStatus(status); 121 | } 122 | } 123 | ); 124 | } else { 125 | response.sendStatus(404); 126 | } 127 | }); 128 | }; 129 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/substationHQ/substation-diy/85ce06d5f3fbda8c5915a89c47c320faea030a32/favicon.ico -------------------------------------------------------------------------------- /models/subscribers.js: -------------------------------------------------------------------------------- 1 | /******************************************************************** 2 | * 3 | * MODELS: subscribers.js /// 4 | * 5 | * Substation's DIY core is (mostly) database-less. Subscriber 6 | * information is all stored in Braintree's secure PCI vault. This 7 | * leads to security and portability, but also makes for a funny 8 | * data model in general. 9 | * 10 | * The subscribers model deals with the Braintree API calls to 11 | * add and remove members, as well as pulling broad membership info. 12 | * 13 | *******************************************************************/ 14 | 15 | // set up the Braintree environment 16 | var braintree = require("braintree"); 17 | var gateway = braintree.connect({ 18 | environment: braintree.Environment[process.env.BRAINTREE_ENVIRONMENT], 19 | merchantId: process.env.BRAINTREE_MERCHANT_ID, 20 | publicKey: process.env.BRAINTREE_PUBLIC_KEY, 21 | privateKey: process.env.BRAINTREE_PRIVATE_KEY 22 | }); 23 | 24 | 25 | /****** FUNCTION: subscribers.add() *********************************/ 26 | // Adds a new member, expecting the following inputs: 27 | // (string) email 28 | // (string) firstName 29 | // (string) lastName 30 | // (string) nonce [obtained from Braintree checkout form] 31 | // (string) amount [monthly amount paid] 32 | // (function) callback(err,result) 33 | module.exports.add = function( 34 | email, 35 | firstName, 36 | lastName, 37 | nonce, 38 | amount, 39 | callback 40 | ) { 41 | var result = gateway.customer.search(function(search) { 42 | search.email().is(email); 43 | }); 44 | if (result.success) { 45 | // customer already exists. we should check if they're active, 46 | // and if not then we sign them up for a new sub at the rate 47 | // they selected. if they are...do nothing? 48 | // 49 | // DATA MODEL (FROM API) REMINDER: 50 | // Customer.creditCards 51 | // - each.subscriptions 52 | // - each.planId 53 | // - each.status ("Active") 54 | // Customer.paypalAccounts[each] 55 | // - each.subscriptions 56 | // - each.planId 57 | // - each.status ("Active") 58 | } else { 59 | var finalPrice = process.env.BRAINTREE_MINIMUM_COST; 60 | // make sure it's at least the defined minimum 61 | if (amount > finalPrice) { 62 | finalPrice = amount; 63 | } 64 | // add the user 65 | gateway.customer.create( 66 | { 67 | firstName: firstName, 68 | lastName: lastName, 69 | email: email, 70 | paymentMethodNonce: nonce, 71 | creditCard: { 72 | options: { 73 | verifyCard: true 74 | } 75 | } 76 | }, 77 | // define callback 78 | function(err, res) { 79 | if (res.success) { 80 | // res.customer.id; // e.g 160923 81 | gateway.subscription.create( 82 | { 83 | paymentMethodToken: res.customer.paymentMethods[0].token, 84 | planId: process.env.BRAINTREE_PLAN_ID, 85 | price: finalPrice 86 | }, 87 | function(e, r) { 88 | // this is where we find out if it worked and send a response 89 | // console.log(JSON.stringify(r)); 90 | callback(null, 200); 91 | } 92 | ); 93 | } else { 94 | // Card processing failed. Send a PaymentRequired 402 95 | // TODO: better error handling between validation and form 96 | // SAMPLE ERROR: 97 | /* 98 | ErrorResponse { 99 | errors: ValidationErrorsCollection { 100 | validationErrors: {}, 101 | errorCollections: { customer: [ValidationErrorsCollection] } 102 | }, 103 | params: { 104 | customer: { 105 | firstName: 'Jesse', 106 | lastName: 'von Doom', 107 | email: 'jessevondoom heresanothertest@gmail.com', 108 | paymentMethodNonce: 'tokencc_bc_yw3dp2_yff7gj_y3sbmy_77kstx_7z4', 109 | creditCard: [Object] 110 | } 111 | }, 112 | message: 'Email is an invalid format.', 113 | success: false 114 | } 115 | */ 116 | console.log(res); 117 | // 402 "Payment required" 118 | callback(402, null); 119 | } 120 | } 121 | ); 122 | } 123 | }; 124 | 125 | /****** FUNCTION: subscribers.getTotals() ***************************/ 126 | // Gets total member count and upcoming monthly total subscription 127 | // values. The only parameter is its callback. 128 | // (function) callback(err,result) 129 | module.exports.getTotals = function(callback) { 130 | var stream = gateway.subscription.search( 131 | // first we look for all active members by plan ID 132 | function(search) { 133 | search.planId().is(process.env.BRAINTREE_PLAN_ID); 134 | search.status().is("Active"); 135 | }, 136 | function(err, subs) { 137 | if (err) { 138 | callback(err.message, null); 139 | } else { 140 | // set up our totals for counting 141 | var totals = { 142 | "value": 0, 143 | "members":0 144 | }; 145 | var len = subs.length(); 146 | if (len === 0) { 147 | callback(null,totals); 148 | } else { 149 | // we need this count to know where we are in the stream — 150 | // feels a little janky but less janky than moving to an 151 | // async/await structure 152 | var count = 0; 153 | subs.each(function(err, subscription) { 154 | // loop all viable entries. the check for a blank email is 155 | // likely unnecessary, but a few blanks snuck in during 156 | // testing so it clearly can't hurt. we've added some 157 | // checks when setting things up, but left in the check 158 | // against blank email addresses here because who cares. 159 | if (subscription.transactions[0].customer.email !== "") { 160 | totals.members = totals.members+1; 161 | totals.value = Math.round(totals.value + parseFloat(subscription.nextBillingPeriodAmount)); 162 | } 163 | count++; 164 | // check if we've read the whole stream. if so, output the CSV 165 | if (count == len) { 166 | callback(null,totals); 167 | } 168 | }); 169 | } 170 | } 171 | } 172 | ); 173 | } 174 | 175 | /****** FUNCTION: subscribers.getActive() ***************************/ 176 | // Gets all active members from the Braintree API with a callback. 177 | // (function) callback(err,result) 178 | module.exports.getActive = function(callback) { 179 | // set up a return array of members 180 | var users = [ 181 | { 182 | firstName: "firstName", 183 | lastName: "lastName", 184 | email: "email" 185 | } 186 | ]; 187 | // request all the active users (all paid up and current — this will 188 | // include users who have canceled but are paid up until the end of 189 | // the current pay period) 190 | var stream = gateway.subscription.search( 191 | function(search) { 192 | search.planId().is(process.env.BRAINTREE_PLAN_ID); 193 | search.status().is("Active"); 194 | }, 195 | function(err, subs) { 196 | if (err) { 197 | callback(err.message, null); 198 | } else { 199 | // we found some active members — set up our count so we can 200 | // navigate the stream and output when finished. 201 | var len = subs.length(); 202 | var count = 0; 203 | subs.each(function(err, subscription) { 204 | if (subscription.transactions[0].customer.email !== "") { 205 | // looks like a viable member — push their first name, 206 | // last name, and email address into the return array 207 | users.push({ 208 | firstName: subscription.transactions[0].customer.firstName, 209 | lastName: subscription.transactions[0].customer.lastName, 210 | email: subscription.transactions[0].customer.email 211 | }); 212 | } 213 | count++; 214 | // check if we've read the whole stream. if so, callback 215 | if (count == len) { 216 | if (count > 0) { 217 | callback(null, users); 218 | } else { 219 | callback("No active subscribers found.", null); 220 | } 221 | } 222 | }); 223 | } 224 | } 225 | ); 226 | }; 227 | 228 | /****** FUNCTION: subscribers.remove() ******************************/ 229 | // Cancels a member subscription. The member remains active until 230 | // their current pay period ends. Expects email and callback. 231 | // (string) email 232 | // (function) callback(err,result) 233 | module.exports.remove = function(email, callback) { 234 | 235 | // here we call the local getStatus function to see if the email 236 | // is currently associated with an active subscriber 237 | module.exports.getStatus(email,function(err, member) { 238 | if (err) { 239 | callback(err, null); 240 | } else { 241 | if (member) { 242 | // the "sub" if true is the subscription id for 243 | // the specific user subsription relationship 244 | gateway.subscription.cancel(member.vendorSubID, function( 245 | err, 246 | result 247 | ) { 248 | if (err) { 249 | // dang. return the error 250 | callback(err, null); 251 | } else { 252 | // it was a success! 253 | callback(null, result); 254 | } 255 | }); 256 | } else { 257 | // not an active subscriber, but the goal was to remove 258 | // the member from the list so we return true as in 259 | // "this member is not a part of the subscription" 260 | // by leaving a message this state can be explicitly 261 | // checked for should that be needed. 262 | callback(null, "Member not associated with plan."); 263 | } 264 | } 265 | }); 266 | }; 267 | 268 | /****** FUNCTION: subscribers.getStatus() ****************************/ 269 | // Checks to see if an email is associated with an active/in good 270 | // standing member. If not it will only return a false active 271 | // parameter. If the member is active it will return true, as well 272 | // as give other information like firstName, lastName, and email. 273 | // (string) email 274 | // (function) callback(err,result) 275 | module.exports.getStatus = function(email, callback) { 276 | // FWIW: can't get the customers as an iterable array/object 277 | // so we wind up in this nested loopsy upside-down stream place. 278 | // Technically we're fine. There should only be ONE match for 279 | // any given email, but that doesn't feel like a solid or stable 280 | // thing to build on. For now it's what we've got. 281 | // 282 | // We currently iterate through every sub for each customer match, 283 | // then deal with cancelations at the end to avoid race conditions 284 | // on a per-user basis. 285 | var stream = gateway.customer.search( 286 | function(search) { 287 | search.email().is(email); 288 | }, 289 | function(err, customers) { 290 | if (err) { 291 | callback(err, null); 292 | } else { 293 | if (customers.length() < 1) { 294 | // return no error, but null data to show no customers 295 | callback(null, null); 296 | } else { 297 | customers.each(function(err, customer) { 298 | // TODO: this weird "each" construction is necessary because of 299 | // the Braintree API, but will throw an error if two customers 300 | // exist with the same email address. Should only occur in 301 | // error conditions so not fixing at present, but early 302 | // Substation development did create multiple customers with 303 | // the same email address. Worth looking at a long-term fix. 304 | if (err) { 305 | callback(err, null); 306 | } else { 307 | // we found at least one subscription, but we don't know yet if 308 | // any are active. if the nonce is valid and we made it this far 309 | // it's safe to assume the user is trying to cancel their 310 | // subscription, even if they already have, so we show them 311 | // success that they are indeed unsubscribed no matter what. 312 | 313 | // loooooooooooooops (credit cards) — subscriptions are actually 314 | // stored per card in Braintree so we gotta keep digging... 315 | for ( 316 | var ii = 0, len = customer.creditCards.length; 317 | ii < len; 318 | ii++ 319 | ) { 320 | var card = customer.creditCards[ii]; 321 | var active = false; 322 | // more loooooooooooooooooooooooops (subscriptions) 323 | for ( 324 | var iii = 0, le = card.subscriptions.length; 325 | iii < le; 326 | iii++ 327 | ) { 328 | var subscription = card.subscriptions[iii]; 329 | // sweet lord we can finally check to make sure the sub is 330 | // active and the plan matches the plan in the substation .env 331 | if ( 332 | subscription.status == "Active" && 333 | subscription.planId == process.env.BRAINTREE_PLAN_ID 334 | ) { 335 | // if true, return the member's subscription ID as true 336 | callback(null, { 337 | firstName: customer.firstName, 338 | lastName: customer.lastName, 339 | vendorSubID: subscription.id, 340 | active: true 341 | }); 342 | active = true; 343 | } 344 | } 345 | if (!active) { 346 | // return no error, but give false status — this shows 347 | // we found a user, but they are not subscribed to the 348 | // current plan 349 | callback(null, false); 350 | } 351 | } 352 | } 353 | }); 354 | } 355 | } 356 | } 357 | ); 358 | }; 359 | -------------------------------------------------------------------------------- /models/users.js: -------------------------------------------------------------------------------- 1 | /******************************************************************** 2 | * 3 | * MODELS: users.js /// 4 | * 5 | * This is a bit more simple than the subscribers model, because 6 | * we're dealing with a far smaller range of options. 7 | * 8 | * The user model deals with admin users of the substation app, as 9 | * opposed to members of the club/subscription 10 | * 11 | *******************************************************************/ 12 | 13 | 14 | /****** FUNCTION: users.getAdminUsers() ****************************/ 15 | // Parses the .env file and returns an array of one or more email 16 | // addresses that are valid admin users. There's nothing async here 17 | // so no callback, just a simple return. 18 | // 19 | // Potential .env admin formats: 20 | // ADMIN_EMAIL=person@domain.com 21 | // ADMIN_EMAIL=$'["person@domain.com","anotherperson@domain.com"]' 22 | // 23 | // For those unfamiliar with .env files, the plain text (no quotes) 24 | // string up top is a basic string that can work for a single admin 25 | // email. To add multiple admin users we need to pop in some JSON, 26 | // which means adding a string literal that can include quotes. 27 | // For that we use the $'' format, hence the difference in format. 28 | module.exports.getAdminUsers = function() { 29 | var admin = process.env.ADMIN_EMAIL; 30 | if (admin.charAt(0) != '"' && admin.charAt(0) != '[') { 31 | // this is just a basic string, so we wrap it in quotes so it 32 | // will parse as valid JSON 33 | admin = '"' + admin + '"'; 34 | } 35 | try { 36 | // parse the admin string as JSON 37 | admin = JSON.parse(admin); 38 | } catch(err) { 39 | console.error(err) 40 | } 41 | // parsed okay — now we can check if it's a string or an array 42 | if (typeof admin === 'string') { 43 | // single user string, so we throw it into a single element 44 | // array for consistency in the return 45 | return [admin]; 46 | } else { 47 | // multiple users — just return the array 48 | return admin; 49 | } 50 | } 51 | 52 | /****** FUNCTION: users.isAdmin() **********************************/ 53 | // Checks an email address against the defined admin user(s) and 54 | // validates that the email should have admin priviledges. 55 | // 56 | // Nothing asynchronous about this function so it's a basic return 57 | // function that expects only one parameter: 58 | // (string) email 59 | module.exports.isAdmin = function(email) { 60 | var admin = module.exports.getAdminUsers(); 61 | if (admin.includes(email)) { 62 | return true; 63 | } else { 64 | return false; 65 | } 66 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "//1": "describes your app and its dependencies", 3 | "//2": "https://docs.npmjs.com/files/package.json", 4 | "//3": "updating this file will download and update your packages", 5 | "name": "substation", 6 | "version": "0.1.1", 7 | "description": "A simple subscription app that uses Braintree + Mailgun to create fan club subscriptions.", 8 | "main": "app.js", 9 | "scripts": { 10 | "start": "node app.js", 11 | "dev": "node app.js dev" 12 | }, 13 | "dependencies": { 14 | "body-parser": "^1.19.0", 15 | "braintree": "^2.21.0", 16 | "cors": "^2.8.5", 17 | "dotenv": "^8.2.0", 18 | "express": "^4.17.1", 19 | "express-csp-header": "^2.3.2", 20 | "express-session": "^1.16.1", 21 | "form-data": "^3.0.0", 22 | "helmet": "^3.21.3", 23 | "html-to-text": "^5.1.1", 24 | "jsdom": "^16.1.1", 25 | "jsonwebtoken": "^8.5.1", 26 | "mailgun-js": "^0.22.0", 27 | "mustache-express": "^1.3.0", 28 | "session-file-store": "^1.4.0", 29 | "sqlite3": "^4.1.1", 30 | "uuid": "^7.0.2" 31 | }, 32 | "devDependencies": { 33 | "eslint": "^7.4.0", 34 | "eslint-config-airbnb-base": "^14.2.0", 35 | "eslint-config-prettier": "^6.11.0", 36 | "eslint-plugin-html": "^6.1.1", 37 | "eslint-plugin-import": "^2.22.0", 38 | "eslint-plugin-prettier": "^3.1.4", 39 | "prettier": "^2.0.5" 40 | }, 41 | "engines": { 42 | "node": "12.13.1" 43 | }, 44 | "repository": { 45 | "url": "https://glitch.com/edit/#!/substation" 46 | }, 47 | "license": "MIT", 48 | "keywords": [ 49 | "node", 50 | "express", 51 | "subscription", 52 | "braintree" 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /public/assets/css/custom.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Arvo|Quicksand&display=swap'); 2 | 3 | /* styles */ 4 | /* called by your view template */ 5 | html, body { 6 | min-height:100%; 7 | } 8 | 9 | /* apply a natural box layout model to all elements, but allowing components to change */ 10 | body { 11 | height:100%; 12 | background-color:rgb(255, 255, 255); 13 | font-size: 16px; 14 | font-family:'Arvo','helvetica neue',helvetica,arial,sans-serif; 15 | } 16 | 17 | code.embed { 18 | display: block; 19 | overflow: auto; 20 | } 21 | 22 | samp { 23 | padding: 0 0.25em 0 0.25em; 24 | } 25 | 26 | h1,h2,h3,h4 { 27 | font-family:'Quicksand','helvetica neue',helvetica,arial,sans-serif; 28 | color:#000; 29 | } 30 | 31 | h1 { 32 | font-size:3.5em; 33 | font-weight:bold; 34 | margin-bottom:0.25em; 35 | } 36 | 37 | h2 { 38 | font-size: 1.75em; 39 | font-weight: bold; 40 | } 41 | 42 | h3 { 43 | font-size:2em; 44 | font-weight:bold; 45 | } 46 | 47 | h4 { 48 | font-size: 1em; 49 | font-weight: bold; 50 | margin-bottom: 0.125em; 51 | text-transform:uppercase; 52 | } 53 | 54 | ol, ul { 55 | margin: 0; 56 | padding: 1em; 57 | list-style-position: outside; 58 | } 59 | 60 | span.logo { 61 | position:relative; 62 | top:-0.15em; 63 | font-size: 0.75em; 64 | letter-spacing: -0.1em; 65 | text-shadow: 1px 0 0 currentColor; 66 | } 67 | 68 | span.diy { 69 | color:#1EAEDB; 70 | } 71 | 72 | div.section.more div.seven.columns { 73 | padding-top: 36px; 74 | padding-bottom: 24px; 75 | } 76 | 77 | div.row.padded { 78 | padding-top:3em; 79 | } 80 | 81 | .small { 82 | font-size:0.8em; 83 | } 84 | 85 | .spacer { 86 | height:1px; 87 | overflow: visible; 88 | } 89 | 90 | /* unsubscribe */ 91 | 92 | #unsubscribe-form-wrapper { 93 | margin-left: auto; 94 | margin-right: auto; 95 | } 96 | 97 | #unsubscribeForm { 98 | display: flex; 99 | justify-content: space-between 100 | } 101 | 102 | #unsubscribeForm input { 103 | height: 48px; 104 | } 105 | 106 | #unsubscribeForm input[type=submit] { 107 | width:40% !important; 108 | } 109 | 110 | #unsubscribeForm input[type=email] { 111 | color:#222; 112 | width:57% !important; 113 | } 114 | 115 | 116 | /* form elements */ 117 | 118 | form { 119 | margin-bottom: 25px; 120 | padding: 15px 0 15px 0; 121 | display: inline-block; 122 | font-weight:200; 123 | width: 100%; 124 | } 125 | 126 | a.button, button, input[type=submit], input[type=button] { 127 | font-family:'Quicksand','helvetica neue',helvetica,arial,sans-serif; 128 | font-size: 12px; 129 | line-height:16px; 130 | background-color: mediumseagreen; 131 | width:100%; 132 | border: none; 133 | padding: 16px; 134 | height: 48px; 135 | color: #fff; 136 | text-transform:uppercase; 137 | cursor: pointer; 138 | } 139 | 140 | input[type=number]::-webkit-inner-spin-button, 141 | input[type=number]::-webkit-outer-spin-button { 142 | -webkit-appearance: none; 143 | margin: 0; 144 | } 145 | 146 | a.button:hover, button:hover, input[type=submit]:hover, input[type=button]:hover { 147 | color:black; 148 | background-color: rgb(235, 219, 0); 149 | } 150 | 151 | button:disabled, input[type=button]:disabled { 152 | background-color: #666; 153 | color: #999; 154 | } 155 | 156 | 157 | /* body */ 158 | .hero { 159 | padding: 4em 0 0 0; 160 | background-image: none; 161 | } 162 | 163 | #intro { 164 | padding:3.5em 0 3.5em 0; 165 | font-size:1.25em; 166 | } 167 | 168 | /* footer */ 169 | .footer { 170 | font-size:0.8em; 171 | padding: 6% 0 3% 0; 172 | } 173 | 174 | .corner { 175 | padding-top:3em; 176 | font-size:1.2em; 177 | text-align:right; 178 | } 179 | 180 | /*subscription widget*/ 181 | iframe.vv-embed { 182 | box-shadow: -2px 2px 4px rgba(0,0,0,0.3); 183 | border-radius: 9px; 184 | } 185 | 186 | 187 | /* Smaller than 635 */ 188 | @media (max-width: 635px) { 189 | 190 | #body-copy { 191 | padding:12em 0 1.5em 0; 192 | } 193 | } 194 | 195 | 196 | @media (min-width:636px) and (max-width:960px) { 197 | 198 | #body-copy { 199 | padding:5em 0 1.5em 0; 200 | } 201 | 202 | } 203 | 204 | 205 | /* overlays and checkout */ 206 | .substation__loading { 207 | position:absolute; 208 | top:50%; 209 | left:50%; 210 | margin-left:-27px; 211 | margin-top:-24px; 212 | width: 55px; 213 | height:48px; 214 | } -------------------------------------------------------------------------------- /public/assets/css/dashboard.css: -------------------------------------------------------------------------------- 1 | /* admin dashboard */ 2 | #admin-login input[type=email] { 3 | width: 100%; 4 | height: 48px; 5 | } 6 | 7 | h2 a { 8 | text-decoration: none; 9 | } 10 | 11 | div.section.more { 12 | background-color: #fff; 13 | min-height: 20em; 14 | } 15 | 16 | .hero { 17 | padding: 4em 0 6em 0; 18 | background-image: none; 19 | } 20 | 21 | span.diy { 22 | color:#1EAEDB; 23 | } 24 | 25 | /* body */ 26 | #body-copy { 27 | padding:2em 0 1.5em 0; 28 | color:#000; 29 | font-size:1.125em; 30 | } 31 | 32 | #body-copy a { 33 | text-decoration: none; 34 | } 35 | 36 | #body-copy li { 37 | display: inline-block; 38 | margin-right:1.5em; 39 | margin-left:-1.5em; 40 | } 41 | 42 | #body-copy li span { 43 | display: inline-block; 44 | position: relative; 45 | top: 9px; 46 | margin-right: 0.5em; 47 | width: 24px; 48 | height: 24px; 49 | background-size: cover; 50 | border-radius: 12px; 51 | } 52 | 53 | code { 54 | display: block; 55 | overflow-y: hidden; 56 | overflow-x: scroll; 57 | } 58 | 59 | /* admin dashboard */ 60 | #admin-login input[type=email] { 61 | width: 100%; 62 | height: 48px; 63 | } 64 | 65 | /* email composer (quill) PLUS other form elements */ 66 | .ql-editor {min-height:16em;} 67 | .ql-toolbar {border-radius:4px 4px 0 0;} 68 | #subject {width:100%;} 69 | #mailing-editor {border-radius:0 0 4px 4px;background-color:#fff;} -------------------------------------------------------------------------------- /public/assets/css/embed.css: -------------------------------------------------------------------------------- 1 | /*** Fonts, typography, resets, etc ***************/ 2 | @import url('https://fonts.googleapis.com/css2?family=Quicksand:wght@400&family=Zilla+Slab:wght@300;700&display=swap'); 3 | 4 | /* apply a natural box layout model to all elements, but allowing components to change */ 5 | body { 6 | background-color:transparent; 7 | font-size: 16px; 8 | font-family: "Zilla Slab","Roboto Slab",serif; 9 | } 10 | 11 | h1,h2,h3,h4 { 12 | font-family:'Quicksand','helvetica neue',helvetica,arial,sans-serif; 13 | color:#000; 14 | } 15 | a { 16 | color: #cff0ff; 17 | } 18 | a:hover { 19 | color: #00aeff; 20 | } 21 | 22 | /* form elements */ 23 | 24 | form { 25 | margin-bottom: 25px; 26 | padding: 15px 0 15px 0; 27 | display: inline-block; 28 | font-weight:200; 29 | width: 100%; 30 | } 31 | 32 | /* 33 | input[type=text] { 34 | font-family: system-ui; 35 | font-weight: 400; 36 | padding: 12px; 37 | font-size: 16px; 38 | line-height: 24px; 39 | color: rgb(40, 44, 55); 40 | } 41 | */ 42 | 43 | a.button, button, input[type=submit], input[type=button] { 44 | font-family:Helvetica,Arial,sans-serif; 45 | font-weight: bold; 46 | font-size: 0.65em; 47 | line-height:16px; 48 | background-color: #00a500; 49 | width:100%; 50 | border: none; 51 | padding: 16px; 52 | height: 48px; 53 | color: #fff; 54 | text-transform:uppercase; 55 | cursor: pointer; 56 | } 57 | 58 | input[type=number]::-webkit-inner-spin-button, 59 | input[type=number]::-webkit-outer-spin-button { 60 | -webkit-appearance: none; 61 | margin: 0; 62 | } 63 | 64 | a.button:hover, button:hover, input[type=submit]:hover, input[type=button]:hover { 65 | color:black; 66 | background-color: #ffff00; 67 | } 68 | 69 | button:disabled, input[type=button]:disabled { 70 | background-color: #666; 71 | color: #999; 72 | } 73 | 74 | 75 | /*subscription widget*/ 76 | .container.vv-embed { 77 | width:100%; 78 | padding:0; 79 | } 80 | 81 | #widget-copy { 82 | position: relative; 83 | top:-11em; 84 | right:-1em; 85 | text-align:right; 86 | } 87 | 88 | .subscription-widget { 89 | font-family: "Zilla Slab","Roboto Slab",serif; 90 | background-color:#fff; 91 | margin-bottom:0; 92 | border-radius: 9px; 93 | overflow:hidden; 94 | } 95 | 96 | .checkout-button { 97 | display: flex; 98 | padding: 16px; 99 | position:relative; 100 | justify-content:space-between; 101 | } 102 | 103 | .checkout-button input { 104 | line-height:16px; 105 | min-width:7em; 106 | flex-basis:40%; 107 | padding:1rem 1rem 1rem 2rem; 108 | height:48px; 109 | font-family: monospace; 110 | position:relative; 111 | color:#111; 112 | margin-bottom:0; 113 | } 114 | .checkout-button::before { 115 | content:'$'; 116 | font-family: monospace; 117 | display:block; 118 | height: 48px; 119 | z-index:10; 120 | color: #666; 121 | line-height:16px; 122 | padding:16px 0 0; 123 | background:transparent; 124 | position: absolute; 125 | top:16px; 126 | left:1.5em; 127 | } 128 | 129 | .checkout-button button { 130 | height:48px; 131 | flex-basis:55%; 132 | transition:all 0.2s ease; 133 | margin-bottom:0; 134 | } 135 | .checkout-button input:focus { 136 | box-shadow: 0 0 8px rgba(100,193,235,0.5); 137 | } 138 | 139 | .fine-print, .sandbox-mode { 140 | background: #333; 141 | padding: 11px; 142 | font-size: 13px; 143 | line-height: 14px; 144 | color:#aaa; 145 | } 146 | 147 | .sandbox-mode a { 148 | color: #ec0000; 149 | } 150 | .sandbox-mode a:hover { 151 | color: #00aeff; 152 | } 153 | 154 | .sandbox-mode { 155 | background: #ffff00; 156 | background-image: repeating-linear-gradient(-45deg, transparent, transparent 15px, rgba(235,219,0.5) 15px, rgba(235,219,0.5) 30px); 157 | padding: 8px 16px; 158 | color:#222; 159 | } 160 | 161 | .checkout-button input { 162 | flex-basis:40%; 163 | } 164 | 165 | .checkout-button button { 166 | flex-basis:55%; 167 | } -------------------------------------------------------------------------------- /public/assets/css/gettingstarted.css: -------------------------------------------------------------------------------- 1 | /*** Fonts, typography, resets, etc ***************/ 2 | @import url('https://fonts.googleapis.com/css2?family=Quicksand:wght@400;600&family=Zilla+Slab:wght@300;700&display=swap'); 3 | 4 | *, *::before, *::after{ 5 | box-sizing: border-box; 6 | } 7 | 8 | :root{ 9 | font-size: 18px; 10 | line-height: 1.5em; 11 | } 12 | body{ 13 | -webkit-font-smoothing: subpixel-antialiased; 14 | font-family: "Zilla Slab","Roboto Slab",serif; 15 | font-weight: 300; 16 | } 17 | main { 18 | color:#222; 19 | position: relative; 20 | padding: 0 .5em; 21 | } 22 | 23 | h1,h2,h3,h4,h5,h6{ 24 | color:#000; 25 | font-family: Quicksand; 26 | font-weight: 400; 27 | } 28 | h1{ 29 | font-size: 5em; 30 | line-height: 1em; 31 | margin-bottom: 0; 32 | } 33 | h2 { 34 | font-size: 1.65em; 35 | line-height: 1.125em; 36 | } 37 | code, samp { 38 | font-size: 0.85em; 39 | color: #00AEFF; 40 | } 41 | tt { 42 | display:block; 43 | font-size: 0.85em; 44 | color: #bbb; 45 | line-height: 1.5em; 46 | } 47 | a { 48 | color: #000; 49 | text-decoration: none; 50 | -webkit-transition: box-shadow 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000), color 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000); 51 | -moz-transition: box-shadow 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000), color 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000); 52 | -o-transition: box-shadow 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000), color 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000); 53 | transition: box-shadow 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000), color 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000); /* easeInOutQuart */ 54 | box-shadow: inset 0 -0.07em 0 #ec008c; 55 | } 56 | a:hover { 57 | box-shadow: inset 0 -0.85em 0 #ffff00; 58 | color: #000; 59 | } 60 | p, li { 61 | font-size: 1.125em; 62 | } 63 | p.tight {margin-bottom: 0.25em;} 64 | li li { 65 | font-size: inherit; 66 | } 67 | 68 | /*** Top half: intro, background ***************/ 69 | .background-wrapper{ 70 | position: absolute; 71 | top: 0; 72 | left: 0; 73 | width: 100%; 74 | height: 80em; 75 | overflow: hidden; 76 | background:center 26em url(../images/logo-s-overprint.svg) no-repeat; 77 | background-size: auto 33em; 78 | } 79 | .intro{ 80 | max-width: 32em; 81 | margin: 0 auto 13em; 82 | padding-top: max(8vh, 4em); 83 | } 84 | .logo-substation { 85 | position: relative; 86 | } 87 | .logo-substation::before { 88 | content: ''; 89 | background-image: url('/assets/images/logo-s.svg'); 90 | display: inline-block; 91 | background-repeat: no-repeat; 92 | height: 0.8em; 93 | width: 0.65em; 94 | position: relative; 95 | top: 0.125em; 96 | } 97 | 98 | .logo-substation span { 99 | color:#00a500; 100 | } 101 | 102 | .icon-paragraph { 103 | position: relative; 104 | } 105 | 106 | .icon-paragraph::before { 107 | content: ''; 108 | display: block; 109 | background-repeat: no-repeat; 110 | height: 4em; 111 | width: 4em; 112 | position: absolute; 113 | left: -4em; 114 | } 115 | 116 | /*** Bottom half: Embed section, docs, etc. ***************/ 117 | .content { 118 | width: 100%; 119 | padding: 0 1em; 120 | max-width: 60em; 121 | } 122 | .split-content{ 123 | width: 100%; 124 | display: flex; 125 | flex-wrap: wrap; 126 | max-width: 1000px; 127 | margin: 1em auto; 128 | } 129 | .split-content section { 130 | padding: 1em; 131 | margin: 0; 132 | flex: 1; 133 | min-width: 300px; 134 | } 135 | .alt-section { 136 | color: white; 137 | position: relative; 138 | padding: 0.25em 4vw 0.25em 4vw; 139 | margin: 0 -0.5em 2em -0.5em; 140 | background-color: black; 141 | } 142 | .alt-section h1,.alt-section h2,.alt-section h3,.alt-section h4,.alt-section h5,.alt-section h6{ 143 | margin: 0; 144 | color:#fff; 145 | } 146 | .docs { 147 | padding: 8em 0; 148 | max-width: 42em; 149 | margin: 0 auto; 150 | } 151 | .docs .interstitial { 152 | width: 100%; 153 | text-align: center; 154 | padding: 3.5em 0 3em 0; 155 | } 156 | .icon-service { 157 | display: block; 158 | width: 2.5em; 159 | height: auto; 160 | border: 0; 161 | float: left; 162 | } 163 | .interstitial a { 164 | box-shadow: none; 165 | } 166 | .interstitial a:hover { 167 | box-shadow: none; 168 | } 169 | .annotated-link { 170 | display: inline-block; 171 | } 172 | .annotated-link a, .annotated-link h4 { 173 | color: #666; 174 | } 175 | .annotated-link h4 { 176 | margin: 4px 0 0 0; 177 | font-weight: 600; 178 | } 179 | .annotated-link.glitch h4 { 180 | margin: -1px 0 0 0; 181 | } 182 | .annotated-link.github h4 { 183 | margin: 3px 0 0 0; 184 | } 185 | .annotation { 186 | display:inline-block; 187 | font-size: 0.9em; 188 | line-height: 1.25em; 189 | text-align: left; 190 | padding-left: 0.5em; 191 | } 192 | code.embed { 193 | display: block; 194 | width:100%; 195 | overflow: hidden; 196 | font-size: 0.70em; 197 | white-space: nowrap; 198 | padding:1.25em; 199 | border-radius: 9px; 200 | background-color: #cff0ff; 201 | } 202 | .screenshots { 203 | width: 100%; 204 | display: flex; 205 | justify-content: space-between; 206 | flex-wrap: wrap; 207 | } 208 | .screenshots img { 209 | outline: 1px solid #ffff00; 210 | display: inline-block; 211 | flex: 1; 212 | max-width: 48%; 213 | margin-bottom: 1.5em; 214 | } 215 | 216 | /*** Footer, overprint slashes, corner slashes ***************/ 217 | .slashes { 218 | position: absolute; 219 | width: 1.5em; 220 | height: auto; 221 | right: 2em; 222 | bottom: 2em; 223 | } 224 | footer { 225 | padding-top: 13em; 226 | position: relative; 227 | } 228 | footer::before{ 229 | content:''; 230 | position: absolute; 231 | top: 0; 232 | width: 88%; 233 | height: 13em; 234 | display: block; 235 | background-image: url('/assets/images/slashes-overprint.svg'); 236 | background-repeat: no-repeat; 237 | background-position: center 0; 238 | background-size: 36em; 239 | } 240 | .footer-logo-wrap{ 241 | background: black; 242 | text-align: center; 243 | padding: 1em 0; 244 | } 245 | .footer-logo { 246 | display: block; 247 | margin: 0 auto; 248 | line-height: 1; 249 | } 250 | 251 | /*** Media queries, mostly tweaks for mobile ***************/ 252 | @media only screen and (max-device-width: 1024px) { 253 | h1{ 254 | font-size: 3.5em; 255 | white-space: nowrap; 256 | } 257 | .intro{ 258 | padding-top: 1.5em; 259 | } 260 | .intro h2 { 261 | font-size: 1.5em; 262 | margin-bottom: 2em; 263 | } 264 | .intro p { 265 | font-size: 1.25em; 266 | } 267 | .background-wrapper{ 268 | background-size: auto 43em; 269 | } 270 | .screenshots img { 271 | max-width: 100%; 272 | } 273 | } 274 | @media only screen and (max-width: 740px) { 275 | footer::before{ 276 | width:100%; 277 | } 278 | } -------------------------------------------------------------------------------- /public/assets/css/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v3.0.2 | MIT License | git.io/normalize */ 2 | 3 | /** 4 | * 1. Set default font family to sans-serif. 5 | * 2. Prevent iOS text size adjust after orientation change, without disabling 6 | * user zoom. 7 | */ 8 | 9 | html { 10 | font-family: sans-serif; /* 1 */ 11 | -ms-text-size-adjust: 100%; /* 2 */ 12 | -webkit-text-size-adjust: 100%; /* 2 */ 13 | } 14 | 15 | /** 16 | * Remove default margin. 17 | */ 18 | 19 | body { 20 | margin: 0; 21 | } 22 | 23 | /* HTML5 display definitions 24 | ========================================================================== */ 25 | 26 | /** 27 | * Correct `block` display not defined for any HTML5 element in IE 8/9. 28 | * Correct `block` display not defined for `details` or `summary` in IE 10/11 29 | * and Firefox. 30 | * Correct `block` display not defined for `main` in IE 11. 31 | */ 32 | 33 | article, 34 | aside, 35 | details, 36 | figcaption, 37 | figure, 38 | footer, 39 | header, 40 | hgroup, 41 | main, 42 | menu, 43 | nav, 44 | section, 45 | summary { 46 | display: block; 47 | } 48 | 49 | /** 50 | * 1. Correct `inline-block` display not defined in IE 8/9. 51 | * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. 52 | */ 53 | 54 | audio, 55 | canvas, 56 | progress, 57 | video { 58 | display: inline-block; /* 1 */ 59 | vertical-align: baseline; /* 2 */ 60 | } 61 | 62 | /** 63 | * Prevent modern browsers from displaying `audio` without controls. 64 | * Remove excess height in iOS 5 devices. 65 | */ 66 | 67 | audio:not([controls]) { 68 | display: none; 69 | height: 0; 70 | } 71 | 72 | /** 73 | * Address `[hidden]` styling not present in IE 8/9/10. 74 | * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. 75 | */ 76 | 77 | [hidden], 78 | template { 79 | display: none; 80 | } 81 | 82 | /* Links 83 | ========================================================================== */ 84 | 85 | /** 86 | * Remove the gray background color from active links in IE 10. 87 | */ 88 | 89 | a { 90 | background-color: transparent; 91 | } 92 | 93 | /** 94 | * Improve readability when focused and also mouse hovered in all browsers. 95 | */ 96 | 97 | a:active, 98 | a:hover { 99 | outline: 0; 100 | } 101 | 102 | /* Text-level semantics 103 | ========================================================================== */ 104 | 105 | /** 106 | * Address styling not present in IE 8/9/10/11, Safari, and Chrome. 107 | */ 108 | 109 | abbr[title] { 110 | border-bottom: 1px dotted; 111 | } 112 | 113 | /** 114 | * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. 115 | */ 116 | 117 | b, 118 | strong { 119 | font-weight: bold; 120 | } 121 | 122 | /** 123 | * Address styling not present in Safari and Chrome. 124 | */ 125 | 126 | dfn { 127 | font-style: italic; 128 | } 129 | 130 | /** 131 | * Address variable `h1` font-size and margin within `section` and `article` 132 | * contexts in Firefox 4+, Safari, and Chrome. 133 | */ 134 | 135 | h1 { 136 | font-size: 2em; 137 | margin: 0.67em 0; 138 | } 139 | 140 | /** 141 | * Address styling not present in IE 8/9. 142 | */ 143 | 144 | mark { 145 | background: #ff0; 146 | color: #000; 147 | } 148 | 149 | /** 150 | * Address inconsistent and variable font size in all browsers. 151 | */ 152 | 153 | small { 154 | font-size: 80%; 155 | } 156 | 157 | /** 158 | * Prevent `sub` and `sup` affecting `line-height` in all browsers. 159 | */ 160 | 161 | sub, 162 | sup { 163 | font-size: 75%; 164 | line-height: 0; 165 | position: relative; 166 | vertical-align: baseline; 167 | } 168 | 169 | sup { 170 | top: -0.5em; 171 | } 172 | 173 | sub { 174 | bottom: -0.25em; 175 | } 176 | 177 | /* Embedded content 178 | ========================================================================== */ 179 | 180 | /** 181 | * Remove border when inside `a` element in IE 8/9/10. 182 | */ 183 | 184 | img { 185 | border: 0; 186 | } 187 | 188 | /** 189 | * Correct overflow not hidden in IE 9/10/11. 190 | */ 191 | 192 | svg:not(:root) { 193 | overflow: hidden; 194 | } 195 | 196 | /* Grouping content 197 | ========================================================================== */ 198 | 199 | /** 200 | * Address margin not present in IE 8/9 and Safari. 201 | */ 202 | 203 | figure { 204 | margin: 1em 40px; 205 | } 206 | 207 | /** 208 | * Address differences between Firefox and other browsers. 209 | */ 210 | 211 | hr { 212 | -moz-box-sizing: content-box; 213 | box-sizing: content-box; 214 | height: 0; 215 | } 216 | 217 | /** 218 | * Contain overflow in all browsers. 219 | */ 220 | 221 | pre { 222 | overflow: auto; 223 | } 224 | 225 | /** 226 | * Address odd `em`-unit font size rendering in all browsers. 227 | */ 228 | 229 | code, 230 | kbd, 231 | pre, 232 | samp { 233 | font-family: monospace, monospace; 234 | font-size: 1em; 235 | } 236 | 237 | /* Forms 238 | ========================================================================== */ 239 | 240 | /** 241 | * Known limitation: by default, Chrome and Safari on OS X allow very limited 242 | * styling of `select`, unless a `border` property is set. 243 | */ 244 | 245 | /** 246 | * 1. Correct color not being inherited. 247 | * Known issue: affects color of disabled elements. 248 | * 2. Correct font properties not being inherited. 249 | * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. 250 | */ 251 | 252 | button, 253 | input, 254 | optgroup, 255 | select, 256 | textarea { 257 | color: inherit; /* 1 */ 258 | font: inherit; /* 2 */ 259 | margin: 0; /* 3 */ 260 | } 261 | 262 | /** 263 | * Address `overflow` set to `hidden` in IE 8/9/10/11. 264 | */ 265 | 266 | button { 267 | overflow: visible; 268 | } 269 | 270 | /** 271 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 272 | * All other form control elements do not inherit `text-transform` values. 273 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 274 | * Correct `select` style inheritance in Firefox. 275 | */ 276 | 277 | button, 278 | select { 279 | text-transform: none; 280 | } 281 | 282 | /** 283 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 284 | * and `video` controls. 285 | * 2. Correct inability to style clickable `input` types in iOS. 286 | * 3. Improve usability and consistency of cursor style between image-type 287 | * `input` and others. 288 | */ 289 | 290 | button, 291 | html input[type="button"], /* 1 */ 292 | input[type="reset"], 293 | input[type="submit"] { 294 | -webkit-appearance: button; /* 2 */ 295 | cursor: pointer; /* 3 */ 296 | } 297 | 298 | /** 299 | * Re-set default cursor for disabled elements. 300 | */ 301 | 302 | button[disabled], 303 | html input[disabled] { 304 | cursor: default; 305 | } 306 | 307 | /** 308 | * Remove inner padding and border in Firefox 4+. 309 | */ 310 | 311 | button::-moz-focus-inner, 312 | input::-moz-focus-inner { 313 | border: 0; 314 | padding: 0; 315 | } 316 | 317 | /** 318 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in 319 | * the UA stylesheet. 320 | */ 321 | 322 | input { 323 | line-height: normal; 324 | } 325 | 326 | /** 327 | * It's recommended that you don't attempt to style these elements. 328 | * Firefox's implementation doesn't respect box-sizing, padding, or width. 329 | * 330 | * 1. Address box sizing set to `content-box` in IE 8/9/10. 331 | * 2. Remove excess padding in IE 8/9/10. 332 | */ 333 | 334 | input[type="checkbox"], 335 | input[type="radio"] { 336 | box-sizing: border-box; /* 1 */ 337 | padding: 0; /* 2 */ 338 | } 339 | 340 | /** 341 | * Fix the cursor style for Chrome's increment/decrement buttons. For certain 342 | * `font-size` values of the `input`, it causes the cursor style of the 343 | * decrement button to change from `default` to `text`. 344 | */ 345 | 346 | input[type="number"]::-webkit-inner-spin-button, 347 | input[type="number"]::-webkit-outer-spin-button { 348 | height: auto; 349 | } 350 | 351 | /** 352 | * 1. Address `appearance` set to `searchfield` in Safari and Chrome. 353 | * 2. Address `box-sizing` set to `border-box` in Safari and Chrome 354 | * (include `-moz` to future-proof). 355 | */ 356 | 357 | input[type="search"] { 358 | -webkit-appearance: textfield; /* 1 */ 359 | -moz-box-sizing: content-box; 360 | -webkit-box-sizing: content-box; /* 2 */ 361 | box-sizing: content-box; 362 | } 363 | 364 | /** 365 | * Remove inner padding and search cancel button in Safari and Chrome on OS X. 366 | * Safari (but not Chrome) clips the cancel button when the search input has 367 | * padding (and `textfield` appearance). 368 | */ 369 | 370 | input[type="search"]::-webkit-search-cancel-button, 371 | input[type="search"]::-webkit-search-decoration { 372 | -webkit-appearance: none; 373 | } 374 | 375 | /** 376 | * Define consistent border, margin, and padding. 377 | */ 378 | 379 | fieldset { 380 | border: 1px solid #c0c0c0; 381 | margin: 0 2px; 382 | padding: 0.35em 0.625em 0.75em; 383 | } 384 | 385 | /** 386 | * 1. Correct `color` not being inherited in IE 8/9/10/11. 387 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 388 | */ 389 | 390 | legend { 391 | border: 0; /* 1 */ 392 | padding: 0; /* 2 */ 393 | } 394 | 395 | /** 396 | * Remove default vertical scrollbar in IE 8/9/10/11. 397 | */ 398 | 399 | textarea { 400 | overflow: auto; 401 | } 402 | 403 | /** 404 | * Don't inherit the `font-weight` (applied by a rule above). 405 | * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. 406 | */ 407 | 408 | optgroup { 409 | font-weight: bold; 410 | } 411 | 412 | /* Tables 413 | ========================================================================== */ 414 | 415 | /** 416 | * Remove most spacing between table cells. 417 | */ 418 | 419 | table { 420 | border-collapse: collapse; 421 | border-spacing: 0; 422 | } 423 | 424 | td, 425 | th { 426 | padding: 0; 427 | } -------------------------------------------------------------------------------- /public/assets/css/skeleton.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Skeleton V2.0.4 3 | * Copyright 2014, Dave Gamache 4 | * www.getskeleton.com 5 | * Free to use under the MIT license. 6 | * http://www.opensource.org/licenses/mit-license.php 7 | * 12/29/2014 8 | */ 9 | 10 | 11 | /* Table of contents 12 | –––––––––––––––––––––––––––––––––––––––––––––––––– 13 | - Grid 14 | - Base Styles 15 | - Typography 16 | - Links 17 | - Buttons 18 | - Forms 19 | - Lists 20 | - Code 21 | - Tables 22 | - Spacing 23 | - Utilities 24 | - Clearing 25 | - Media Queries 26 | */ 27 | 28 | 29 | /* Grid 30 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 31 | .container { 32 | position: relative; 33 | width: 100%; 34 | max-width: 960px; 35 | margin: 0 auto; 36 | padding: 0 20px; 37 | box-sizing: border-box; } 38 | .column, 39 | .columns { 40 | width: 100%; 41 | float: left; 42 | box-sizing: border-box; } 43 | 44 | /* For devices larger than 400px */ 45 | @media (min-width: 401px) { 46 | .container { 47 | width: 85%; 48 | padding: 0; } 49 | } 50 | 51 | /* For devices larger than 650px */ 52 | @media (min-width: 651px) { 53 | .container { 54 | width: 80%; } 55 | .column, 56 | .columns { 57 | margin-left: 4%; } 58 | .column:first-child, 59 | .columns:first-child { 60 | margin-left: 0; } 61 | 62 | .one.column, 63 | .one.columns { width: 4.66666666667%; } 64 | .two.columns { width: 13.3333333333%; } 65 | .three.columns { width: 22%; } 66 | .four.columns { width: 30.6666666667%; } 67 | .five.columns { width: 39.3333333333%; } 68 | .six.columns { width: 48%; } 69 | .seven.columns { width: 56.6666666667%; } 70 | .eight.columns { width: 65.3333333333%; } 71 | .nine.columns { width: 74.0%; } 72 | .ten.columns { width: 82.6666666667%; } 73 | .eleven.columns { width: 91.3333333333%; } 74 | .twelve.columns { width: 100%; margin-left: 0; } 75 | 76 | .one-third.column { width: 30.6666666667%; } 77 | .two-thirds.column { width: 65.3333333333%; } 78 | 79 | .one-half.column { width: 48%; } 80 | 81 | /* Offsets */ 82 | .offset-by-one.column, 83 | .offset-by-one.columns { margin-left: 8.66666666667%; } 84 | .offset-by-two.column, 85 | .offset-by-two.columns { margin-left: 17.3333333333%; } 86 | .offset-by-three.column, 87 | .offset-by-three.columns { margin-left: 26%; } 88 | .offset-by-four.column, 89 | .offset-by-four.columns { margin-left: 34.6666666667%; } 90 | .offset-by-five.column, 91 | .offset-by-five.columns { margin-left: 43.3333333333%; } 92 | .offset-by-six.column, 93 | .offset-by-six.columns { margin-left: 52%; } 94 | .offset-by-seven.column, 95 | .offset-by-seven.columns { margin-left: 60.6666666667%; } 96 | .offset-by-eight.column, 97 | .offset-by-eight.columns { margin-left: 69.3333333333%; } 98 | .offset-by-nine.column, 99 | .offset-by-nine.columns { margin-left: 78.0%; } 100 | .offset-by-ten.column, 101 | .offset-by-ten.columns { margin-left: 86.6666666667%; } 102 | .offset-by-eleven.column, 103 | .offset-by-eleven.columns { margin-left: 95.3333333333%; } 104 | 105 | .offset-by-one-third.column, 106 | .offset-by-one-third.columns { margin-left: 34.6666666667%; } 107 | .offset-by-two-thirds.column, 108 | .offset-by-two-thirds.columns { margin-left: 69.3333333333%; } 109 | 110 | .offset-by-one-half.column, 111 | .offset-by-one-half.columns { margin-left: 52%; } 112 | 113 | } 114 | 115 | 116 | /* Base Styles 117 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 118 | /* NOTE 119 | html is set to 62.5% so that all the REM measurements throughout Skeleton 120 | are based on 10px sizing. So basically 1.5rem = 15px :) */ 121 | html { 122 | font-size: 62.5%; } 123 | body { 124 | font-size: 1.5em; /* currently ems cause chrome bug misinterpreting rems on body element */ 125 | line-height: 1.6; 126 | font-weight: 400; 127 | font-family: "Raleway", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif; 128 | color: #222; } 129 | 130 | 131 | /* Typography 132 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 133 | h1, h2, h3, h4, h5, h6 { 134 | margin-top: 0; 135 | margin-bottom: 2rem; 136 | font-weight: 300; } 137 | h1 { font-size: 4.0rem; line-height: 1.2; letter-spacing: -.1rem;} 138 | h2 { font-size: 3.6rem; line-height: 1.25; letter-spacing: -.1rem; } 139 | h3 { font-size: 3.0rem; line-height: 1.3; letter-spacing: -.1rem; } 140 | h4 { font-size: 2.4rem; line-height: 1.35; letter-spacing: -.08rem; } 141 | h5 { font-size: 1.8rem; line-height: 1.5; letter-spacing: -.05rem; } 142 | h6 { font-size: 1.5rem; line-height: 1.6; letter-spacing: 0; } 143 | 144 | /* Larger than phablet */ 145 | @media (min-width: 651px) { 146 | h1 { font-size: 5.0rem; } 147 | h2 { font-size: 4.2rem; } 148 | h3 { font-size: 3.6rem; } 149 | h4 { font-size: 3.0rem; } 150 | h5 { font-size: 2.4rem; } 151 | h6 { font-size: 1.5rem; } 152 | } 153 | 154 | p { 155 | margin-top: 0; } 156 | 157 | 158 | /* Links 159 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 160 | a { 161 | color: #1EAEDB; } 162 | a:hover { 163 | color: #0FA0CE; } 164 | 165 | 166 | /* Buttons 167 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 168 | .button, 169 | button, 170 | input[type="submit"], 171 | input[type="reset"], 172 | input[type="button"] { 173 | display: inline-block; 174 | height: 38px; 175 | padding: 0 30px; 176 | color: #555; 177 | text-align: center; 178 | font-size: 11px; 179 | font-weight: 600; 180 | line-height: 38px; 181 | letter-spacing: .1rem; 182 | text-transform: uppercase; 183 | text-decoration: none; 184 | white-space: nowrap; 185 | background-color: transparent; 186 | border-radius: 4px; 187 | border: 1px solid #bbb; 188 | cursor: pointer; 189 | box-sizing: border-box; } 190 | .button:hover, 191 | button:hover, 192 | input[type="submit"]:hover, 193 | input[type="reset"]:hover, 194 | input[type="button"]:hover, 195 | .button:focus, 196 | button:focus, 197 | input[type="submit"]:focus, 198 | input[type="reset"]:focus, 199 | input[type="button"]:focus { 200 | color: #333; 201 | border-color: #888; 202 | outline: 0; } 203 | .button.button-primary, 204 | button.button-primary, 205 | input[type="submit"].button-primary, 206 | input[type="reset"].button-primary, 207 | input[type="button"].button-primary { 208 | color: #FFF; 209 | background-color: #33C3F0; 210 | border-color: #33C3F0; } 211 | .button.button-primary:hover, 212 | button.button-primary:hover, 213 | input[type="submit"].button-primary:hover, 214 | input[type="reset"].button-primary:hover, 215 | input[type="button"].button-primary:hover, 216 | .button.button-primary:focus, 217 | button.button-primary:focus, 218 | input[type="submit"].button-primary:focus, 219 | input[type="reset"].button-primary:focus, 220 | input[type="button"].button-primary:focus { 221 | color: #FFF; 222 | background-color: #1EAEDB; 223 | border-color: #1EAEDB; } 224 | 225 | 226 | /* Forms 227 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 228 | input[type="email"], 229 | input[type="number"], 230 | input[type="search"], 231 | input[type="text"], 232 | input[type="tel"], 233 | input[type="url"], 234 | input[type="password"], 235 | textarea, 236 | select { 237 | height: 38px; 238 | padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */ 239 | background-color: #fff; 240 | border: 1px solid #D1D1D1; 241 | border-radius: 4px; 242 | box-shadow: none; 243 | box-sizing: border-box; } 244 | /* Removes awkward default styles on some inputs for iOS */ 245 | input[type="email"], 246 | input[type="number"], 247 | input[type="search"], 248 | input[type="text"], 249 | input[type="tel"], 250 | input[type="url"], 251 | input[type="password"], 252 | textarea { 253 | -webkit-appearance: none; 254 | -moz-appearance: none; 255 | appearance: none; } 256 | textarea { 257 | min-height: 65px; 258 | padding-top: 6px; 259 | padding-bottom: 6px; } 260 | input[type="email"]:focus, 261 | input[type="number"]:focus, 262 | input[type="search"]:focus, 263 | input[type="text"]:focus, 264 | input[type="tel"]:focus, 265 | input[type="url"]:focus, 266 | input[type="password"]:focus, 267 | textarea:focus, 268 | select:focus { 269 | border: 1px solid #33C3F0; 270 | outline: 0; } 271 | label, 272 | legend { 273 | display: block; 274 | margin-bottom: .5rem; 275 | font-weight: 600; } 276 | fieldset { 277 | padding: 0; 278 | border-width: 0; } 279 | input[type="checkbox"], 280 | input[type="radio"] { 281 | display: inline; } 282 | label > .label-body { 283 | display: inline-block; 284 | margin-left: .5rem; 285 | font-weight: normal; } 286 | 287 | 288 | /* Lists 289 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 290 | ul { 291 | list-style: circle inside; } 292 | ol { 293 | list-style: decimal inside; } 294 | ol, ul { 295 | padding-left: 0; 296 | margin-top: 0; } 297 | ul ul, 298 | ul ol, 299 | ol ol, 300 | ol ul { 301 | margin: 1.5rem 0 1.5rem 3rem; 302 | font-size: 90%; } 303 | li { 304 | margin-bottom: 1rem; } 305 | 306 | 307 | /* Code 308 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 309 | code { 310 | padding: .2rem .5rem; 311 | margin: 0 .2rem; 312 | font-size: 90%; 313 | white-space: nowrap; 314 | background: #F1F1F1; 315 | border: 1px solid #E1E1E1; 316 | border-radius: 4px; } 317 | pre > code { 318 | display: block; 319 | padding: 1rem 1.5rem; 320 | white-space: pre; } 321 | 322 | 323 | /* Tables 324 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 325 | th, 326 | td { 327 | padding: 12px 15px; 328 | text-align: left; 329 | border-bottom: 1px solid #E1E1E1; } 330 | th:first-child, 331 | td:first-child { 332 | padding-left: 0; } 333 | th:last-child, 334 | td:last-child { 335 | padding-right: 0; } 336 | 337 | 338 | /* Spacing 339 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 340 | button, 341 | .button { 342 | margin-bottom: 1rem; } 343 | input, 344 | textarea, 345 | select, 346 | fieldset { 347 | margin-bottom: 1.5rem; } 348 | pre, 349 | blockquote, 350 | dl, 351 | figure, 352 | table, 353 | p, 354 | ul, 355 | ol, 356 | form { 357 | margin-bottom: 2.5rem; } 358 | 359 | 360 | /* Utilities 361 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 362 | .u-full-width { 363 | width: 100%; 364 | box-sizing: border-box; } 365 | .u-max-full-width { 366 | max-width: 100%; 367 | box-sizing: border-box; } 368 | .u-pull-right { 369 | float: right; } 370 | .u-pull-left { 371 | float: left; } 372 | 373 | 374 | /* Misc 375 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 376 | hr { 377 | margin-top: 3rem; 378 | margin-bottom: 3.5rem; 379 | border-width: 0; 380 | border-top: 1px solid #E1E1E1; } 381 | 382 | 383 | /* Clearing 384 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 385 | 386 | /* Self Clearing Goodness */ 387 | .container:after, 388 | .row:after, 389 | .u-cf { 390 | content: ""; 391 | display: table; 392 | clear: both; } 393 | 394 | 395 | /* Media Queries 396 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 397 | /* 398 | Note: The best way to structure the use of media queries is to create the queries 399 | near the relevant code. For example, if you wanted to change the styles for buttons 400 | on small devices, paste the mobile query code up in the buttons section and style it 401 | there. 402 | */ 403 | 404 | 405 | /* Larger than mobile */ 406 | @media (min-width: 401px) {} 407 | 408 | /* Larger than phablet (also point when grid becomes active) */ 409 | @media (min-width: 651px) {} 410 | 411 | /* Larger than tablet */ 412 | @media (min-width: 771px) {} 413 | 414 | /* Larger than desktop */ 415 | @media (min-width: 1001px) {} 416 | 417 | /* Larger than Desktop HD */ 418 | @media (min-width: 1201px) {} -------------------------------------------------------------------------------- /public/assets/images/icon-github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/images/icon-glitch.svg: -------------------------------------------------------------------------------- 1 | logo-sunset -------------------------------------------------------------------------------- /public/assets/images/icon-twitter.svg: -------------------------------------------------------------------------------- 1 | Twitter_Logo_Blue -------------------------------------------------------------------------------- /public/assets/images/logo-s-overprint.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/images/logo-s.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/images/opengraph-substation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/substationHQ/substation-diy/85ce06d5f3fbda8c5915a89c47c320faea030a32/public/assets/images/opengraph-substation.png -------------------------------------------------------------------------------- /public/assets/images/screenshot-1-login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/substationHQ/substation-diy/85ce06d5f3fbda8c5915a89c47c320faea030a32/public/assets/images/screenshot-1-login.png -------------------------------------------------------------------------------- /public/assets/images/screenshot-2-email.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/substationHQ/substation-diy/85ce06d5f3fbda8c5915a89c47c320faea030a32/public/assets/images/screenshot-2-email.png -------------------------------------------------------------------------------- /public/assets/images/screenshot-3-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/substationHQ/substation-diy/85ce06d5f3fbda8c5915a89c47c320faea030a32/public/assets/images/screenshot-3-dashboard.png -------------------------------------------------------------------------------- /public/assets/images/screenshot-4-compose.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/substationHQ/substation-diy/85ce06d5f3fbda8c5915a89c47c320faea030a32/public/assets/images/screenshot-4-compose.png -------------------------------------------------------------------------------- /public/assets/images/slashes-overprint.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/images/slashes.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/images/wordmark-overprint.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utility/auth.js: -------------------------------------------------------------------------------- 1 | /******************************************************************** 2 | * 3 | * UTILITY: auth.js /// 4 | * 5 | * This module focuses on authenticating the login nonces we send 6 | * in lieu of permanent admin and/or member passwords. It may also 7 | * be expaneded to handle some of the API authentication, but that's 8 | * still in progress / TBD. 9 | * 10 | *******************************************************************/ 11 | 12 | // We need the db for this one 13 | var db = require(__dirname + '/database.js'); 14 | 15 | 16 | /****** FUNCTION: auth.generateNonce() *****************************/ 17 | // Looks at email+nonce pairings in the database and checks them 18 | // against a given input, expecting: 19 | // (string) email 20 | // (function) callback(err,result) 21 | // 22 | // TODO: placeholder. Needs to be completed, but also probably 23 | // needs to be synchronous? 24 | // 25 | // research: https://www.npmjs.com/package/better-sqlite3 26 | module.exports.generateNonce = function(email, callback) { 27 | /* 28 | // enable basic uuids for a little later 29 | var { v1: uuidv1 } = require('uuid'); 30 | var db = require(__dirname + "/database.js"); 31 | // quickly generate a nonce 32 | var nonce = uuidv1(); 33 | // assume we need the return, so store it in db 34 | db.serialize(function() { 35 | db.run( 36 | 'INSERT INTO Nonces (email, nonce) VALUES ("' + 37 | email + 38 | '","' + 39 | nonce + 40 | '")' 41 | ); 42 | }); 43 | */ 44 | } 45 | 46 | /****** FUNCTION: auth.validateNonce() *****************************/ 47 | // Looks at email+nonce pairings in the database and checks them 48 | // against a given input, expecting: 49 | // (string) email 50 | // (string) nonce 51 | // (function) callback(err,result) 52 | module.exports.validateNonce = function(email, nonce, callback) { 53 | // default to false 54 | var isvalid = false; 55 | // get a row count for email+nonce+datetime (1 = valid, 0 = not valid) 56 | db.get( 57 | 'SELECT Count(*) as count from Nonces WHERE email = "' + 58 | email + '" AND nonce = "' + nonce + 59 | '" AND created > datetime("now","-24 hours")', 60 | function(err, row) { 61 | if (row.count) { 62 | // we've found one that matches email and nonce, plus is 63 | // within the 24 hour window. winner winner chicken dinner. 64 | isvalid = true; 65 | } 66 | // clean up old rows, no matter the result above. anything set 67 | // for this same email address should now be removed. 68 | db.run('DELETE FROM Nonces WHERE email = "' + email + '"'); 69 | 70 | // do the callback — it's a true/false check so there will be 71 | // no error, but keep it for the footprint 72 | callback(null, isvalid); 73 | } 74 | ); 75 | } 76 | 77 | /****** FUNCTION: auth.getAPISecrets() *****************************/ 78 | // Creates a unique hashed "key" for each admin user and returns an 79 | // array of each user/key combination keyed to the email address. 80 | module.exports.getAPISecrets = function() { 81 | var users = require(__dirname + "/../models/users.js"); 82 | var crypto = require('crypto'); 83 | var admins = users.getAdminUsers(); 84 | var secrets = {}; 85 | admins.forEach(function(admin){ 86 | // create the hash from a combination of the admin email address and 87 | // the security secret, then shorten to 24 characters 88 | secrets[admin] = crypto.createHash('sha256').update(admin + process.env.SECURITY_SECRET).digest('hex').substr(0,24); 89 | }); 90 | return secrets; 91 | } 92 | 93 | /****** FUNCTION: auth.validateAPIToken() **************************/ 94 | // A middleware function the API controller that looks for the 95 | // x-access-token header and checks its value against an issued 96 | // token — these are JSON web tokens encoded with an admin's 97 | // email address and API key, and containing just the admin email 98 | module.exports.validateAPIToken = function(request, response, next) { 99 | var jwt = require('jsonwebtoken'); 100 | var users = require(__dirname + "/../models/users.js"); 101 | 102 | var token = request.headers['x-access-token']; 103 | if (!token) { 104 | response.status(403).send({ auth: false, message: 'No token provided.' }); 105 | } else { 106 | jwt.verify(token, process.env.SECURITY_SECRET, function(err, decoded) { 107 | if (err) { 108 | response.status(500).send({ auth: false, message: 'Failed to authenticate token.' }); 109 | } else { 110 | // if everything good, save to request for use in other routes 111 | request.email = decoded.email; 112 | 113 | // now check for admin persmissions 114 | if (!users.isAdmin(request.email)) { 115 | response.status(401).send({ auth: false, message: 'Unauthorized.' }); 116 | } else { 117 | // whew! next. 118 | next(); 119 | } 120 | } 121 | }); 122 | } 123 | } -------------------------------------------------------------------------------- /utility/database.js: -------------------------------------------------------------------------------- 1 | /******************************************************************** 2 | * 3 | * UTILITY: database.js /// 4 | * 5 | * The database is only used for issuing one-time nonce tokens to 6 | * verify a user either for login or for unsubscribe. 7 | * 8 | * Even if the server sleeps, the database persists. That said — 9 | * if it's lost it's not a tragedy for the app. All real data is 10 | * stored in Braintree, so should the database get blown away by 11 | * some server fluke a user needs only retry their login/unsubscribe 12 | * request and it will set itself up again. 13 | * 14 | * This module either initializes or connects to our SQLite db in 15 | * the hidden .data/ directory. 16 | * 17 | *******************************************************************/ 18 | 19 | // set up what we need for the db 20 | var sqlite3 = require("sqlite3").verbose(); 21 | var dbFile = __dirname + "/../.data/sqlite.db"; 22 | 23 | // connect to things and set the db object 24 | let db = new sqlite3.Database(dbFile, sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE, function(err) { 25 | if(err) { 26 | // can't open database 27 | console.error("Can't open database: " + err.message); 28 | } else { 29 | db.run( 30 | "CREATE TABLE IF NOT EXISTS Nonces (email TEXT, nonce TEXT, created TEXT default current_timestamp)" 31 | ); 32 | console.log("SQLite database initialized."); 33 | } 34 | }); 35 | 36 | // export the db object so it's available whenever this script is 37 | // required elsewhere. 38 | module.exports = db; -------------------------------------------------------------------------------- /utility/messaging.js: -------------------------------------------------------------------------------- 1 | /******************************************************************** 2 | * 3 | * UTILITY: messaging.js /// 4 | * 5 | * Currently this module handles individual and list mailings, 6 | * interfacing with the MailGun API. It's pretty straightforward 7 | * despite the length — we use more lines than seem necessary 8 | * because we allow for user-customizable views in the /config 9 | * directory. All of the following will overrule their counterpart 10 | * views: 11 | * 12 | * /config/views/email.html (list mail base template) 13 | * /config/views/messages/login.html (click to login w/ nonce) 14 | * /config/views/messages/unsubscribe.html (confirm unsubscription) 15 | * /config/views/messages/welcome.html (new signup welcome message) 16 | * 17 | * It's best practice for users to create these override files 18 | * rather than directly editing defaults — this allows for future 19 | * updates to the core scripts without losing any customizations. 20 | * 21 | *******************************************************************/ 22 | 23 | var fs = require('fs'); 24 | //var mailgen = require("mailgen"); 25 | var mailgun = require("mailgun-js"); 26 | 27 | // set up mailgun 28 | var mg = mailgun({ 29 | apiKey: process.env.MAILGUN_API_KEY, 30 | domain: process.env.MAILGUN_DOMAIN 31 | }); 32 | 33 | 34 | /****** FUNCTION: messaging.sendMessage() **************************/ 35 | // Handles transactional messaging for unsubscribes, logins, and 36 | // new user welcome messages. Expects the following parameters: 37 | // (object) app (pass in the main express object) 38 | // (string) email 39 | // (string) subject 40 | // (string) title 41 | // (string) message (which message? accepts: login, usubscribe, or welcome) 42 | // (string) buttontext (for the "click here...") 43 | // (string) url (if not set there will be no "click here..." button) 44 | // (string) redirecturl (for API initiated logins) 45 | module.exports.sendMessage = function( 46 | app, 47 | email, 48 | subject, 49 | title, 50 | message, 51 | buttontext, 52 | url, 53 | redirecturl 54 | ) { 55 | var nonce = ''; 56 | if (message == 'login' || message == 'unsubscribe') { 57 | // TODO: CENTRALIZE NONCE GEN IN auth.js 58 | // enable basic uuids for a little later 59 | var { v1: uuidv1 } = require('uuid'); 60 | var db = require(__dirname + "/database.js"); 61 | // quickly generate a nonce 62 | nonce = uuidv1(); 63 | // assume we need the return, so store it in db 64 | db.serialize(function() { 65 | db.run( 66 | 'INSERT INTO Nonces (email, nonce) VALUES ("' + 67 | email + 68 | '","' + 69 | nonce + 70 | '")' 71 | ); 72 | }); 73 | } 74 | 75 | // set details for the view 76 | var details = { 77 | "title":title, 78 | "copy":"", 79 | "showbutton":true, 80 | "env": { 81 | "title":process.env.TITLE, 82 | "url":process.env.URL 83 | }, 84 | "unsubscribe":process.env.URL+'unsubscribe' 85 | }; 86 | // give a proper false to the email template for view if statements 87 | // set the button url/copy where needed 88 | if (!url) { 89 | details.showbutton = false; 90 | } else { 91 | details.button = { 92 | "url":url + "?email=" + email + "&nonce=" + nonce, 93 | "copy":buttontext 94 | }; 95 | } 96 | // the redirecturl is only set for API-initiated login requests. if 97 | // present we add the redirect parameter 98 | if (details.showbutton && redirecturl) { 99 | details.button.url += "&redirect=" + redirecturl; 100 | } 101 | 102 | // select the correct view and prep the outgoing email 103 | // we start by defining where the user customized version in /config 104 | // woud be then see if it exists. 105 | var file = __dirname + '/../config/views/messages/' + message + '.html'; 106 | try { 107 | if (!fs.existsSync(file)) { 108 | // not there? use the default! 109 | file = __dirname + '/../views/messages/' + message + '.html'; 110 | } 111 | } catch(err) { 112 | // weird error? use the default! 113 | console.error(err); 114 | file = __dirname + '/../views/messages/' + message + '.html'; 115 | } 116 | fs.readFile(file, 'utf8', function(err, contents) { 117 | if (contents) { 118 | // so we got the message file, now we need to stuff it into 119 | // the email wrapper. that means more checks and the same 120 | // basic process again. 121 | details.copy = contents; 122 | var template = __dirname + '/../config/views/email.html'; 123 | try { 124 | if (!fs.existsSync(template)) { 125 | // no custom email view, so use the default one! 126 | template = 'email'; 127 | } 128 | } catch(err) { 129 | // error. use the default view. 130 | console.error(err); 131 | template = 'email'; 132 | } 133 | // render the contents with our chosen email view 134 | app.render(template, details, function (err, html) { 135 | if (err) { 136 | console.log("messaging.sendTransactional: " + err); 137 | } else { 138 | // rendered (yay!) now initiate the actual send 139 | initiateSend(subject,html,email); 140 | } 141 | }); 142 | } else { 143 | // spit out the dang error and get sad 144 | console.log("messaging.sendTransactional: " + err); 145 | } 146 | }); 147 | }; 148 | 149 | /****** FUNCTION: messaging.sendMailing() **************************/ 150 | // Handles transactional messaging for unsubscribes, logins, and 151 | // new user welcome messages. Expects the following parameters: 152 | // (object) app (pass in the main express object) 153 | // (string) subject 154 | // (string) contents 155 | // (boolean) sending (true: do a full list send, false: test send) 156 | module.exports.sendMailing = function( 157 | app, 158 | subject, 159 | contents, 160 | sending 161 | ) { 162 | // set the details we'll pass to the view 163 | var details = { 164 | "copy":contents, 165 | "env": { 166 | "title":process.env.TITLE, 167 | "url":process.env.URL 168 | }, 169 | "unsubscribe":process.env.URL+'unsubscribe', 170 | "showbutton":false 171 | }; 172 | 173 | // we need to coose the correct view and prep the outgoing email 174 | var email = __dirname + '/../config/views/email.html'; 175 | try { 176 | if (!fs.existsSync(email)) { 177 | // user customized template not found, use the default view 178 | email = 'email'; 179 | } 180 | } catch(err) { 181 | // error. use default view. 182 | console.error(err); 183 | email = 'email'; 184 | } 185 | app.render(email, details, function (err, html) { 186 | if (err) { 187 | console.log("messaging.sendMailing: " + err); 188 | } else { 189 | if (sending) { 190 | // okay we're sending to the full list. unleash the kraken. 191 | initiateSend(subject,html,false,true); 192 | } else { 193 | // just a test, so we get all admin user emails and send 194 | // a test to them instead of the big list 195 | var users = require(__dirname + "/../models/users.js"); 196 | var admins = users.getAdminUsers(); 197 | admins.forEach(function(admin){ 198 | initiateSend(subject,html,admin); 199 | }); 200 | } 201 | } 202 | }); 203 | }; 204 | 205 | /****** FUNCTION: messaging.sendMailing() **************************/ 206 | // Handles transactional messaging for unsubscribes, logins, and 207 | // new user welcome messages. Expects the following parameters: 208 | // (string) subject 209 | // (string) contents 210 | // (string/array) to 211 | // (boolean) batch (true: do a full list send — when true to should be an array) 212 | var initiateSend = function(subject,contents,to,batch) { 213 | // we're gonna need this in a second (to generate the plain text version) 214 | var htmlToText = require('html-to-text'); 215 | 216 | // now we'll use jsdom to get all base64 encoded images and pull them 217 | // out, replacing them with a cid reference for mailing 218 | var jsdom = require("jsdom"); 219 | var { JSDOM } = jsdom; 220 | var dom = new JSDOM(contents, { includeNodeLocations: true }); 221 | var images = dom.window.document.querySelectorAll("img"); 222 | var attachments = []; 223 | 224 | // get all the images, encode them, and turn them into cid references 225 | images.forEach( 226 | async function(img,index) { 227 | if (img.src.substr(0,4) == 'data') { 228 | // we've got a data: URL base64 encoded image, so let's get to work 229 | // swiped regex from https://stackoverflow.com/questions/11335460/how-do-i-parse-a-data-url-in-node 230 | // may(?) be worth splitting the img.src string for optimizations later, but that's later. 231 | var regex = /^data:.+\/(.+);base64,(.*)$/; 232 | 233 | // capture the image source, info about it, and decode from base64 234 | var tmp = img.src; 235 | var matches = img.src.match(regex); 236 | var ext = matches[1]; 237 | var data = matches[2]; 238 | var buffer = Buffer.from(data, 'base64'); 239 | 240 | // add the image to our attachments array 241 | attachments.push( 242 | new mg.Attachment({ 243 | data: buffer, 244 | filename: 'img'+index+'.'+ext, 245 | contentType: 'image/'+ext 246 | }) 247 | ); 248 | // now change the original image's src to match out cid reference 249 | img.src = 'cid:img'+index+'.'+ext; 250 | } 251 | } 252 | ); 253 | 254 | // serialize the emailBody to reconstruct content markup 255 | // create a text-only version from the html 256 | var emailBody = dom.serialize(); 257 | var emailText = htmlToText.fromString(emailBody); 258 | 259 | // gather all the parameters we'll pass to MailGun 260 | var emailData = { 261 | 'from': process.env.MAILGUN_FROM_EMAIL, 262 | 'subject': subject, 263 | 'text': emailText, 264 | 'html': emailBody, 265 | 'inline': attachments 266 | }; 267 | 268 | if (batch) { 269 | // when batch is true we're sending to the whole list 270 | var toArray = []; 271 | var toVars = {}; 272 | // get the current members then send them batched as an 273 | // on-the-fly email list at MailGun 274 | // 275 | // TODO: this has a 1000 email rate limit — we ultimately need 276 | // to chunk out the toArray and do multiple requests for 277 | // every group of one thousand. 278 | var subscribers = require(__dirname + "/../models/subscribers.js"); 279 | subscribers.getActive(function(err, subs) { 280 | if (err) { 281 | console.log("error getting subscribers"); 282 | } else { 283 | subs.forEach(function(s){ 284 | toArray.push(s.email); 285 | toVars[s.email] = { 286 | 'firstName':s.firstName, 287 | 'lastName':s.lastName 288 | } 289 | }); 290 | // we set the array of addresses as the to field 291 | emailData['to'] = toArray; 292 | // and they all need to have recipient variables set 293 | // at MailGun in order to be sent as a batched send with 294 | // each recipient only seeing it addressed to them (like 295 | // any proper mailing list. GTFO BCC.) 296 | emailData['recipient-variables'] = toVars; 297 | mg.messages().send(emailData, function(err, body) { 298 | if (err) { 299 | console.log("There was an error sending email. " + err); 300 | } 301 | }); 302 | } 303 | }); 304 | } else { 305 | // smaller test send to the designated (admin email addresses) to param 306 | emailData.to = to; 307 | mg.messages().send(emailData, function(err, body) { 308 | if (err) { 309 | console.log("There was an error sending email. " + err); 310 | } 311 | }); 312 | } 313 | } -------------------------------------------------------------------------------- /utility/server.js: -------------------------------------------------------------------------------- 1 | /******************************************************************** 2 | * 3 | * UTILITY: server.js /// 4 | * 5 | * The main server. This module sets up requirements, configures the 6 | * server, and returns a viable express object as app. 7 | * 8 | *******************************************************************/ 9 | 10 | // all base requirements 11 | var express = require("express"); 12 | var session = require("express-session"); 13 | var FileStore = require('session-file-store')(session); 14 | var csp = require('express-csp-header'); 15 | var bodyParser = require("body-parser"); 16 | var mustacheExpress = require("mustache-express"); 17 | var cors = require('cors'); 18 | var helmet = require('helmet'); 19 | 20 | // make sure sessions don't wind up in the project repo 21 | var fileStoreOptions = { 22 | "path":__dirname+"/../.data" 23 | }; 24 | 25 | // instantiate express and do settings 26 | var app = express(); 27 | app.use( 28 | session({ 29 | store: new FileStore(fileStoreOptions), 30 | secret: process.env.SECURITY_SECRET, 31 | resave: true, 32 | saveUninitialized: false, 33 | cookie: { 34 | maxAge: 86400000 35 | } 36 | }) 37 | ); 38 | 39 | // http security measures 40 | app.use(helmet()); 41 | 42 | // enable CORS for all requests 43 | app.use(cors()); 44 | 45 | // create a nonce to use with all inline script tags — we 46 | // store this in the app variable so it's universally available 47 | // in all controllers for any view. 48 | app.scriptNonce = Buffer.from(process.env.SECURITY_SECRET + Date.now()).toString('base64').substring(0, 12); 49 | 50 | // enable basic csp headers (only for remote!) 51 | app.use(csp({ 52 | policies: { 53 | "default-src": ['https:', 'data:', "'unsafe-inline'"], 54 | "script-src": [ 55 | "'self'", 56 | "'nonce-"+app.scriptNonce+"'", 57 | "https://cdn.jsdelivr.net/gh/substationhq/", 58 | "https://js.braintreegateway.com/", 59 | "https://cdn.quilljs.com/", 60 | "https://localhost/" 61 | ], 62 | "object-src": ["'self'"], 63 | "form-action": ["'self'"], 64 | "base-uri": ['none'] 65 | } 66 | })); 67 | 68 | // parse every kind of request automatically — the 24mb limit 69 | // is pretty arbitrary, but it's important it's large enough for 70 | // email attachments encoded over http. 71 | app.use(bodyParser.urlencoded({limit: '24mb', extended: true})); 72 | app.use(bodyParser.json()); 73 | 74 | // directly serve static files from the public folder 75 | app.use(express.static(__dirname + "/../public")); 76 | // mustache-express settings 77 | app.engine("html", mustacheExpress()); // register .html extension 78 | app.disable("view cache"); // <--------------------------------------------------- COMMENT OUT IN PRODUCTION 79 | app.set("view engine", "html"); // set the engine 80 | app.set("views", __dirname + "/../views"); // point at our views 81 | 82 | // force SSL! 83 | function checkHttps(req, res, next) { 84 | // protocol check, if http, redirect to https 85 | if (process.argv.indexOf("dev")) { 86 | // no forward/redirect shenanigans on local dev 87 | return next(); 88 | } else { 89 | if (req.get("X-Forwarded-Proto").indexOf("https") != -1) { 90 | return next(); 91 | } else { 92 | console.log("redirecting to ssl"); 93 | res.redirect("https://" + req.hostname + req.url); 94 | } 95 | } 96 | } 97 | app.all("*", checkHttps); 98 | 99 | // export the express object 100 | module.exports = app; -------------------------------------------------------------------------------- /views/dashboard.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | {{copy.title}} 10 | 11 | 12 | 13 | 15 | 16 | 17 | 19 | 20 | 21 | 22 | 23 | 24 | 26 | 27 | 28 | 29 | 30 | 31 | 33 | 34 |
35 |
36 |
37 |

38 | {{#showadmin}} 39 | {{subscription.members}} people, ${{subscription.value}}/period 40 | {{/showadmin}} 41 | {{^showadmin}} 42 | Log in 43 | {{/showadmin}} 44 |

45 |

46 | SUBSTATION DASHBOARD 47 |

48 |
49 |
50 |
51 | 52 |
53 |
54 |
55 |
 
56 |
57 | {{#showadmin}} 58 |

59 | Here you can export your up-to-the-minute member list as a basic CSV 60 | file or send a message to all those members directly. You'll also find 61 | an embed code for a signup widget you can put on your own website. 62 |

63 | 64 | <!-- Substation embed code --> 65 |
66 | <script src="https://cdn.jsdelivr.net/gh/substationhq/lodge@0.9.1/source/lodge.js"></script>
67 | <embed class="lodge" src="{{substationURL}}embed">
68 |
69 |

70 |
71 | Export subscribers as CSV 72 |
73 | Send a mailing to all subscribers 74 |

75 | {{/showadmin}} 76 | {{^showadmin}} 77 |

78 | Substation doesn't use easily-forgotten passwords. Instead we send a secure single-use 79 | login link directly to your inbox. Just enter your admin email address to get started. 80 |

81 | {{^postsend}} 82 |
83 | 84 | 85 |
86 | {{/postsend}} 87 | {{#postsend}} 88 |

89 | Check your inbox. We just sent you a login link. 90 |

91 | {{/postsend}} 92 | {{/showadmin}} 93 |
94 |
 
95 |
96 |
97 |
98 | 99 | 111 | 112 | 114 | 115 | 116 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /views/docs/gettingstarted.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{copy.title}} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 |
40 | 41 |
42 |
43 |

substation

44 |

Make your own club today

45 |

46 | Substation lets you offer a subscription or newsletter using your own accounts on services like Braintree 47 | and Mailgun. It's free, open-source, and easy to set up. 48 |

49 |
50 |
51 |
52 |
53 |

Want to kick the tires?

54 |

55 | Test the sign-up with this card: 56 |

57 | 58 | 4111 1111 1111 1111
59 | Exp: 04/2023   CVV: 123 60 |
61 | 62 |
63 |
64 | 65 |
66 |
67 |
68 | 69 |
70 |

First things first

71 |

72 | Substation is free software. That means independence and control. You can make your community truly 73 | yours and customize things however you like. But you'll have to edit a config file and some html. There 74 | is set-up. That might sound intimidating, but you can handle it. 75 |

76 | We're here to help. Say hi. 77 |

78 | 79 |
80 | 89 |
90 | 91 |

Tell me more

92 |

93 | Potential members first interact with substation using the sign-up embed. 94 | This can live on your site or on a simple page hosted by substation — or both even. There's a dashboard you can use 95 | to email all members or export a CSV. It's all built on a simple API that handles email verification, 96 | mailing campaigns, and recurring paments. 97 |

98 | You can see the sign-up embed in the demo above. Here are a few pictures of the dashboard and email. 99 |

100 |
101 | The login window, a simple form with just one input for your email address 102 | The login verification email sent to your inbox 103 | The dashboard — an embed code, a CSV export, and an option to mail all members 104 | Composing an email to all members 105 |
106 | 107 |
108 | 117 |
118 | 119 |

So how does it work?

120 |

121 | Substation is just a few parts stitched together with a library we built called 122 | Lodge. For now you'll need accounts with Braintree 123 | (payments) and Mailgun (bulk email) to get it all 124 | running. Neither service will cost you anything up front except a little time for set up. 125 |

126 | Lodge handles the responsive embed, which can open a full-screen checkout overlay on any page, 127 | and it's as easy to embed as a YouTube video: 128 |

129 | 130 | 131 | <!-- Substation embed code --> 132 |
133 | <script src="https://cdn.jsdelivr.net/gh/substationhq/lodge@0.9.1/dist/lodge.js"></script>
134 | <embed class="lodge" src="{{substationURL}}embed">
135 |
136 | 137 |

138 | Now here's the weird thing: there's no database. We wanted to make substation as easy to use as possible, 139 | so we designed around what we could do with just a payment API and email authentication. So all customer 140 | information is stored safely with the payment provider, and user authentication happens pretty much like 141 | every other site resets a password — except without a password. Just push a button in your inbox to 142 | login. 143 |

144 | This means all of substation is configured and customized with a single .env file 145 | and a few markup/css files for customization. More importantly it makes custom software a lot less 146 | intimidating. Databases can cut you. 147 |

148 | If you're still reading, then maybe you'd enjoy this README. 149 |

150 | 151 |
152 | 161 |
162 | 163 |

164 | What's next? 165 |

166 |

167 | Substation is a work in progress. Right now we're exploring more payment options and services for Lodge. After that the idea is to move substation 168 | towards more of a serverless architecture, but carefully so it can work on services like Glitch and Vercel 169 | side by side. 170 |

171 | We're going to keep making substation easier and better, because we think it's important for people to have 172 | more ways to connect and support each other directly. 173 |

174 | Email or tweet any questions. 175 |

176 |
177 |
178 | 181 | 182 | -------------------------------------------------------------------------------- /views/email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 62 | 63 | 73 | 74 | 82 | 87 | 88 | 89 | 96 | 97 | 98 | 100 | 101 | 102 | 103 | 104 | 105 | 106 |
107 | 108 | 109 | 116 | 117 | 118 |
119 | 120 | 121 | 122 | 123 | 134 | 135 | 136 |
124 | 133 |
137 | 138 |
139 | 140 | 141 | 152 | 153 | 154 |
155 | 156 | 157 | 158 | 159 | 242 | 243 | 244 |
160 | 169 | 170 |
171 | 172 | 173 | 174 | 175 | 188 | 189 | 190 | {{#title}} 191 | 192 | 199 | 200 | {{/title}} 201 | 202 | 203 | 210 | 211 | 212 | {{#showbutton}} 213 | 214 | 227 | 228 | {{/showbutton}} 229 | 230 |
176 | 177 | 178 | 179 | 180 | 183 | 184 | 185 |
181 | 182 |
186 | 187 |
193 | 194 |
195 | {{{title}}} 196 |
197 | 198 |
204 | 205 |
206 | {{{copy}}} 207 |
208 | 209 |
215 | 216 | 217 | 224 | 225 |
218 | 219 | 220 | {{button.copy}} 221 | 222 | 223 |
226 |
231 | 232 |
233 | 234 | 241 |
245 | 246 |
247 | 248 | 249 | 260 | 261 | 262 |
263 | 264 | 265 | 266 | 267 | 314 | 315 | 316 |
268 | 277 | 278 |
279 | 280 | 281 | 282 | 283 | 300 | 301 | 302 |
284 | 285 | 286 | 287 | 288 | 295 | 296 | 297 |
289 | 290 |
291 | Unsubscribe 292 |
293 | 294 |
298 | 299 |
303 | 304 |
305 | 306 | 313 |
317 | 318 |
319 | 320 | 321 | 326 | 327 | 328 |
329 | 330 | 331 | 332 | -------------------------------------------------------------------------------- /views/embed.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | {{copy.title}} 10 | 11 | 12 | 13 | 15 | 16 | 17 | 19 | 20 | 21 | 22 | 23 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 35 | 36 |
37 | 38 |
39 |
40 | 41 | 42 |
43 | {{#sandboxed}} 44 |
45 | SANDBOX MODE. Test cards only, no real money. 46 |
47 | {{/sandboxed}} 48 |
49 | You're signing up for recurring payments, cancel any time. 50 |
51 |
52 | 53 |
54 | 55 | 56 | 58 | 132 | 133 | 135 | 136 | 137 | -------------------------------------------------------------------------------- /views/export.html: -------------------------------------------------------------------------------- 1 | {{#users}} 2 | {{firstName}},{{lastName}},{{email}} 3 | {{/users}} -------------------------------------------------------------------------------- /views/mailing.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | {{copy.title}} 10 | 11 | 12 | 13 | 15 | 16 | 17 | 18 | 19 | 20 | 22 | 23 | 24 | 25 | 26 | 27 | 29 | 30 | 31 | 32 | 33 | 34 | 36 | 37 |
38 |
39 |
40 |

41 | Mail all current members 42 |

43 |

44 | SUBSTATION DASHBOARD: MAILING 45 |

46 |
47 |
48 |
49 | 50 |
51 |
52 |
53 |
 
54 |
55 | {{#showadmin}} 56 |

57 | Write a message and send a test to yourself to unlock the "send to all" button. If 58 | the test looks good then let it fly. 59 |

60 | 61 |
62 | 63 | 64 | 65 |
66 | 67 |

68 | 69 | 70 | 71 |
72 | {{/showadmin}} 73 | {{^showadmin}} 74 | {{^postsend}} 75 |
76 | 77 | 78 |
79 | {{/postsend}} 80 | {{#postsend}} 81 |

82 | Check your inbox. We just sent a secure single-use link to your dashboard. 83 |

84 | {{/postsend}} 85 | {{/showadmin}} 86 |
87 |
 
88 |
89 |
90 |
91 | 92 | 104 | 105 | 107 | {{#showadmin}} 108 | 109 | 110 | 111 | 112 | 162 | {{/showadmin}} 163 | 164 | 166 | 167 | 168 | -------------------------------------------------------------------------------- /views/messages/login.html: -------------------------------------------------------------------------------- 1 |

2 | Here you go. 3 |

4 | Just click the button below to log in. That's it. 5 | The button will stop working after you click it. 6 | Next time you want to log in you'll get a new link 7 | sent to you. Easy, disposable, and secure. 8 |

9 | -------------------------------------------------------------------------------- /views/messages/unsubscribe.html: -------------------------------------------------------------------------------- 1 |

2 | To confirm that you'd like to cancel your membership 3 | just click the button below. 4 |

5 | Note: you'll still be an active member until your 6 | current subscription period ends. 7 |

-------------------------------------------------------------------------------- /views/messages/welcome.html: -------------------------------------------------------------------------------- 1 |

2 | Welcome to the club! We'll send email updates 3 | and more instructions as we go. Glad you're here 4 | with us! 5 |

-------------------------------------------------------------------------------- /views/unsubscribe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | {{copy.title}} 10 | 11 | 12 | 13 | 15 | 16 | 17 | 19 | 20 | 21 | 22 | 23 | 24 | 26 | 27 | 28 | 29 | 30 | 31 | 33 | 34 | 35 |
36 |
37 |
38 |

39 | {{#showgoodbye}} 40 | We're sorry to see you go. 41 | {{/showgoodbye}} 42 | {{^showgoodbye}} 43 | Cancel and unsubscribe. 44 | {{/showgoodbye}} 45 |

46 |

47 | SUBSTATION DIY 48 |

49 |

50 |
51 | {{#showgoodbye}} 52 | {{#justrequested}} 53 | We've sent you a link to confirm that you want to cancel. Just click the button to 54 | cancel any payments and opt out of mailings. 55 | {{/justrequested}} 56 | {{^justrequested}} 57 | Your recurring payment has been cancelled. You will still be seen as holding an active 58 | subscription membership through the end of this current billing cycle. Should you want 59 | to restart at any time just visit the main page and sign up again. 60 |

61 | Thank you for your support. 62 | {{/justrequested}} 63 | {{/showgoodbye}} 64 | {{^showgoodbye}} 65 |

Enter your email — we'll send you a cancel/unsubscribe link.

66 |
67 | 68 | 69 |
70 | {{/showgoodbye}} 71 |

72 |
73 |
74 |
75 | 76 | 78 | 79 | 81 | 82 | 83 | --------------------------------------------------------------------------------