├── .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 |
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 |
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 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
116 |
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 |
153 |
154 |
160 |
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 |
179 |
180 |
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 |
124 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 | {{#title}}
191 |
192 |
193 |
194 |
195 | {{{title}}}
196 |
197 |
198 |
199 |
200 | {{/title}}
201 |
202 |
203 |
204 |
205 |
206 | {{{copy}}}
207 |
208 |
209 |
210 |
211 |
212 | {{#showbutton}}
213 |
214 |
215 |
226 |
227 |
228 | {{/showbutton}}
229 |
230 |
231 |
232 |
233 |
234 |
241 |
242 |
243 |
244 |
245 |
246 |
247 |
248 |
249 |
260 |
261 |
262 |
263 |
264 |
265 |
266 |
267 |
268 |
277 |
278 |
279 |
280 |
281 |
282 |
283 |
284 |
285 |
286 |
287 |
288 |
289 |
290 |
293 |
294 |
295 |
296 |
297 |
298 |
299 |
300 |
301 |
302 |
303 |
304 |
305 |
306 |
313 |
314 |
315 |
316 |
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 |
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 |
72 | {{/showadmin}}
73 | {{^showadmin}}
74 | {{^postsend}}
75 |
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 |
70 | {{/showgoodbye}}
71 |
72 |
73 |
74 |
75 |
76 |
78 |
79 |
81 |
82 |
83 |
--------------------------------------------------------------------------------