Merge branch 'postgres'

This commit is contained in:
Jonathan Cremin 2016-08-07 19:16:03 +01:00
commit 305dd77f43
36 changed files with 1195 additions and 312 deletions

View file

@ -5,6 +5,7 @@ import StatsD from 'statsy';
import auth from './lib/auth';
import * as user from './routes/user';
import * as file from './routes/file';
import * as pro from './routes/pro';
import debugname from 'debug';
const debug = debugname('hostr-api');
@ -64,10 +65,11 @@ router.get('/user/token', auth, user.token);
router.get('/token', auth, user.token);
router.get('/user/transaction', auth, user.transaction);
router.post('/user/settings', auth, user.settings);
router.post('/user/pro', auth, pro.create);
router.delete('/user/pro', auth, pro.cancel);
router.get('/file', auth, file.list);
router.post('/file', auth, file.post);
router.get('/file/:id', file.get);
router.put('/file/:id', auth, file.put);
router.delete('/file/:id', auth, file.del);
router.delete('/file/:id', auth, file.del);

View file

@ -1,63 +1,88 @@
import passwords from 'passwords';
import auth from 'basic-auth';
import models from '../../models';
import debugname from 'debug';
const debug = debugname('hostr-api:auth');
const badLoginMsg = '{"error": {"message": "Incorrect login details.", "code": 607}}';
export default function* (next) {
const Users = this.db.Users;
const Files = this.db.Files;
const Logins = this.db.Logins;
let user = false;
const remoteIp = this.req.headers['x-real-ip'] || this.req.connection.remoteAddress;
const login = yield models.login.create({
ip: remoteIp,
successful: false,
});
if (this.req.headers.authorization && this.req.headers.authorization[0] === ':') {
debug('Logging in with token');
const userToken = yield this.redis.get(this.req.headers.authorization.substr(1));
this.assert(userToken, 401, '{"error": {"message": "Invalid token.", "code": 606}}');
debug('Token found');
user = yield Users.findOne({ _id: this.db.objectId(userToken) });
user = yield models.user.findById(userToken);
if (!user) {
login.save();
return;
}
} else {
const authUser = auth(this);
this.assert(authUser, 401, badLoginMsg);
const remoteIp = this.req.headers['x-real-ip'] || this.req.connection.remoteAddress;
const count = yield Logins.count({
ip: remoteIp,
successful: false,
at: { $gt: Math.ceil(Date.now() / 1000) - 600 },
const count = yield models.login.count({
where: {
ip: remoteIp,
successful: false,
createdAt: {
$gt: new Date(Date.now() - 600000),
},
},
});
this.assert(count < 25, 401,
'{"error": {"message": "Too many incorrect logins.", "code": 608}}');
yield Logins.insertOne({ ip: remoteIp, at: Math.ceil(Date.now() / 1000), successful: null });
user = yield Users.findOne({
email: authUser.name,
banned: { $exists: false },
status: { $ne: 'deleted' },
user = yield models.user.findOne({
where: {
email: authUser.name,
activated: true,
},
});
this.assert(user, 401, badLoginMsg);
const authenticated = yield passwords.match(authUser.pass, user.salted_password);
this.assert(authenticated, 401, badLoginMsg);
if (!user || !(yield passwords.match(authUser.pass, user.password))) {
login.save();
this.throw(401, badLoginMsg);
return;
}
}
debug('Checking user');
this.assert(user, 401, badLoginMsg);
debug('Checking user is activated');
this.assert(!user.activationCode, 401,
debug(user.activated);
this.assert(user.activated === true, 401,
'{"error": {"message": "Account has not been activated.", "code": 603}}');
const uploadedTotal = yield Files.count({ owner: user._id, status: { $ne: 'deleted' } });
const uploadedToday = yield Files.count({
owner: user._id,
time_added: { $gt: Math.ceil(Date.now() / 1000) - 86400 },
login.successful = true;
yield login.save();
const uploadedTotal = yield models.file.count({
where: {
userId: user.id,
},
});
const uploadedToday = yield models.file.count({
where: {
userId: user.id,
createdAt: {
$gt: Date.now() - 86400000,
},
},
});
const normalisedUser = {
id: user._id,
id: user.id,
email: user.email,
daily_upload_allowance: user.type === 'Pro' ? 'unlimited' : 15,
daily_upload_allowance: user.plan === 'Pro' ? 'unlimited' : 15,
file_count: uploadedTotal,
max_filesize: user.type === 'Pro' ? 524288000 : 20971520,
plan: user.type || 'Free',
max_filesize: user.plan === 'Pro' ? 524288000 : 20971520,
plan: user.plan,
uploads_today: uploadedToday,
};
this.response.set('Daily-Uploads-Remaining',

View file

@ -1,5 +1,6 @@
import redis from 'redis';
import models from '../../models';
import { formatFile } from '../../lib/format';
import Uploader from '../../lib/uploader';
@ -20,7 +21,6 @@ export function* post(next) {
uploader.receive();
yield uploader.save();
yield uploader.promise;
uploader.processingEvent();
@ -30,7 +30,7 @@ export function* post(next) {
yield uploader.finalise();
this.status = 201;
this.body = uploader.toJSON();
this.body = formatFile(uploader.file);
uploader.completeEvent();
uploader.malwareScan();
@ -38,67 +38,56 @@ export function* post(next) {
export function* list() {
const Files = this.db.Files;
let status = 'active';
if (this.request.query.trashed) {
status = 'trashed';
} else if (this.request.query.all) {
status = { $in: ['active', 'trashed'] };
}
let limit = 20;
if (this.request.query.perpage === '0') {
limit = false;
limit = 1000;
} else if (this.request.query.perpage > 0) {
limit = parseInt(this.request.query.perpage / 1, 10);
}
let skip = 0;
let offset = 0;
if (this.request.query.page) {
skip = parseInt(this.request.query.page - 1, 10) * limit;
offset = parseInt(this.request.query.page - 1, 10) * limit;
}
const queryOptions = {
limit, skip, sort: [['time_added', 'desc']],
hint: {
owner: 1, status: 1, time_added: -1,
const files = yield models.file.findAll({
where: {
userId: this.user.id,
processed: true,
},
};
order: '"createdAt" DESC',
offset,
limit,
});
const userFiles = yield Files.find({
owner: this.user.id, status }, queryOptions).toArray();
this.statsd.incr('file.list', 1);
this.body = userFiles.map(formatFile);
this.body = files.map(formatFile);
}
export function* get() {
const Files = this.db.Files;
const Users = this.db.Users;
const file = yield Files.findOne({ _id: this.params.id,
status: { $in: ['active', 'uploading'] } });
const file = yield models.file.findOne({
where: {
id: this.params.id,
},
});
this.assert(file, 404, '{"error": {"message": "File not found", "code": 604}}');
const user = yield Users.findOne({ _id: file.owner });
const user = yield file.getUser();
this.assert(user && !user.banned, 404, '{"error": {"message": "File not found", "code": 604}}');
this.statsd.incr('file.get', 1);
this.body = formatFile(file);
}
export function* put() {
if (this.request.body.trashed) {
const Files = this.db.Files;
const status = this.request.body.trashed ? 'trashed' : 'active';
yield Files.updateOne({ _id: this.params.id, owner: this.user.id },
{ $set: { status } }, { w: 1 });
}
}
export function* del() {
yield this.db.Files.updateOne({ _id: this.params.id, owner: this.db.objectId(this.user.id) },
{ $set: { status: 'deleted' } }, { w: 1 });
const file = yield models.file.findOne({
where: {
id: this.params.id,
userId: this.user.id,
},
});
this.assert(file, 401, '{"error": {"message": "File not found", "code": 604}}');
yield file.destroy();
const event = { type: 'file-deleted', data: { id: this.params.id } };
yield this.redis.publish(`/file/${this.params.id}`, JSON.stringify(event));
yield this.redis.publish(`/user/${this.user.id}`, JSON.stringify(event));

85
api/routes/pro.js Normal file
View file

@ -0,0 +1,85 @@
import path from 'path';
import views from 'co-views';
const render = views(path.join(__dirname, '/../views'), { default: 'ejs' });
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
import sendgridInit from 'sendgrid';
const sendgrid = sendgridInit(process.env.SENDGRID_KEY);
import models from '../../models';
const from = process.env.EMAIL_FROM;
const fromname = process.env.EMAIL_NAME;
export function* create() {
const stripeToken = this.request.body.stripeToken;
const ip = this.request.headers['x-real-ip'] || this.req.connection.remoteAddress;
const createCustomer = {
card: stripeToken.id,
plan: 'usd_monthly',
email: this.user.email,
};
const customer = yield stripe.customers.create(createCustomer);
this.assert(customer.subscription.status === 'active', 400, '{"status": "error"}');
delete customer.subscriptions;
const user = yield models.user.findById(this.user.id);
user.plan = 'Pro';
yield user.save();
const transaction = yield models.transaction.create({
userId: this.user.id,
amount: customer.subscription.plan.amount,
description: customer.subscription.plan.name,
data: customer,
type: 'direct',
ip,
});
yield transaction.save();
this.user.plan = 'Pro';
this.body = { status: 'active' };
const html = yield render('email/inlined/pro');
const text = `Hey, thanks for upgrading to Hostr Pro!
You've signed up for Hostr Pro Monthly at $6/Month.
Jonathan Cremin, Hostr Founder
`;
const mail = new sendgrid.Email({
to: this.user.email,
subject: 'Hostr Pro',
from,
fromname,
html,
text,
});
mail.addCategory('pro-upgrade');
sendgrid.send(mail);
}
export function* cancel() {
const user = yield models.user.findById(this.user.id);
const transactions = yield user.getTransactions();
const transaction = transactions[0];
yield stripe.customers.cancelSubscription(
transaction.data.id,
transaction.data.subscription.id,
{ at_period_end: false }
);
user.plan = 'Free';
yield user.save();
this.user.plan = 'Free';
this.body = { status: 'inactive' };
}

View file

@ -2,6 +2,7 @@ import uuid from 'node-uuid';
import redis from 'redis';
import co from 'co';
import passwords from 'passwords';
import models from '../../models';
import debugname from 'debug';
const debug = debugname('hostr-api:user');
@ -19,17 +20,19 @@ export function* token() {
}
export function* transaction() {
const Transactions = this.db.Transactions;
const transactions = yield Transactions.find({ user_id: this.user.id }).toArray();
const transactions = yield models.transaction.findAll({
where: {
userId: this.user.id,
},
});
this.body = transactions.map((transaction) => { // eslint-disable-line no-shadow
const type = transaction.paypal ? 'paypal' : 'direct';
this.body = transactions.map((item) => {
return {
id: transaction._id,
amount: transaction.paypal ? transaction.amount : transaction.amount / 100,
date: transaction.date,
description: transaction.desc,
type,
id: item.id,
amount: item.amount / 100,
date: item.date,
description: item.description,
type: 'direct',
};
});
}
@ -39,23 +42,18 @@ export function* settings() {
'{"error": {"message": "Current Password required to update account.", "code": 612}}');
this.assert(this.request.body.current_password, 400,
'{"error": {"message": "Current Password required to update account.", "code": 612}}');
const Users = this.db.Users;
const user = yield Users.findOne({ _id: this.user.id });
this.assert(yield passwords.match(this.request.body.current_password, user.salted_password), 400,
const user = yield models.user.findById(this.user.id);
this.assert(yield passwords.match(this.request.body.current_password, user.password), 400,
'{"error": {"message": "Incorrect password", "code": 606}}');
const data = {};
if (this.request.body.email && this.request.body.email !== user.email) {
data.email = this.request.body.email;
if (!user.activated_email) {
data.activated_email = user.email; // eslint-disable-line camelcase
}
user.email = this.request.body.email;
}
if (this.request.body.new_password) {
this.assert(this.request.body.new_password.length >= 7, 400,
'{"error": {"message": "Password must be 7 or more characters long.", "code": 606}}');
data.salted_password = yield passwords.hash(this.request.body.new_password);
user.password = yield passwords.hash(this.request.body.new_password);
}
Users.updateOne({ _id: user._id }, { $set: data });
yield user.save();
this.body = {};
}

View file

@ -0,0 +1,101 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml" style="box-sizing: border-box; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; margin: 0; padding: 0">
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Hostr Pro</title>
</head>
<body style="-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; background: #f6f6f6; box-sizing: border-box; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; height: 100%; line-height: 1.6; margin: 0; padding: 0; width: 100% !important" bgcolor="#f6f6f6"><style type="text/css">
img {
max-width: 100%;
}
body {
-webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; height: 100%; line-height: 1.6;
}
body {
background-color: #f6f6f6;
}
@media only screen and (max-width: 640px) {
h1 {
font-weight: 600 !important; margin: 20px 0 5px !important;
}
h2 {
font-weight: 600 !important; margin: 20px 0 5px !important;
}
h3 {
font-weight: 600 !important; margin: 20px 0 5px !important;
}
h4 {
font-weight: 600 !important; margin: 20px 0 5px !important;
}
h1 {
font-size: 22px !important;
}
h2 {
font-size: 18px !important;
}
h3 {
font-size: 16px !important;
}
.container {
width: 100% !important;
}
.content {
padding: 10px !important;
}
.content-wrapper {
padding: 10px !important;
}
.invoice {
width: 100% !important;
}
}
</style>
<table class="body-wrap" style="background: #f6f6f6; box-sizing: border-box; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; margin: 0; padding: 0; width: 100%" bgcolor="#f6f6f6">
<tr style="box-sizing: border-box; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; margin: 0; padding: 0">
<td align="center" width="100%" colspan="2" style="box-sizing: border-box; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; margin: 0; padding: 0; vertical-align: top" valign="top"><img src="https://hostr.co/images/logo-dark-r.png" height="22" width="26" class="logo" style="box-sizing: border-box; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; margin: 40px 0 20px; max-width: 100%; padding: 0" /></td>
</tr>
<tr style="box-sizing: border-box; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; margin: 0; padding: 0">
<td style="box-sizing: border-box; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; margin: 0; padding: 0; vertical-align: top" valign="top"></td>
<td class="container" width="600" style="box-sizing: border-box; clear: both !important; display: block !important; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; margin: 0 auto; max-width: 600px !important; padding: 0; vertical-align: top" valign="top">
<div class="content" style="box-sizing: border-box; display: block; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; margin: 0 auto; max-width: 600px; padding: 20px">
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="background: #fff; border-radius: 3px; border: 1px solid #e9e9e9; box-sizing: border-box; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; margin: 0; padding: 0" bgcolor="#fff">
<tr style="box-sizing: border-box; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; margin: 0; padding: 0">
<td class="content-wrap" style="box-sizing: border-box; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; margin: 0; padding: 20px; vertical-align: top" valign="top">
<table width="100%" cellpadding="0" cellspacing="0" style="box-sizing: border-box; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; margin: 0; padding: 0">
<tr style="box-sizing: border-box; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; margin: 0; padding: 0">
<td class="content-block" style="box-sizing: border-box; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; margin: 0; padding: 0 0 20px; vertical-align: top" valign="top">
Thanks for upgrading to Hostr Pro!
</td>
</tr>
<tr style="box-sizing: border-box; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; margin: 0; padding: 0">
<td class="content-block" style="box-sizing: border-box; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; margin: 0; padding: 0 0 20px; vertical-align: top" valign="top">
You've signed up for Hostr Pro Monthly at $6/month
</td>
</tr>
<tr style="box-sizing: border-box; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; margin: 0; padding: 0">
<td class="content-block" style="box-sizing: border-box; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; margin: 0; padding: 0 0 20px; vertical-align: top" valign="top">
— Jonathan Cremin, Hostr Founder
</td>
</tr>
</table>
</td>
</tr>
</table>
<div class="footer" style="box-sizing: border-box; clear: both; color: #999; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; margin: 0; padding: 20px; width: 100%">
<table width="100%" style="box-sizing: border-box; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; margin: 0; padding: 0">
<tr style="box-sizing: border-box; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; margin: 0; padding: 0">
<td class="aligncenter content-block" style="box-sizing: border-box; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 12px; margin: 0; padding: 0 0 20px; text-align: center; vertical-align: top" align="center" valign="top">Follow <a href="http://twitter.com/gethostr" style="box-sizing: border-box; color: #999; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 12px; margin: 0; padding: 0; text-decoration: underline">@gethostr</a> on Twitter.</td>
</tr>
</table>
</div></div>
</td>
<td style="box-sizing: border-box; font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; font-size: 14px; margin: 0; padding: 0; vertical-align: top" valign="top"></td>
</tr>
</table>
</body>
</html>

56
api/views/email/pro.html Normal file
View file

@ -0,0 +1,56 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Hostr Pro</title>
<link href="style.css" media="all" rel="stylesheet" type="text/css" />
</head>
<body>
<table class="body-wrap">
<tr>
<td align="center" width="100%" colspan="2"><img src="https://hostr.co/images/logo-dark-r.png" height="22" width="26" class="logo" /></td>
</tr>
<tr>
<td></td>
<td class="container" width="600">
<div class="content">
<table class="main" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td class="content-wrap">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td class="content-block">
Thanks for upgrading to Hostr Pro!
</td>
</tr>
<tr>
<td class="content-block">
You've signed up for Hostr Pro Monthly at $6/month
</td>
</tr>
<tr>
<td class="content-block">
&mdash; Jonathan Cremin, Hostr Founder
</td>
</tr>
</table>
</td>
</tr>
</table>
<div class="footer">
<table width="100%">
<tr>
<td class="aligncenter content-block">Follow <a href="http://twitter.com/gethostr">@gethostr</a> on Twitter.</td>
</tr>
</table>
</div></div>
</td>
<td></td>
</tr>
</table>
</body>
</html>

271
api/views/email/style.css Normal file
View file

@ -0,0 +1,271 @@
/* -------------------------------------
GLOBAL
A very basic CSS reset
------------------------------------- */
* {
margin: 0;
padding: 0;
font-family: "Helvetica Neue", "Helvetica", Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
}
img {
max-width: 100%;
}
body {
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 1.6;
}
/* Let's make sure all tables have defaults */
table td {
vertical-align: top;
}
/* -------------------------------------
BODY & CONTAINER
------------------------------------- */
body {
background-color: #f6f6f6;
}
.body-wrap {
background-color: #f6f6f6;
width: 100%;
}
.container {
display: block !important;
max-width: 600px !important;
margin: 0 auto !important;
/* makes it centered */
clear: both !important;
}
.content {
max-width: 600px;
margin: 0 auto;
display: block;
padding: 20px;
}
/* -------------------------------------
HEADER, FOOTER, MAIN
------------------------------------- */
.main {
background: #fff;
border: 1px solid #e9e9e9;
border-radius: 3px;
}
.content-wrap {
padding: 20px;
}
.content-block {
padding: 0 0 20px;
}
.header {
width: 100%;
margin-bottom: 20px;
}
.logo {
margin-top: 40px;
margin-bottom: 20px;
}
.footer {
width: 100%;
clear: both;
color: #999;
padding: 20px;
}
.footer a {
color: #999;
}
.footer p, .footer a, .footer unsubscribe, .footer td {
font-size: 12px;
}
/* -------------------------------------
TYPOGRAPHY
------------------------------------- */
h1, h2, h3 {
font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif;
color: #000;
margin: 40px 0 0;
line-height: 1.2;
font-weight: 400;
}
h1 {
font-size: 32px;
font-weight: 500;
}
h2 {
font-size: 24px;
}
h3 {
font-size: 18px;
}
h4 {
font-size: 14px;
font-weight: 600;
}
p, ul, ol {
margin-bottom: 10px;
font-weight: normal;
}
p li, ul li, ol li {
margin-left: 5px;
list-style-position: inside;
}
/* -------------------------------------
LINKS & BUTTONS
------------------------------------- */
a {
color: #456470;
text-decoration: underline;
}
.btn-primary {
text-decoration: none;
color: #FFF;
background-color: #456470;
border: solid #456470;
/* seems to be a bug stopping this from being applied above */
border-color: #456470;
border-width: 10px 20px;
line-height: 2;
font-weight: bold;
text-align: center;
cursor: pointer;
display: inline-block;
border-radius: 5px;
text-transform: capitalize;
}
/* -------------------------------------
OTHER STYLES THAT MIGHT BE USEFUL
------------------------------------- */
.last {
margin-bottom: 0;
}
.first {
margin-top: 0;
}
.aligncenter {
text-align: center;
}
.alignright {
text-align: right;
}
.alignleft {
text-align: left;
}
.clear {
clear: both;
}
/* -------------------------------------
ALERTS
Change the class depending on warning email, good email or bad email
------------------------------------- */
.alert {
font-size: 16px;
color: #fff;
font-weight: 500;
padding: 20px;
text-align: center;
border-radius: 3px 3px 0 0;
}
.alert a {
color: #fff;
text-decoration: none;
font-weight: 500;
font-size: 16px;
}
.alert.alert-warning {
background: #ff9f00;
}
.alert.alert-bad {
background: #d0021b;
}
.alert.alert-good {
background: #68b90f;
}
/* -------------------------------------
INVOICE
Styles for the billing table
------------------------------------- */
.invoice {
margin: 40px auto;
text-align: left;
width: 80%;
}
.invoice td {
padding: 5px 0;
}
.invoice .invoice-items {
width: 100%;
}
.invoice .invoice-items td {
border-top: #eee 1px solid;
}
.invoice .invoice-items .total td {
border-top: 2px solid #333;
border-bottom: 2px solid #333;
font-weight: 700;
}
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 640px) {
h1, h2, h3, h4 {
font-weight: 600 !important;
margin: 20px 0 5px !important;
}
h1 {
font-size: 22px !important;
}
h2 {
font-size: 18px !important;
}
h3 {
font-size: 16px !important;
}
.container {
width: 100% !important;
}
.content, .content-wrapper {
padding: 10px !important;
}
.invoice {
width: 100% !important;
}
}