Compare commits

..

63 Commits

Author SHA1 Message Date
Johannes Zellner 84153793d9 Add 1.0.1 changes 2017-07-25 22:08:34 +02:00
Girish Ramakrishnan 31ad42501b Use -%> for newline slurping
Fixes #383
2017-07-25 22:08:34 +02:00
Johannes Zellner 42476e10e1 Allow digest to be templated with or without subscription 2017-07-25 22:08:30 +02:00
Girish Ramakrishnan 91e5f850ab Adjust digest wording 2017-07-25 22:08:03 +02:00
Johannes Zellner 6ee383185d Add cron job to send email digest 2017-07-25 22:07:50 +02:00
Johannes Zellner 2fb02d57ed Add cron job to send email digest 2017-07-25 21:52:46 +02:00
Johannes Zellner 6b8edbf4f0 send lastLogin event timestamp with alive status 2017-07-25 19:54:08 +02:00
Girish Ramakrishnan 4d7f9ba9a5 isPaying is not set properly for non-caas 2017-06-21 22:38:39 -07:00
Girish Ramakrishnan 6d0cdc36b2 move getSubscription to appstore.js 2017-06-21 22:17:32 -07:00
Girish Ramakrishnan 79541a68a5 Display and send usernames instead of the email address 2017-06-21 19:34:55 -07:00
Girish Ramakrishnan 845d386478 Grammar 2017-06-21 19:28:38 -07:00
Girish Ramakrishnan 8771de5c12 Minor rewording 2017-06-21 19:14:15 -07:00
Girish Ramakrishnan 76246b2952 Try to fix sporadic mysql startup issue after cloudron-setup 2017-06-21 17:20:02 -07:00
Johannes Zellner f994b68701 wait for dns with the correct zone even on external domain setup 2017-06-21 15:04:39 +02:00
Johannes Zellner 77558c823c Check for subscription right after appstore login 2017-06-21 13:43:34 +02:00
Johannes Zellner dd6a19ea85 get zoneName from domain on migration if not set 2017-06-21 13:41:13 +02:00
Johannes Zellner 16978f8c1a Keep the subdomain as is for non-custom domain cloudrons 2017-06-21 10:23:04 +02:00
Johannes Zellner f311c3da1c Fix explicit zone information in dns setup view 2017-06-21 09:51:35 +02:00
Johannes Zellner 423e355fd6 Add changes 2017-06-21 09:37:34 +02:00
Johannes Zellner 8fadb3badc Use the actual result not the potentially cached value 2017-06-20 13:10:07 +02:00
Johannes Zellner 3845065085 Enable catchall based on subscription status 2017-06-20 12:58:14 +02:00
Johannes Zellner 801d848908 Show hint about subdomain cloudrons in dns setup 2017-06-20 11:56:09 +02:00
Girish Ramakrishnan e6eda1283c Format the combo box better 2017-06-19 23:16:03 -07:00
Girish Ramakrishnan a553755f4a the noop callback will print the error 2017-06-19 22:20:25 -07:00
Girish Ramakrishnan cd52459f05 more descriptive debug 2017-06-19 22:20:25 -07:00
Girish Ramakrishnan 1802201e9e Remove one level of indentation 2017-06-19 22:20:22 -07:00
Johannes Zellner 2d72f49261 Ensure the updatechecker does not prematurely callback
Also add tests and make sure we send update notifications if automatic
updates cannot be applied
2017-06-19 14:34:36 +02:00
Johannes Zellner cd42a6c2ea Send update notifications on the free plan 2017-06-19 13:27:08 +02:00
Johannes Zellner 65f949e669 Add settings.getSubscription() 2017-06-19 13:26:49 +02:00
Johannes Zellner f3fec9a33c Handle 402 response on app installation 2017-06-19 12:17:55 +02:00
Johannes Zellner 13182de57f Appstore login dialog does not exist anymore 2017-06-19 12:06:42 +02:00
Girish Ramakrishnan c33566b553 Add note that LE certs require valid email
part of #338
2017-06-18 17:23:41 -07:00
Johannes Zellner 4faf247898 Add catch-all address interface 2017-06-16 21:04:46 +02:00
Johannes Zellner 9952a986eb Always remind the user that the DNS zone has to be hosted on the provider
Do not use $location as the search() object is not consistent without
the angular router, which is not used here
2017-06-16 21:04:44 +02:00
Girish Ramakrishnan 40aaffe365 tests: Fix usage of settings.setDnsConfig 2017-06-15 20:05:35 -07:00
Girish Ramakrishnan 3745e96a6f domain -> fqdn 2017-06-15 19:56:04 -07:00
Girish Ramakrishnan 157ce06f93 Add zoneName query parameter to dns setup
fixes #110
2017-06-15 19:55:48 -07:00
Girish Ramakrishnan 822dfb8af5 Allow 3rd level domains in UI
part of #110
2017-06-15 19:55:32 -07:00
Girish Ramakrishnan 9ead482dc6 Make verifyDnsConfig take zone name
part of #110
2017-06-15 19:55:24 -07:00
Girish Ramakrishnan 865c0a7aa7 Pass other level domains to dns API backends
part of #110
2017-06-15 19:55:01 -07:00
Girish Ramakrishnan c760c42f92 make waitForDns take zone name argument
part of #110
2017-06-15 19:54:08 -07:00
Girish Ramakrishnan ded31b977e Add config.setFqdn and config.setZoneName
Part of #110
2017-06-15 19:53:20 -07:00
Johannes Zellner 4781c4e364 Deliver empty JSON object on success
This ensures the client does not throw a parsing exception
2017-06-15 07:49:19 -07:00
Johannes Zellner 8e123b017e Add REST wrapper for catchall 2017-06-15 07:49:07 -07:00
Girish Ramakrishnan 658cbcdab9 bump mail container version (catchall support)
part of #33
2017-06-15 07:48:57 -07:00
Girish Ramakrishnan 0cc980f539 Add setting for catch all address
Note that this is not a flag on the mailboxes because we might theoretically
support forwarding to some other external domain in the future.

Part of #33
2017-06-15 07:48:46 -07:00
Girish Ramakrishnan da7648fe3f Match the button text with existing text in the UI 2017-06-14 21:55:17 -07:00
Johannes Zellner 8db1073980 Add changes 2017-06-14 20:29:10 +02:00
Girish Ramakrishnan f74f17af02 fix language 2017-06-13 14:42:30 -07:00
Johannes Zellner 87ca05281d Revert "Always check for updates prior to performing an update"
Lets keep the rest apis more single purpose and offload this case to the
client

This reverts commit 0bddd5a2c6.
2017-06-13 22:58:07 +02:00
Johannes Zellner 9780f77fa8 Ensure we fetch the latest update info
This is to bring the webadmin in sync
2017-06-13 22:51:53 +02:00
Johannes Zellner 0bddd5a2c6 Always check for updates prior to performing an update
This covers the case where the box has not yet received a tarballUrl but
the user already setup a subscription.
2017-06-13 21:42:32 +02:00
Johannes Zellner 20f2a6e4c6 Block updates if sourceTarballUrl is missing 2017-06-13 21:33:03 +02:00
Johannes Zellner 6d47737de7 Remove unused require 2017-06-13 21:14:27 +02:00
Johannes Zellner e8f9552ff9 Remove email modal, it is included in the free plan 2017-06-13 17:26:28 +02:00
Johannes Zellner 9c76c5fc27 Also handle the undecided case 2017-06-13 17:25:59 +02:00
Johannes Zellner f9d5f92397 Align the text with the dialog 2017-06-13 17:23:44 +02:00
Johannes Zellner 3a2a05dfce Change the plan configure label 2017-06-13 17:21:38 +02:00
Johannes Zellner 5a291fa2a4 Change subscription dialog to reflect 1.0 2017-06-13 17:08:36 +02:00
Johannes Zellner 84d34ec004 Mention our app request tracker in the missing app dialog 2017-06-13 16:07:21 +02:00
Girish Ramakrishnan 63fca38f0b Add gce to cloudron-setup 2017-06-12 14:05:03 -07:00
Johannes Zellner e3b2799230 Make it clear that the domain, not the server must be hosted on the DNS provider 2017-06-12 10:16:53 +02:00
Girish Ramakrishnan 2efe72519e Can only update using paid plan 2017-06-09 11:05:23 -07:00
53 changed files with 1306 additions and 235 deletions
+10 -2
View File
@@ -885,5 +885,13 @@
* Prevent email view from flickering
* Prepare for 1.0
[0.160.1]
* Improved update notification
[1.0.0]
* Make selfhosting great again
[1.0.1]
* Notification improvements
[1.1.0]
* Add support for email catch-all
* Support Cloudrons on subdomains
+30 -2
View File
@@ -1196,6 +1196,34 @@ Request:
}
```
### Get Catch All Address
GET `/api/v1/settings/catch_all_address` <scope>admin</scope>
Gets the address(es) to which emails addressed to a non-existent mailbox are forwarded to.
Configuring a catch-all address can help avoid losing emails due to misspelling.
Response(200):
```
{
"address": [ <string> ] // array of mailbox names
}
```
### Set Catch All Address
PUT `/api/v1/settings/catch_all_address` <scope>admin</scope>
Sets the address(es) to which emails addressed to a non-existent mailbox are forwarded.
Configuring a catch-all address can help avoid losing emails due to misspelling.
Request:
```
{
"address": [ <string> ] // array of mailbox names
}
```
### Get DNS Configuration
GET `/api/v1/settings/dns_config` <scope>admin</scope> <scope>internal</scope>
@@ -1221,7 +1249,7 @@ This is currently internal API and is documented here for completeness.
### Get Email Configuration
GET `/api/v1/settings/mail_config` <scope>admin</scope> <scope>internal</scope>
GET `/api/v1/settings/mail_config` <scope>admin</scope>
Gets the email configuration. The Cloudron has a built-in email server for users.
This configuration can be used to disable the server. Note that the Cloudron will
@@ -1236,7 +1264,7 @@ Response(200):
### Set Email Configuration
POST `/api/v1/settings/mail_config` <scope>admin</scope> <scope>internal</scope>
POST `/api/v1/settings/mail_config` <scope>admin</scope>
Sets the email configuration. The Cloudron has a built-in email server for users.
This configuration can be used to enable or disable the email server. Note that
+3 -1
View File
@@ -105,6 +105,7 @@ if [[ -z "${dataJson}" ]]; then
"${provider}" != "azure" && \
"${provider}" != "digitalocean" && \
"${provider}" != "ec2" && \
"${provider}" != "gce" && \
"${provider}" != "lightsail" && \
"${provider}" != "linode" && \
"${provider}" != "ovh" && \
@@ -113,7 +114,7 @@ if [[ -z "${dataJson}" ]]; then
"${provider}" != "vultr" && \
"${provider}" != "generic" \
]]; then
echo "--provider must be one of: azure, digitalocean, ec2, lightsail, linode, ovh, rosehosting, scaleway, vultr or generic"
echo "--provider must be one of: azure, digitalocean, ec2, gce, lightsail, linode, ovh, rosehosting, scaleway, vultr or generic"
exit 1
fi
@@ -281,5 +282,6 @@ fi
if [[ "${rebootServer}" == "true" ]]; then
echo -e "\n\nRebooting this server now to let bootloader changes take effect.\n"
systemctl stop mysql # sometimes mysql ends up having corrupt privilege tables
systemctl reboot
fi
+23 -1
View File
@@ -4,6 +4,8 @@ exports = module.exports = {
purchase: purchase,
unpurchase: unpurchase,
getSubscription: getSubscription,
sendAliveStatus: sendAliveStatus,
getAppUpdate: getAppUpdate,
@@ -69,6 +71,25 @@ function getAppstoreConfig(callback) {
}
}
function getSubscription(callback) {
assert.strictEqual(typeof callback, 'function');
getAppstoreConfig(function (error, appstoreConfig) {
if (error) return callback(error);
const url = config.apiServerOrigin() + '/api/v1/users/' + appstoreConfig.userId + '/cloudrons/' + appstoreConfig.cloudronId + '/subscription';
superagent.get(url).query({ accessToken: appstoreConfig.token }).timeout(30 * 1000).end(function (error, result) {
if (error && !error.response) return callback(new AppstoreError(AppstoreError.EXTERNAL_ERROR, error.message));
if (result.statusCode === 401) return callback(new AppstoreError(AppstoreError.EXTERNAL_ERROR, 'invalid appstore token'));
if (result.statusCode === 403) return callback(new AppstoreError(AppstoreError.EXTERNAL_ERROR, 'wrong user'));
if (result.statusCode === 502) return callback(new AppstoreError(AppstoreError.EXTERNAL_ERROR, 'stripe error'));
if (result.statusCode !== 200) return callback(new AppstoreError(AppstoreError.EXTERNAL_ERROR, 'unknown error'));
callback(null, result.body.subscription);
});
});
}
function purchase(appId, appstoreId, callback) {
assert.strictEqual(typeof appId, 'string');
assert.strictEqual(typeof appstoreId, 'string');
@@ -83,9 +104,10 @@ function purchase(appId, appstoreId, callback) {
var data = { appstoreId: appstoreId };
superagent.post(url).send(data).query({ accessToken: appstoreConfig.token }).timeout(30 * 1000).end(function (error, result) {
if (error && !error.response) return callback(new AppstoreError(AppstoreError.EXTERNAL_ERROR, error));
if (error && !error.response) return callback(new AppstoreError(AppstoreError.EXTERNAL_ERROR, error.message));
if (result.statusCode === 404) return callback(new AppstoreError(AppstoreError.NOT_FOUND));
if (result.statusCode === 403 || result.statusCode === 401) return callback(new AppstoreError(AppstoreError.BILLING_REQUIRED));
if (result.statusCode === 402) return callback(new AppstoreError(AppstoreError.BILLING_REQUIRED, result.body.message));
if (result.statusCode !== 201 && result.statusCode !== 200) return callback(new AppstoreError(AppstoreError.EXTERNAL_ERROR, util.format('App purchase failed. %s %j', result.status, result.body)));
callback(null);
+13 -5
View File
@@ -58,6 +58,7 @@ var appdb = require('./appdb.js'),
subdomains = require('./subdomains.js'),
superagent = require('superagent'),
sysinfo = require('./sysinfo.js'),
tld = require('tldjs'),
tokendb = require('./tokendb.js'),
updateChecker = require('./updatechecker.js'),
user = require('./user.js'),
@@ -165,18 +166,24 @@ function onDomainConfigured(callback) {
], callback);
}
function dnsSetup(dnsConfig, domain, callback) {
function dnsSetup(dnsConfig, domain, zoneName, callback) {
assert.strictEqual(typeof dnsConfig, 'object');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof zoneName, 'string');
assert.strictEqual(typeof callback, 'function');
if (config.fqdn()) return callback(new CloudronError(CloudronError.ALREADY_SETUP));
settings.setDnsConfig(dnsConfig, domain, function (error) {
if (!zoneName) zoneName = tld.getDomain(domain) || '';
debug('dnsSetup: Setting up Cloudron with domain %s and zone %s', domain, zoneName);
settings.setDnsConfig(dnsConfig, domain, zoneName, function (error) {
if (error && error.reason === SettingsError.BAD_FIELD) return callback(new CloudronError(CloudronError.BAD_FIELD, error.message));
if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error));
config.set('fqdn', domain); // set fqdn only after dns config is valid, otherwise cannot re-setup if we failed
config.setFqdn(domain); // set fqdn only after dns config is valid, otherwise cannot re-setup if we failed
config.setZoneName(zoneName);
async.series([ // do not block
onDomainConfigured,
@@ -615,6 +622,7 @@ function updateToLatest(auditSource, callback) {
var boxUpdateInfo = updateChecker.getUpdateInfo().box;
if (!boxUpdateInfo) return callback(new CloudronError(CloudronError.ALREADY_UPTODATE, 'No update available'));
if (!boxUpdateInfo.sourceTarballUrl) return callback(new CloudronError(CloudronError.BAD_STATE, 'No automatic update available'));
// check if this is just a version number change
if (config.version().match(/[-+]/) !== null && config.version().replace(/[-+].*/, '') === boxUpdateInfo.version) {
@@ -854,9 +862,9 @@ function migrate(options, callback) {
if (!options.domain) return doMigrate(options, callback);
var dnsConfig = _.pick(options, 'domain', 'provider', 'accessKeyId', 'secretAccessKey', 'region', 'endpoint', 'token');
var dnsConfig = _.pick(options, 'domain', 'provider', 'accessKeyId', 'secretAccessKey', 'region', 'endpoint', 'token', 'zoneName');
settings.setDnsConfig(dnsConfig, options.domain, function (error) {
settings.setDnsConfig(dnsConfig, options.domain, options.zoneName || tld.getDomain(options.domain), function (error) {
if (error && error.reason === SettingsError.BAD_FIELD) return callback(new CloudronError(CloudronError.BAD_FIELD, error.message));
if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error));
+20 -7
View File
@@ -17,6 +17,7 @@ exports = module.exports = {
apiServerOrigin: apiServerOrigin,
webServerOrigin: webServerOrigin,
fqdn: fqdn,
setFqdn: setFqdn,
token: token,
version: version,
setVersion: setVersion,
@@ -31,6 +32,7 @@ exports = module.exports = {
mailFqdn: mailFqdn,
appFqdn: appFqdn,
zoneName: zoneName,
setZoneName: setZoneName,
isDemo: isDemo,
@@ -46,6 +48,7 @@ var assert = require('assert'),
fs = require('fs'),
path = require('path'),
safe = require('safetydance'),
tld = require('tldjs'),
_ = require('underscore');
var homeDir = process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE;
@@ -74,6 +77,7 @@ function _reset(callback) {
function initConfig() {
// setup defaults
data.fqdn = 'localhost';
data.zoneName = '';
data.token = null;
data.version = null;
@@ -143,10 +147,26 @@ function webServerOrigin() {
return get('webServerOrigin');
}
function setFqdn(fqdn) {
set('fqdn', fqdn);
}
function fqdn() {
return get('fqdn');
}
function setZoneName(zone) {
set('zoneName', zone);
}
function zoneName() {
var zone = get('zoneName');
if (zone) return zone;
// TODO: move this to migration code path instead
return tld.getDomain(fqdn()) || '';
}
// keep this in sync with start.sh admin.conf generation code
function appFqdn(location) {
assert.strictEqual(typeof location, 'string');
@@ -191,13 +211,6 @@ function isCustomDomain() {
return get('isCustomDomain');
}
function zoneName() {
if (isCustomDomain()) return fqdn(); // the appstore sets up the custom domain as a zone
// for shared domain name, strip out the hostname
return fqdn().substr(fqdn().indexOf('.') + 1);
}
function database() {
return get('database');
}
+22 -9
View File
@@ -15,6 +15,7 @@ var apps = require('./apps.js'),
constants = require('./constants.js'),
CronJob = require('cron').CronJob,
debug = require('debug')('box:cron'),
digest = require('./digest.js'),
eventlog = require('./eventlog.js'),
janitor = require('./janitor.js'),
scheduler = require('./scheduler.js'),
@@ -22,20 +23,21 @@ var apps = require('./apps.js'),
semver = require('semver'),
updateChecker = require('./updatechecker.js');
var gAutoupdaterJob = null,
gBoxUpdateCheckerJob = null,
var gAliveJob = null, // send periodic stats
gAppUpdateCheckerJob = null,
gHeartbeatJob = null, // for CaaS health check
gAliveJob = null, // send periodic stats
gAutoupdaterJob = null,
gBackupJob = null,
gCleanupTokensJob = null,
gCleanupBackupsJob = null,
gDockerVolumeCleanerJob = null,
gSchedulerSyncJob = null,
gBoxUpdateCheckerJob = null,
gCertificateRenewJob = null,
gCheckDiskSpaceJob = null,
gCleanupBackupsJob = null,
gCleanupEventlogJob = null,
gDynamicDNSJob = null;
gCleanupTokensJob = null,
gDockerVolumeCleanerJob = null,
gDynamicDNSJob = null,
gHeartbeatJob = null, // for CaaS health check
gSchedulerSyncJob = null,
gDigestEmailJob = null;
var NOOP_CALLBACK = function (error) { if (error) console.error(error); };
var AUDIT_SOURCE = { userId: null, username: 'cron' };
@@ -173,6 +175,14 @@ function recreateJobs(tz) {
start: true,
timeZone: tz
});
if (gDigestEmailJob) gDigestEmailJob.stop();
gDigestEmailJob = new CronJob({
cronTime: '00 00 * * * 3', // every tuesday
onTick: digest.maybeSend,
start: true,
timeZone: tz
});
}
function autoupdatePatternChanged(pattern) {
@@ -272,5 +282,8 @@ function uninitialize(callback) {
if (gDynamicDNSJob) gDynamicDNSJob.stop();
gDynamicDNSJob = null;
if (gDigestEmailJob) gDigestEmailJob.stop();
gDigestEmailJob = null;
callback();
}
+64
View File
@@ -0,0 +1,64 @@
'use strict';
var appstore = require('./appstore.js'),
debug = require('debug')('box:digest'),
eventlog = require('./eventlog.js'),
updatechecker = require('./updatechecker.js'),
mailer = require('./mailer.js'),
settings = require('./settings.js');
var NOOP_CALLBACK = function (error) { if (error) debug(error); };
exports = module.exports = {
maybeSend: maybeSend
};
function maybeSend(callback) {
callback = callback || NOOP_CALLBACK;
settings.getEmailDigest(function (error, enabled) {
if (error) return callback(error);
if (!enabled) {
debug('Email digest is disabled');
return callback();
}
var updateInfo = updatechecker.getUpdateInfo();
var pendingAppUpdates = updateInfo.apps || {};
pendingAppUpdates = Object.keys(pendingAppUpdates).map(function (key) { return pendingAppUpdates[key]; });
appstore.getSubscription(function (error, result) {
if (error) debug('Error getting subscription:', error);
var hasSubscription = result && result.plan.id !== 'free' && result.plan.id !== 'undecided';
eventlog.getByActionLastWeek(eventlog.ACTION_APP_UPDATE, function (error, appUpdates) {
if (error) return callback(error);
eventlog.getByActionLastWeek(eventlog.ACTION_UPDATE, function (error, boxUpdates) {
if (error) return callback(error);
var info = {
hasSubscription: hasSubscription,
pendingAppUpdates: pendingAppUpdates,
pendingBoxUpdate: updateInfo.box || null,
finishedAppUpdates: (appUpdates || []).map(function (e) { return e.data; }),
finishedBoxUpdates: (boxUpdates || []).map(function (e) { return e.data; })
};
if (info.pendingAppUpdates.length || info.pendingBoxUpdate || info.finishedAppUpdates.length || info.finishedBoxUpdates.length) {
debug('maybeSend: sending digest email', info);
mailer.sendDigest(info);
} else {
debug('maybeSend: nothing happened, NOT sending digest email');
}
callback();
});
});
});
});
}
+2 -1
View File
@@ -112,9 +112,10 @@ function del(dnsConfig, zoneName, subdomain, type, values, callback) {
});
}
function verifyDnsConfig(dnsConfig, domain, ip, callback) {
function verifyDnsConfig(dnsConfig, domain, zoneName, ip, callback) {
assert.strictEqual(typeof dnsConfig, 'object');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof zoneName, 'string');
assert.strictEqual(typeof ip, 'string');
assert.strictEqual(typeof callback, 'function');
+7 -4
View File
@@ -180,9 +180,10 @@ function del(dnsConfig, zoneName, subdomain, type, values, callback) {
});
}
function verifyDnsConfig(dnsConfig, domain, ip, callback) {
function verifyDnsConfig(dnsConfig, fqdn, zoneName, ip, callback) {
assert.strictEqual(typeof dnsConfig, 'object');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof fqdn, 'string');
assert.strictEqual(typeof zoneName, 'string');
assert.strictEqual(typeof ip, 'string');
assert.strictEqual(typeof callback, 'function');
@@ -193,7 +194,7 @@ function verifyDnsConfig(dnsConfig, domain, ip, callback) {
if (process.env.BOX_ENV === 'test') return callback(null, credentials); // this shouldn't be here
dns.resolveNs(domain, function (error, nameservers) {
dns.resolveNs(zoneName, function (error, nameservers) {
if (error && error.code === 'ENOTFOUND') return callback(new SubdomainError(SubdomainError.BAD_FIELD, 'Unable to resolve nameservers for this domain'));
if (error || !nameservers) return callback(new SubdomainError(SubdomainError.BAD_FIELD, error ? error.message : 'Unable to get nameservers'));
@@ -202,7 +203,9 @@ function verifyDnsConfig(dnsConfig, domain, ip, callback) {
return callback(new SubdomainError(SubdomainError.BAD_FIELD, 'Domain nameservers are not set to Digital Ocean'));
}
upsert(credentials, domain, constants.ADMIN_LOCATION, 'A', [ ip ], function (error, changeId) {
const name = constants.ADMIN_LOCATION + (fqdn === zoneName ? '' : '.' + fqdn.slice(0, - zoneName.length - 1));
upsert(credentials, zoneName, name, 'A', [ ip ], function (error, changeId) {
if (error) return callback(error);
debug('verifyDnsConfig: A record added with change id %s', changeId);
+2 -1
View File
@@ -56,9 +56,10 @@ function del(dnsConfig, zoneName, subdomain, type, values, callback) {
callback(new Error('not implemented'));
}
function verifyDnsConfig(dnsConfig, domain, ip, callback) {
function verifyDnsConfig(dnsConfig, domain, zoneName, ip, callback) {
assert.strictEqual(typeof dnsConfig, 'object');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof zoneName, 'string');
assert.strictEqual(typeof ip, 'string');
assert.strictEqual(typeof callback, 'function');
+3 -2
View File
@@ -51,15 +51,16 @@ function del(dnsConfig, zoneName, subdomain, type, values, callback) {
return callback();
}
function verifyDnsConfig(dnsConfig, domain, ip, callback) {
function verifyDnsConfig(dnsConfig, domain, zoneName, ip, callback) {
assert.strictEqual(typeof dnsConfig, 'object');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof zoneName, 'string');
assert.strictEqual(typeof ip, 'string');
assert.strictEqual(typeof callback, 'function');
var adminDomain = constants.ADMIN_LOCATION + '.' + domain;
dns.resolveNs(domain, function (error, nameservers) {
dns.resolveNs(zoneName, function (error, nameservers) {
if (error || !nameservers) return callback(new SubdomainError(SubdomainError.BAD_FIELD, 'Unable to get nameservers'));
async.every(nameservers, function (nameserver, everyNsCallback) {
+4 -2
View File
@@ -46,8 +46,9 @@ function del(dnsConfig, zoneName, subdomain, type, values, callback) {
return callback();
}
function waitForDns(domain, value, type, options, callback) {
function waitForDns(domain, zoneName, value, type, options, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof zoneName, 'string');
assert(typeof value === 'string' || util.isRegExp(value));
assert(type === 'A' || type === 'CNAME' || type === 'TXT');
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
@@ -56,9 +57,10 @@ function waitForDns(domain, value, type, options, callback) {
callback();
}
function verifyDnsConfig(dnsConfig, domain, ip, callback) {
function verifyDnsConfig(dnsConfig, domain, zoneName, ip, callback) {
assert.strictEqual(typeof dnsConfig, 'object');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof zoneName, 'string');
assert.strictEqual(typeof ip, 'string');
assert.strictEqual(typeof callback, 'function');
+8 -5
View File
@@ -218,9 +218,10 @@ function del(dnsConfig, zoneName, subdomain, type, values, callback) {
});
}
function verifyDnsConfig(dnsConfig, domain, ip, callback) {
function verifyDnsConfig(dnsConfig, fqdn, zoneName, ip, callback) {
assert.strictEqual(typeof dnsConfig, 'object');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof fqdn, 'string');
assert.strictEqual(typeof zoneName, 'string');
assert.strictEqual(typeof ip, 'string');
assert.strictEqual(typeof callback, 'function');
@@ -234,11 +235,11 @@ function verifyDnsConfig(dnsConfig, domain, ip, callback) {
if (process.env.BOX_ENV === 'test') return callback(null, credentials); // this shouldn't be here
dns.resolveNs(domain, function (error, nameservers) {
dns.resolveNs(zoneName, function (error, nameservers) {
if (error && error.code === 'ENOTFOUND') return callback(new SubdomainError(SubdomainError.BAD_FIELD, 'Unable to resolve nameservers for this domain'));
if (error || !nameservers) return callback(new SubdomainError(SubdomainError.BAD_FIELD, error ? error.message : 'Unable to get nameservers'));
getHostedZone(credentials, domain, function (error, zone) {
getHostedZone(credentials, zoneName, function (error, zone) {
if (error) return callback(error);
if (!_.isEqual(zone.DelegationSet.NameServers.sort(), nameservers.sort())) {
@@ -246,7 +247,9 @@ function verifyDnsConfig(dnsConfig, domain, ip, callback) {
return callback(new SubdomainError(SubdomainError.BAD_FIELD, 'Domain nameservers are not set to Route53'));
}
upsert(credentials, domain, constants.ADMIN_LOCATION, 'A', [ ip ], function (error, changeId) {
const name = constants.ADMIN_LOCATION + (fqdn === zoneName ? '' : '.' + fqdn.slice(0, - zoneName.length - 1));
upsert(credentials, zoneName, name, 'A', [ ip ], function (error, changeId) {
if (error) return callback(error);
debug('verifyDnsConfig: A record added with change id %s', changeId);
+4 -4
View File
@@ -8,7 +8,6 @@ var assert = require('assert'),
dig = require('../dig.js'),
dns = require('dns'),
SubdomainError = require('../subdomains.js').SubdomainError,
tld = require('tldjs'),
util = require('util');
function isChangeSynced(domain, value, type, nameserver, callback) {
@@ -38,7 +37,7 @@ function isChangeSynced(domain, value, type, nameserver, callback) {
}
if (!answer || answer.length === 0) {
debug('bad answer from nameserver %s (%s) resolving %s (%s): %j', nameserver, nsIp, domain, type, answer);
debug('bad answer from nameserver %s (%s) resolving %s (%s)', nameserver, nsIp, domain, type);
return iteratorCallback(null, false);
}
@@ -60,18 +59,19 @@ function isChangeSynced(domain, value, type, nameserver, callback) {
}
// check if IP change has propagated to every nameserver
function waitForDns(domain, value, type, options, callback) {
function waitForDns(domain, zoneName, value, type, options, callback) {
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof zoneName, 'string');
assert(typeof value === 'string' || util.isRegExp(value));
assert(type === 'A' || type === 'CNAME' || type === 'TXT');
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
assert.strictEqual(typeof callback, 'function');
var zoneName = tld.getDomain(domain);
if (typeof value === 'string') {
// http://stackoverflow.com/questions/3561493/is-there-a-regexp-escape-function-in-javascript
value = new RegExp('^' + value.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&') + '$');
}
debug('waitForIp: domain %s to be %s in zone %s.', domain, value, zoneName);
var attempt = 1;
+12
View File
@@ -6,6 +6,7 @@ exports = module.exports = {
add: add,
get: get,
getAllPaged: getAllPaged,
getByActionLastWeek: getByActionLastWeek,
cleanup: cleanup,
// keep in sync with webadmin index.js filter and CLI tool
@@ -103,6 +104,17 @@ function getAllPaged(action, search, page, perPage, callback) {
});
}
function getByActionLastWeek(action, callback) {
assert(typeof action === 'string' || action === null);
assert.strictEqual(typeof callback, 'function');
eventlogdb.getByActionLastWeek(action, function (error, boxes) {
if (error) return callback(new EventLogError(EventLogError.INTERNAL_ERROR, error));
callback(null, boxes);
});
}
function cleanup(callback) {
callback = callback || NOOP_CALLBACK;
+15
View File
@@ -3,6 +3,7 @@
exports = module.exports = {
get: get,
getAllPaged: getAllPaged,
getByActionLastWeek: getByActionLastWeek,
add: add,
count: count,
delByCreationTime: delByCreationTime,
@@ -71,6 +72,20 @@ function getAllPaged(action, search, page, perPage, callback) {
});
}
function getByActionLastWeek(action, callback) {
assert(typeof action === 'string' || action === null);
assert.strictEqual(typeof callback, 'function');
var query = 'SELECT ' + EVENTLOGS_FIELDS + ' FROM eventlog WHERE action=? AND creationTime >= DATE_SUB(NOW(), INTERVAL 1 WEEK) ORDER BY creationTime DESC';
database.query(query, [ action ], function (error, results) {
if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error));
results.forEach(postProcess);
callback(null, results);
});
}
function add(id, action, source, data, callback) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof action, 'string');
+1 -1
View File
@@ -18,7 +18,7 @@ exports = module.exports = {
'postgresql': { repo: 'cloudron/postgresql', tag: 'cloudron/postgresql:0.17.0' },
'mongodb': { repo: 'cloudron/mongodb', tag: 'cloudron/mongodb:0.13.0' },
'redis': { repo: 'cloudron/redis', tag: 'cloudron/redis:0.11.0' },
'mail': { repo: 'cloudron/mail', tag: 'cloudron/mail:0.32.0' },
'mail': { repo: 'cloudron/mail', tag: 'cloudron/mail:0.33.0' },
'graphite': { repo: 'cloudron/graphite', tag: 'cloudron/graphite:0.11.0' }
}
};
+17 -22
View File
@@ -2,21 +2,17 @@
Dear <%= cloudronName %> Admin,
Cloudron Version <%= newBoxVersion %> is now available!
Version <%= newBoxVersion %> for Cloudron <%= fqdn %> is now available!
Your Cloudron will update to 1.0 once you have selected a plan (https://cloudron.io/pricing.html).
Your Cloudron will update automatically tonight. Alternately, update immediately at <%= webadminUrl %>.
With a paid plan, you get continuous updates for the Cloudron and apps just like you had until now.
This ensures you are running the latest versions of apps and keeps your server secure. All paid
plans come with support via email (support@cloudron.io) and live chat (https://chat.cloudron.io).
You can read more about our pricing changes in our blog at https://cloudron.io/blog/2017-06-06-pricing.html.
Visit our pricing page https://cloudron.io/pricing.html for pricing information.
Visit your Cloudron at <%= webadminUrl %> to perform the update.
Changelog:
<% for (var i = 0; i < changelog.length; i++) { %>
* <%- changelog[i] %>
<% } %>
Thank you,
Cloudron.io team
your Cloudron
<% } else { %>
@@ -28,22 +24,20 @@ Cloudron.io team
<div style="width: 650px; text-align: left;">
<p>
Cloudron Version <b><%= newBoxVersion %></b> is now available!
Version <b><%= newBoxVersion %></b> for Cloudron <%= fqdn %> is now available!
</p>
<p>
Your Cloudron will update to 1.0 once you have selected a <a href="https://cloudron.io/pricing.html">plan</a>.
</p>
<p>
With a paid plan, you get continuous updates for the Cloudron and apps just like you had until now. This ensures you are running the latest versions of apps and keeps your server secure. All paid plans come with support via <a href="mailto:support@cloudron.io">email</a> and <a target="_blank" href="https://chat.cloudron.io">live chat</a>.
</p>
<p>
You can read more about our pricing changes in our <a href="https://cloudron.io/blog/2017-06-06-pricing.html" target="_blank">blog</a>.
Your Cloudron will update automatically tonight.<br/>
Alternately, update immediately <a href="<%= webadminUrl %>">here</a>.
</p>
<p>
Visit your Cloudron <a href="<%= webadminUrl %>">here</a> to perform the update.
</p>
<h5>Changelog:</h5>
<ul>
<% for (var i = 0; i < changelogHTML.length; i++) { %>
<li><%- changelogHTML[i] %></li>
<% } %>
</ul>
<br/>
<br/>
@@ -58,3 +52,4 @@ Cloudron.io team
<img src="https://analytics.cloudron.io/piwik.php?idsite=2&rec=1&e_c=CloudronEmail&e_a=update" style="border:0" alt="" />
<% } %>
+47
View File
@@ -0,0 +1,47 @@
<% if (format === 'text') { -%>
Dear <%= cloudronName %> Admin,
This is the weekly summary of activities on your Cloudron <%= fqdn %>.
<% if (info.pendingBoxUpdate) { -%>
Cloudron v<%- info.pendingBoxUpdate.version %> is available:
<% for (var i = 0; i < info.pendingBoxUpdate.changelog.length; i++) { -%>
* <%- info.pendingBoxUpdate.changelog[i] %>
<% }} -%>
<% if (info.pendingAppUpdates.length) { -%>
One or more app updates are available:
<% for (var i = 0; i < info.pendingAppUpdates.length; i++) { -%>
- <%= info.pendingAppUpdates[i].manifest.title %> package v<%= info.pendingAppUpdates[i].manifest.version %>
<% for (var j = 0; j < info.pendingAppUpdates[i].manifest.changelog.trim().split('\n').length; j++) { -%>
<%= info.pendingAppUpdates[i].manifest.changelog.trim().split('\n')[j] %>
<% }}} -%>
<% if (info.finishedBoxUpdates.length) { -%>
Cloudron was updated with the following releases:
<% for (var i = 0; i < info.finishedBoxUpdates.length; i++) { -%>
- Version <%= info.finishedBoxUpdates[i].boxUpdateInfo.version %>
<% for (var j = 0; j < info.finishedBoxUpdates[i].boxUpdateInfo.changelog.length; j++) { -%>
* <%= info.finishedBoxUpdates[i].boxUpdateInfo.changelog[j] %>
<% }}} -%>
<% if (info.finishedAppUpdates.length) { -%>
The following apps were updated:
<% for (var i = 0; i < info.finishedAppUpdates.length; i++) { -%>
- <%= info.finishedAppUpdates[i].toManifest.title %> package v<%= info.finishedAppUpdates[i].toManifest.version %>
<% for (var j = 0; j < info.finishedAppUpdates[i].toManifest.changelog.trim().split('\n').length; j++) { -%>
<%= info.finishedAppUpdates[i].toManifest.changelog.trim().split('\n')[j] %>
<% }}} -%>
<% if (!info.hasSubscription) { -%>
*Keep your Cloudron automatically up-to-date and secure by upgrading to a paid plan at* <%= webadminUrl %>/#/settings
<% } -%>
Powered by https://cloudron.io
Sent at: <%= new Date().toUTCString() %>
<% } else { %>
<% } %>
+25
View File
@@ -10,6 +10,7 @@ exports = module.exports = {
passwordReset: passwordReset,
boxUpdateAvailable: boxUpdateAvailable,
appUpdateAvailable: appUpdateAvailable,
sendDigest: sendDigest,
sendInvite: sendInvite,
unexpectedExit: unexpectedExit,
@@ -418,6 +419,30 @@ function appUpdateAvailable(app, updateInfo) {
});
}
function sendDigest(info) {
assert.strictEqual(typeof info, 'object');
getAdminEmails(function (error, adminEmails) {
if (error) return console.log('Error getting admins', error);
settings.getCloudronName(function (error, cloudronName) {
if (error) {
debug(error);
cloudronName = 'Cloudron';
}
var mailOptions = {
from: mailConfig().from,
to: adminEmails.join(', '),
subject: util.format('[%s] Cloudron - Weekly activity digest', config.fqdn()),
text: render('digest.ejs', { fqdn: config.fqdn(), webadminUrl: config.adminOrigin(), cloudronName: cloudronName, info: info, format: 'text' })
};
enqueue(mailOptions);
});
});
}
function outOfDiskSpace(message) {
assert.strictEqual(typeof message, 'string');
+11 -5
View File
@@ -239,16 +239,22 @@ function createMailConfig(callback) {
const mailFqdn = config.adminFqdn();
const alertsFrom = 'no-reply@' + config.fqdn();
debug('createMailConfig: generating mail config');
user.getOwner(function (error, owner) {
var alertsTo = config.provider() === 'caas' ? [ 'support@cloudron.io' ] : [ ];
alertsTo.concat(error ? [] : owner.email).join(',');
if (!safe.fs.writeFileSync(paths.ADDON_CONFIG_DIR + '/mail/mail.ini',
`mail_domain=${fqdn}\nmail_server_name=${mailFqdn}\nalerts_from=${alertsFrom}\nalerts_to=${alertsTo}`, 'utf8')) {
return callback(new Error('Could not create mail var file:' + safe.error.message));
}
settings.getCatchAllAddress(function (error, address) {
var catchAll = address.join(',');
callback();
if (!safe.fs.writeFileSync(paths.ADDON_CONFIG_DIR + '/mail/mail.ini',
`mail_domain=${fqdn}\nmail_server_name=${mailFqdn}\nalerts_from=${alertsFrom}\nalerts_to=${alertsTo}\ncatch_all=${catchAll}\n`, 'utf8')) {
return callback(new Error('Could not create mail var file:' + safe.error.message));
}
callback();
});
});
}
+1 -1
View File
@@ -139,7 +139,7 @@ function installApp(req, res, next) {
if (error && error.reason === AppsError.PORT_RESERVED) return next(new HttpError(409, 'Port ' + error.message + ' is reserved.'));
if (error && error.reason === AppsError.PORT_CONFLICT) return next(new HttpError(409, 'Port ' + error.message + ' is already in use.'));
if (error && error.reason === AppsError.BAD_FIELD) return next(new HttpError(400, error.message));
if (error && error.reason === AppsError.BILLING_REQUIRED) return next(new HttpError(402, 'Billing required'));
if (error && error.reason === AppsError.BILLING_REQUIRED) return next(new HttpError(402, error.message));
if (error && error.reason === AppsError.BAD_CERTIFICATE) return next(new HttpError(400, error.message));
if (error && error.reason === AppsError.EXTERNAL_ERROR) return next(new HttpError(503, error.message));
if (error) return next(new HttpError(500, error));
+5 -1
View File
@@ -80,7 +80,9 @@ function dnsSetup(req, res, next) {
if (typeof req.body.provider !== 'string') return next(new HttpError(400, 'provider is required'));
if (typeof req.body.domain !== 'string' || !req.body.domain) return next(new HttpError(400, 'domain is required'));
cloudron.dnsSetup(req.body, req.body.domain.toLowerCase(), function (error) {
if ('zoneName' in req.body && typeof req.body.zoneName !== 'string') return next(new HttpError(400, 'zoneName must be a string'));
cloudron.dnsSetup(req.body, req.body.domain.toLowerCase(), req.body.zoneName || '', function (error) {
if (error && error.reason === CloudronError.ALREADY_SETUP) return next(new HttpError(409, error.message));
if (error && error.reason === CloudronError.BAD_FIELD) return next(new HttpError(400, error.message));
if (error) return next(new HttpError(500, error));
@@ -159,6 +161,8 @@ function migrate(req, res, next) {
if (typeof req.body.provider !== 'string') return next(new HttpError(400, 'provider must be string'));
}
if ('zoneName' in req.body && typeof req.body.zoneName !== 'string') return next(new HttpError(400, 'zoneName must be string'));
debug('Migration requested domain:%s size:%s region:%s', req.body.domain, req.body.size, req.body.region);
var options = _.pick(req.body, 'domain', 'size', 'region');
+29 -1
View File
@@ -24,6 +24,9 @@ exports = module.exports = {
getMailConfig: getMailConfig,
setMailConfig: setMailConfig,
getCatchAllAddress: getCatchAllAddress,
setCatchAllAddress: setCatchAllAddress,
getAppstoreConfig: getAppstoreConfig,
setAppstoreConfig: setAppstoreConfig,
@@ -125,6 +128,31 @@ function setMailConfig(req, res, next) {
});
}
function getCatchAllAddress(req, res, next) {
settings.getCatchAllAddress(function (error, address) {
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, { address: address }));
});
}
function setCatchAllAddress(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
if (!req.body.address || !Array.isArray(req.body.address)) return next(new HttpError(400, 'address array is required'));
for (var i = 0; i < req.body.address.length; i++) {
if (typeof req.body.address[i] !== 'string') return next(new HttpError(400, 'address must be an array of string'));
}
settings.setCatchAllAddress(req.body.address, function (error) {
if (error && error.reason === SettingsError.BAD_FIELD) return next(new HttpError(400, error.message));
if (error) return next(new HttpError(500, error));
next(new HttpSuccess(200, {}));
});
}
function setCloudronAvatar(req, res, next) {
assert.strictEqual(typeof req.files, 'object');
@@ -171,7 +199,7 @@ function setDnsConfig(req, res, next) {
if (typeof req.body.provider !== 'string') return next(new HttpError(400, 'provider is required'));
settings.setDnsConfig(req.body, config.fqdn(), function (error) {
settings.setDnsConfig(req.body, config.fqdn(), config.zoneName(), function (error) {
if (error && error.reason === SettingsError.BAD_FIELD) return next(new HttpError(400, error.message));
if (error) return next(new HttpError(500, error));
+4 -3
View File
@@ -149,7 +149,8 @@ function startBox(done) {
safe.fs.unlinkSync(paths.INFRA_VERSION_FILE);
child_process.execSync('docker ps -qa | xargs --no-run-if-empty docker rm -f');
config.set('fqdn', 'foobar.com');
config.setFqdn('foobar.com');
config.setZoneName('foobar.com');
awsHostedZones = {
HostedZones: [{
@@ -231,7 +232,7 @@ function startBox(done) {
}, callback);
},
settings.setDnsConfig.bind(null, { provider: 'route53', accessKeyId: 'accessKeyId', secretAccessKey: 'secretAccessKey', endpoint: 'http://localhost:5353' }, config.fqdn()),
settings.setDnsConfig.bind(null, { provider: 'route53', accessKeyId: 'accessKeyId', secretAccessKey: 'secretAccessKey', endpoint: 'http://localhost:5353' }, config.fqdn(), config.zoneName()),
settings.setTlsConfig.bind(null, { provider: 'caas' }),
settings.setBackupConfig.bind(null, { provider: 'caas', token: 'BACKUP_TOKEN', bucket: 'Bucket', prefix: 'Prefix' })
], function (error) {
@@ -640,7 +641,7 @@ describe('App installation', function () {
apiHockServer = http.createServer(apiHockInstance.handler).listen(port, callback);
},
settings.setDnsConfig.bind(null, { provider: 'route53', accessKeyId: 'accessKeyId', secretAccessKey: 'secretAccessKey', endpoint: 'http://localhost:5353' }, config.fqdn()),
settings.setDnsConfig.bind(null, { provider: 'route53', accessKeyId: 'accessKeyId', secretAccessKey: 'secretAccessKey', endpoint: 'http://localhost:5353' }, config.fqdn(), config.zoneName()),
settings.setTlsConfig.bind(null, { provider: 'caas' }),
+1 -1
View File
@@ -28,7 +28,7 @@ function setup(done) {
nock.cleanAll();
config._reset();
config.setVersion('1.2.3');
config.set('fqdn', 'localhost');
config.setFqdn('localhost');
async.series([
server.start.bind(server),
+1 -1
View File
@@ -27,7 +27,7 @@ function setup(done) {
nock.cleanAll();
config._reset();
config.set('version', '0.5.0');
config.set('fqdn', 'localhost');
config.setFqdn('localhost');
server.start(function (error) {
if (error) return done(error);
+52 -1
View File
@@ -28,7 +28,7 @@ var token = null;
var server;
function setup(done) {
config.set('fqdn', 'foobar.com');
config.setFqdn('foobar.com');
async.series([
server.start.bind(server),
@@ -315,6 +315,57 @@ describe('Settings API', function () {
});
});
describe('catch_all', function () {
it('get catch_all succeeds', function (done) {
superagent.get(SERVER_URL + '/api/v1/settings/catch_all_address')
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body).to.eql({ address: [ ] });
done();
});
});
it('cannot set without address field', function (done) {
superagent.put(SERVER_URL + '/api/v1/settings/catch_all_address')
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
it('cannot set with bad address field', function (done) {
superagent.put(SERVER_URL + '/api/v1/settings/catch_all_address')
.query({ access_token: token })
.send({ address: [ "user1", 123 ] })
.end(function (err, res) {
expect(res.statusCode).to.equal(400);
done();
});
});
it('set succeeds', function (done) {
superagent.put(SERVER_URL + '/api/v1/settings/catch_all_address')
.query({ access_token: token })
.send({ address: [ "user1" ] })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
done();
});
});
it('get succeeds', function (done) {
superagent.get(SERVER_URL + '/api/v1/settings/catch_all_address')
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body).to.eql({ address: [ "user1" ] });
done();
});
});
});
describe('Certificates API', function () {
var validCert0, validKey0, // foobar.com
validCert1, validKey1; // *.foobar.com
+1 -1
View File
@@ -28,7 +28,7 @@ var token = null;
var server;
function setup(done) {
config.set('fqdn', 'foobar.com');
config.setFqdn('foobar.com');
async.series([
server.start.bind(server),
+2
View File
@@ -211,6 +211,8 @@ function initializeExpressSync() {
router.post('/api/v1/settings/appstore_config', settingsScope, routes.user.requireAdmin, routes.settings.setAppstoreConfig);
router.get ('/api/v1/settings/mail_config', settingsScope, routes.user.requireAdmin, routes.settings.getMailConfig);
router.post('/api/v1/settings/mail_config', settingsScope, routes.user.requireAdmin, routes.settings.setMailConfig);
router.get ('/api/v1/settings/catch_all_address', settingsScope, routes.user.requireAdmin, routes.settings.getCatchAllAddress);
router.put ('/api/v1/settings/catch_all_address', settingsScope, routes.user.requireAdmin, routes.settings.setCatchAllAddress);
// feedback
router.post('/api/v1/feedback', usersScope, routes.cloudron.feedback);
+64 -2
View File
@@ -44,13 +44,20 @@ exports = module.exports = {
getMailConfig: getMailConfig,
setMailConfig: setMailConfig,
setCatchAllAddress: setCatchAllAddress,
getCatchAllAddress: getCatchAllAddress,
getDefaultSync: getDefaultSync,
getEmailDigest: getEmailDigest,
setEmailDigest: setEmailDigest,
getAll: getAll,
AUTOUPDATE_PATTERN_KEY: 'autoupdate_pattern',
TIME_ZONE_KEY: 'time_zone',
CLOUDRON_NAME_KEY: 'cloudron_name',
DEVELOPER_MODE_KEY: 'developer_mode',
EMAIL_DIGEST: 'email_digest',
DNS_CONFIG_KEY: 'dns_config',
DYNAMIC_DNS_KEY: 'dynamic_dns',
BACKUP_CONFIG_KEY: 'backup_config',
@@ -58,6 +65,7 @@ exports = module.exports = {
UPDATE_CONFIG_KEY: 'update_config',
APPSTORE_CONFIG_KEY: 'appstore_config',
MAIL_CONFIG_KEY: 'mail_config',
CATCH_ALL_ADDRESS: 'catch_all_address',
events: null
};
@@ -77,6 +85,7 @@ var assert = require('assert'),
moment = require('moment-timezone'),
net = require('net'),
paths = require('./paths.js'),
platform = require('./platform.js'),
safe = require('safetydance'),
settingsdb = require('./settingsdb.js'),
subdomains = require('./subdomains.js'),
@@ -104,6 +113,8 @@ var gDefaults = (function () {
result[exports.UPDATE_CONFIG_KEY] = { prerelease: false };
result[exports.APPSTORE_CONFIG_KEY] = {};
result[exports.MAIL_CONFIG_KEY] = { enabled: false };
result[exports.CATCH_ALL_ADDRESS] = [ ];
result[exports.EMAIL_DIGEST] = true;
return result;
})();
@@ -492,15 +503,16 @@ function getDnsConfig(callback) {
});
}
function setDnsConfig(dnsConfig, domain, callback) {
function setDnsConfig(dnsConfig, domain, zoneName, callback) {
assert.strictEqual(typeof dnsConfig, 'object');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof zoneName, 'string');
assert.strictEqual(typeof callback, 'function');
sysinfo.getPublicIp(function (error, ip) {
if (error) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, 'Error getting IP:' + error.message));
subdomains.verifyDnsConfig(dnsConfig, domain, ip, function (error, result) {
subdomains.verifyDnsConfig(dnsConfig, domain, zoneName, ip, function (error, result) {
if (error && error.reason === SubdomainError.ACCESS_DENIED) return callback(new SettingsError(SettingsError.BAD_FIELD, 'Error adding A record. Access denied'));
if (error && error.reason === SubdomainError.NOT_FOUND) return callback(new SettingsError(SettingsError.BAD_FIELD, 'Zone not found'));
if (error && error.reason === SubdomainError.EXTERNAL_ERROR) return callback(new SettingsError(SettingsError.BAD_FIELD, 'Error adding A record:' + error.message));
@@ -653,6 +665,56 @@ function setMailConfig(mailConfig, callback) {
});
}
function getEmailDigest(callback) {
assert.strictEqual(typeof callback, 'function');
settingsdb.get(exports.EMAIL_DIGEST, function (error, enabled) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, gDefaults[exports.EMAIL_DIGEST]);
if (error) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, error));
callback(null, !!enabled); // settingsdb holds string values only
});
}
function setEmailDigest(enabled, callback) {
assert.strictEqual(typeof enabled, 'boolean');
assert.strictEqual(typeof callback, 'function');
settingsdb.set(exports.EMAIL_DIGEST, enabled ? 'enabled' : '', function (error) {
if (error) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, error));
exports.events.emit(exports.EMAIL_DIGEST, enabled);
callback(null);
});
}
function getCatchAllAddress(callback) {
assert.strictEqual(typeof callback, 'function');
settingsdb.get(exports.CATCH_ALL_ADDRESS_KEY, function (error, value) {
if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, gDefaults[exports.CATCH_ALL_ADDRESS_KEY]);
if (error) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, error));
callback(null, JSON.parse(value));
});
}
function setCatchAllAddress(address, callback) {
assert(Array.isArray(address));
assert.strictEqual(typeof callback, 'function');
settingsdb.set(exports.CATCH_ALL_ADDRESS, JSON.stringify(address), function (error) {
if (error) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, error));
exports.events.emit(exports.CATCH_ALL_ADDRESS, address);
platform.createMailConfig(NOOP_CALLBACK);
callback(null);
});
}
function getAppstoreConfig(callback) {
assert.strictEqual(typeof callback, 'function');
+24 -6
View File
@@ -13,6 +13,7 @@ module.exports = exports = {
var assert = require('assert'),
config = require('./config.js'),
settings = require('./settings.js'),
tld = require('tldjs'),
util = require('util');
function SubdomainError(reason, errorOrMessage) {
@@ -58,6 +59,17 @@ function api(provider) {
}
}
function getName(subdomain) {
// support special caas domains
if (!config.isCustomDomain()) return subdomain;
if (config.fqdn() === config.zoneName()) return subdomain;
var part = config.fqdn().slice(0, -config.zoneName().length - 1);
return subdomain === '' ? part : subdomain + '.' + part;
}
function get(subdomain, type, callback) {
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof type, 'string');
@@ -66,7 +78,7 @@ function get(subdomain, type, callback) {
settings.getDnsConfig(function (error, dnsConfig) {
if (error) return callback(new SubdomainError(SubdomainError.INTERNAL_ERROR, error));
api(dnsConfig.provider).get(dnsConfig, config.zoneName(), subdomain, type, function (error, values) {
api(dnsConfig.provider).get(dnsConfig, config.zoneName(), getName(subdomain), type, function (error, values) {
if (error) return callback(error);
callback(null, values);
@@ -83,7 +95,7 @@ function upsert(subdomain, type, values, callback) {
settings.getDnsConfig(function (error, dnsConfig) {
if (error) return callback(new SubdomainError(SubdomainError.INTERNAL_ERROR, error));
api(dnsConfig.provider).upsert(dnsConfig, config.zoneName(), subdomain, type, values, function (error, changeId) {
api(dnsConfig.provider).upsert(dnsConfig, config.zoneName(), getName(subdomain), type, values, function (error, changeId) {
if (error) return callback(error);
callback(null, changeId);
@@ -100,7 +112,7 @@ function remove(subdomain, type, values, callback) {
settings.getDnsConfig(function (error, dnsConfig) {
if (error) return callback(new SubdomainError(SubdomainError.INTERNAL_ERROR, error));
api(dnsConfig.provider).del(dnsConfig, config.zoneName(), subdomain, type, values, function (error) {
api(dnsConfig.provider).del(dnsConfig, config.zoneName(), getName(subdomain), type, values, function (error) {
if (error && error.reason !== SubdomainError.NOT_FOUND) return callback(error);
callback(null);
@@ -118,19 +130,25 @@ function waitForDns(domain, value, type, options, callback) {
settings.getDnsConfig(function (error, dnsConfig) {
if (error) return callback(new SubdomainError(SubdomainError.INTERNAL_ERROR, error));
api(dnsConfig.provider).waitForDns(domain, value, type, options, callback);
var zoneName = config.zoneName();
// if the domain is on another zone in case of external domain, use the correct zone
if (!domain.endsWith(zoneName)) zoneName = tld.getDomain(domain);
api(dnsConfig.provider).waitForDns(domain, zoneName, value, type, options, callback);
});
}
function verifyDnsConfig(dnsConfig, domain, ip, callback) {
function verifyDnsConfig(dnsConfig, domain, zoneName, ip, callback) {
assert(dnsConfig && typeof dnsConfig === 'object'); // the dns config to test with
assert(typeof dnsConfig.provider === 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof zoneName, 'string');
assert.strictEqual(typeof ip, 'string');
assert.strictEqual(typeof callback, 'function');
var backend = api(dnsConfig.provider);
if (!backend) return callback(new SubdomainError(SubdomainError.INVALID_PROVIDER));
api(dnsConfig.provider).verifyDnsConfig(dnsConfig, domain, ip, callback);
api(dnsConfig.provider).verifyDnsConfig(dnsConfig, domain, zoneName, ip, callback);
}
+3 -2
View File
@@ -68,7 +68,8 @@ var APP = {
describe('apptask', function () {
before(function (done) {
config.set('version', '0.5.0');
config.set('fqdn', 'foobar.com');
config.setFqdn('foobar.com');
config.setZoneName('foobar.com');
config.set('provider', 'caas');
awsHostedZones = {
@@ -90,7 +91,7 @@ describe('apptask', function () {
database.initialize,
appdb.add.bind(null, APP.id, APP.appStoreId, APP.manifest, APP.location, APP.portBindings, APP),
settings.initialize,
settings.setDnsConfig.bind(null, { provider: 'route53', accessKeyId: 'accessKeyId', secretAccessKey: 'secretAccessKey', endpoint: 'http://localhost:5353' }, config.fqdn()),
settings.setDnsConfig.bind(null, { provider: 'route53', accessKeyId: 'accessKeyId', secretAccessKey: 'secretAccessKey', endpoint: 'http://localhost:5353' }, config.fqdn(), config.zoneName()),
settings.setTlsConfig.bind(null, { provider: 'caas' })
], done);
});
+3 -4
View File
@@ -41,7 +41,7 @@ describe('config', function () {
expect(config.fqdn()).to.equal('localhost');
expect(config.adminOrigin()).to.equal('https://' + constants.ADMIN_LOCATION + '.localhost');
expect(config.appFqdn('app')).to.equal('app.localhost');
expect(config.zoneName()).to.equal('localhost');
expect(config.zoneName()).to.equal('');
});
it('set saves value in file', function (done) {
@@ -63,7 +63,7 @@ describe('config', function () {
});
it('uses dotted locations with custom domain', function () {
config.set('fqdn', 'example.com');
config.setFqdn('example.com');
config.set('isCustomDomain', true);
expect(config.isCustomDomain()).to.equal(true);
@@ -74,7 +74,7 @@ describe('config', function () {
});
it('uses hyphen locations with non-custom domain', function () {
config.set('fqdn', 'test.example.com');
config.setFqdn('test.example.com');
config.set('isCustomDomain', false);
expect(config.isCustomDomain()).to.equal(false);
@@ -95,4 +95,3 @@ describe('config', function () {
});
});
+28 -18
View File
@@ -18,10 +18,11 @@ var async = require('async'),
describe('dns provider', function () {
before(function (done) {
config._reset();
async.series([
database.initialize,
settings.initialize,
config._reset
settings.initialize
], done);
});
@@ -35,7 +36,10 @@ describe('dns provider', function () {
provider: 'noop'
};
settings.setDnsConfig(data, config.fqdn(), done);
config.setFqdn('example.com');
config.setZoneName('example.com');
settings.setDnsConfig(data, config.fqdn(), config.zoneName(), done);
});
it('upsert succeeds', function (done) {
@@ -76,7 +80,10 @@ describe('dns provider', function () {
token: TOKEN
};
settings.setDnsConfig(data, config.fqdn(), done);
config.setFqdn('example.com');
config.setZoneName('example.com');
settings.setDnsConfig(data, config.fqdn(), config.zoneName(), done);
});
it('upsert non-existing record succeeds', function (done) {
@@ -93,10 +100,10 @@ describe('dns provider', function () {
};
var req1 = nock(DIGITALOCEAN_ENDPOINT).filteringRequestBody(function () { return false; })
.get('/v2/domains/localhost/records')
.get('/v2/domains/' + config.zoneName() + '/records')
.reply(200, { domain_records: [] });
var req2 = nock(DIGITALOCEAN_ENDPOINT).filteringRequestBody(function () { return false; })
.post('/v2/domains/localhost/records')
.post('/v2/domains/' + config.zoneName() + '/records')
.reply(201, { domain_record: DOMAIN_RECORD_0 });
subdomains.upsert('test', 'A', [ '1.2.3.4' ], function (error, result) {
@@ -143,10 +150,10 @@ describe('dns provider', function () {
};
var req1 = nock(DIGITALOCEAN_ENDPOINT).filteringRequestBody(function () { return false; })
.get('/v2/domains/localhost/records')
.get('/v2/domains/' + config.zoneName() + '/records')
.reply(200, { domain_records: [ DOMAIN_RECORD_0, DOMAIN_RECORD_1 ] });
var req2 = nock(DIGITALOCEAN_ENDPOINT).filteringRequestBody(function () { return false; })
.put('/v2/domains/localhost/records/' + DOMAIN_RECORD_1.id)
.put('/v2/domains/' + config.zoneName() + '/records/' + DOMAIN_RECORD_1.id)
.reply(200, { domain_records: DOMAIN_RECORD_1_NEW });
subdomains.upsert('test', 'A', [ DOMAIN_RECORD_1_NEW.data ], function (error, result) {
@@ -223,16 +230,16 @@ describe('dns provider', function () {
};
var req1 = nock(DIGITALOCEAN_ENDPOINT).filteringRequestBody(function () { return false; })
.get('/v2/domains/localhost/records')
.get('/v2/domains/' + config.zoneName() + '/records')
.reply(200, { domain_records: [ DOMAIN_RECORD_0, DOMAIN_RECORD_1, DOMAIN_RECORD_2 ] });
var req2 = nock(DIGITALOCEAN_ENDPOINT).filteringRequestBody(function () { return false; })
.put('/v2/domains/localhost/records/' + DOMAIN_RECORD_1.id)
.put('/v2/domains/' + config.zoneName() + '/records/' + DOMAIN_RECORD_1.id)
.reply(200, { domain_records: DOMAIN_RECORD_1_NEW });
var req3 = nock(DIGITALOCEAN_ENDPOINT).filteringRequestBody(function () { return false; })
.put('/v2/domains/localhost/records/' + DOMAIN_RECORD_2.id)
.put('/v2/domains/' + config.zoneName() + '/records/' + DOMAIN_RECORD_2.id)
.reply(200, { domain_records: DOMAIN_RECORD_2_NEW });
var req4 = nock(DIGITALOCEAN_ENDPOINT).filteringRequestBody(function () { return false; })
.post('/v2/domains/localhost/records')
.post('/v2/domains/' + config.zoneName() + '/records')
.reply(201, { domain_records: DOMAIN_RECORD_2_NEW });
subdomains.upsert('', 'TXT', [ DOMAIN_RECORD_2_NEW.data, DOMAIN_RECORD_1_NEW.data, DOMAIN_RECORD_3_NEW.data ], function (error, result) {
@@ -271,7 +278,7 @@ describe('dns provider', function () {
};
var req1 = nock(DIGITALOCEAN_ENDPOINT).filteringRequestBody(function () { return false; })
.get('/v2/domains/localhost/records')
.get('/v2/domains/' + config.zoneName() + '/records')
.reply(200, { domain_records: [ DOMAIN_RECORD_0, DOMAIN_RECORD_1 ] });
subdomains.get('test', 'A', function (error, result) {
@@ -309,10 +316,10 @@ describe('dns provider', function () {
};
var req1 = nock(DIGITALOCEAN_ENDPOINT).filteringRequestBody(function () { return false; })
.get('/v2/domains/localhost/records')
.get('/v2/domains/' + config.zoneName() + '/records')
.reply(200, { domain_records: [ DOMAIN_RECORD_0, DOMAIN_RECORD_1 ] });
var req2 = nock(DIGITALOCEAN_ENDPOINT).filteringRequestBody(function () { return false; })
.delete('/v2/domains/localhost/records/' + DOMAIN_RECORD_1.id)
.delete('/v2/domains/' + config.zoneName() + '/records/' + DOMAIN_RECORD_1.id)
.reply(204, {});
subdomains.remove('test', 'A', ['1.2.3.4'], function (error) {
@@ -326,13 +333,16 @@ describe('dns provider', function () {
});
describe('route53', function () {
config.setFqdn('example.com');
config.setZoneName('example.com');
// do not clear this with [] but .length = 0 so we don't loose the reference in mockery
var awsAnswerQueue = [];
var AWS_HOSTED_ZONES = {
HostedZones: [{
Id: '/hostedzone/Z34G16B38TNZ9L',
Name: 'localhost.',
Name: config.zoneName() + '.',
CallerReference: '305AFD59-9D73-4502-B020-F4E6F889CB30',
ResourceRecordSetCount: 2,
ChangeInfo: {
@@ -401,7 +411,7 @@ describe('dns provider', function () {
AWS._originalRoute53 = AWS.Route53;
AWS.Route53 = Route53Mock;
settings.setDnsConfig(data, config.fqdn(), done);
settings.setDnsConfig(data, config.fqdn(), config.zoneName(), done);
});
after(function () {
@@ -470,7 +480,7 @@ describe('dns provider', function () {
awsAnswerQueue.push([null, AWS_HOSTED_ZONES]);
awsAnswerQueue.push([null, {
ResourceRecordSets: [{
Name: 'test.localhost.',
Name: 'test.' + config.zoneName() + '.',
Type: 'A',
ResourceRecords: [{
Value: '1.2.3.4'
+21 -1
View File
@@ -95,7 +95,7 @@ describe('Settings', function () {
});
it('can set dns config', function (done) {
settings.setDnsConfig({ provider: 'route53', accessKeyId: 'accessKeyId', secretAccessKey: 'secretAccessKey' }, config.fqdn(), function (error) {
settings.setDnsConfig({ provider: 'route53', accessKeyId: 'accessKeyId', secretAccessKey: 'secretAccessKey' }, config.fqdn(), config.zoneName(), function (error) {
expect(error).to.be(null);
done();
});
@@ -181,6 +181,26 @@ describe('Settings', function () {
});
});
it('can get catch all address', function (done) {
settings.getCatchAllAddress(function (error, address) {
expect(error).to.be(null);
expect(address).to.eql([ ]);
done();
});
});
it('can set catch all address', function (done) {
settings.setCatchAllAddress([ "user1", "user2" ], function (error) {
expect(error).to.be(null);
settings.getCatchAllAddress(function (error, address) {
expect(error).to.be(null);
expect(address).to.eql([ "user1", "user2" ]);
done();
});
});
});
it('can get all values', function (done) {
settings.getAll(function (error, allSettings) {
expect(error).to.be(null);
+136 -4
View File
@@ -43,6 +43,7 @@ function checkMails(number, done) {
function cleanup(done) {
mailer._clearMailQueue();
safe.fs.unlinkSync(paths.UPDATE_CHECKER_FILE);
async.series([
settings.uninitialize,
@@ -50,7 +51,7 @@ function cleanup(done) {
], done);
}
describe('updatechecker - box - manual (mail)', function () {
describe('updatechecker - box - manual (email)', function () {
before(function (done) {
config._reset();
config.set('version', '1.0.0');
@@ -96,11 +97,17 @@ describe('updatechecker - box - manual (mail)', function () {
.query({ boxVersion: config.version(), accessToken: 'token' })
.reply(200, { version: '2.0.0', changelog: [''], sourceTarballUrl: '2.0.0.tar.gz' } );
var scope2 = nock('http://localhost:4444')
.get('/api/v1/users/uid/cloudrons/cid/subscription')
.query({ accessToken: 'token' })
.reply(200, { subscription: { plan: { id: 'pro' } } } );
updatechecker.checkBoxUpdates(function (error) {
expect(!error).to.be.ok();
expect(updatechecker.getUpdateInfo().box.version).to.be('2.0.0');
expect(updatechecker.getUpdateInfo().box.sourceTarballUrl).to.be('2.0.0.tar.gz');
expect(scope.isDone()).to.be.ok();
expect(scope2.isDone()).to.be.ok();
checkMails(1, done);
});
@@ -134,10 +141,16 @@ describe('updatechecker - box - manual (mail)', function () {
.query({ boxVersion: config.version(), accessToken: 'token' })
.reply(200, { version: '2.0.0-pre.0', changelog: [''], sourceTarballUrl: '2.0.0-pre.0.tar.gz' } );
var scope2 = nock('http://localhost:4444')
.get('/api/v1/users/uid/cloudrons/cid/subscription')
.query({ accessToken: 'token' })
.reply(200, { subscription: { plan: { id: 'pro' } } } );
updatechecker.checkBoxUpdates(function (error) {
expect(!error).to.be.ok();
expect(updatechecker.getUpdateInfo().box.version).to.be('2.0.0-pre.0');
expect(scope.isDone()).to.be.ok();
expect(scope2.isDone()).to.be.ok();
checkMails(1, done);
});
@@ -162,7 +175,7 @@ describe('updatechecker - box - manual (mail)', function () {
});
});
describe('updatechecker - box - automatic', function () {
describe('updatechecker - box - automatic (no email)', function () {
before(function (done) {
config.set('version', '1.0.0');
config.set('apiServerOrigin', 'http://localhost:4444');
@@ -186,17 +199,63 @@ describe('updatechecker - box - automatic', function () {
.query({ boxVersion: config.version(), accessToken: 'token' })
.reply(200, { version: '2.0.0', sourceTarballUrl: '2.0.0.tar.gz' } );
var scope2 = nock('http://localhost:4444')
.get('/api/v1/users/uid/cloudrons/cid/subscription')
.query({ accessToken: 'token' })
.reply(200, { subscription: { plan: { id: 'pro' } } } );
updatechecker.checkBoxUpdates(function (error) {
expect(!error).to.be.ok();
expect(updatechecker.getUpdateInfo().box.version).to.be('2.0.0');
expect(scope.isDone()).to.be.ok();
expect(scope2.isDone()).to.be.ok();
checkMails(0, done);
});
});
});
describe('updatechecker - app - manual (mails)', function () {
describe('updatechecker - box - automatic free (email)', function () {
before(function (done) {
config.set('version', '1.0.0');
config.set('apiServerOrigin', 'http://localhost:4444');
config.set('provider', 'notcaas');
async.series([
database.initialize,
settings.initialize,
mailer._clearMailQueue,
user.createOwner.bind(null, USER_0.username, USER_0.password, USER_0.email, USER_0.displayName, AUDIT_SOURCE),
settingsdb.set.bind(null, settings.APPSTORE_CONFIG_KEY, JSON.stringify({ userId: 'uid', cloudronId: 'cid', token: 'token' }))
], done);
});
after(cleanup);
it('new version', function (done) {
nock.cleanAll();
var scope = nock('http://localhost:4444')
.get('/api/v1/users/uid/cloudrons/cid/boxupdate')
.query({ boxVersion: config.version(), accessToken: 'token' })
.reply(200, { version: '2.0.0', changelog: [''], sourceTarballUrl: '2.0.0.tar.gz' } );
var scope2 = nock('http://localhost:4444')
.get('/api/v1/users/uid/cloudrons/cid/subscription')
.query({ accessToken: 'token' })
.reply(200, { subscription: { plan: { id: 'free' } } } );
updatechecker.checkBoxUpdates(function (error) {
expect(!error).to.be.ok();
expect(updatechecker.getUpdateInfo().box.version).to.be('2.0.0');
expect(scope.isDone()).to.be.ok();
expect(scope2.isDone()).to.be.ok();
checkMails(1, done);
});
});
});
describe('updatechecker - app - manual (email)', function () {
var APP_0 = {
id: 'appid-0',
appStoreId: 'io.cloudron.app',
@@ -282,10 +341,16 @@ describe('updatechecker - app - manual (mails)', function () {
.query({ boxVersion: config.version(), accessToken: 'token', appId: APP_0.appStoreId, appVersion: APP_0.manifest.version })
.reply(200, { manifest: { version: '2.0.0' } } );
var scope2 = nock('http://localhost:4444')
.get('/api/v1/users/uid/cloudrons/cid/subscription')
.query({ accessToken: 'token' })
.reply(200, { subscription: { plan: { id: 'pro' } } } );
updatechecker.checkAppUpdates(function (error) {
expect(!error).to.be.ok();
expect(updatechecker.getUpdateInfo().apps).to.eql({ 'appid-0': { manifest: { version: '2.0.0' } } });
expect(scope.isDone()).to.be.ok();
expect(scope2.isDone()).to.be.ok();
checkMails(1, done);
});
@@ -302,7 +367,7 @@ describe('updatechecker - app - manual (mails)', function () {
});
});
describe('updatechecker - app - automatic (no emails)', function () {
describe('updatechecker - app - automatic (no email)', function () {
var APP_0 = {
id: 'appid-0',
appStoreId: 'io.cloudron.app',
@@ -362,3 +427,70 @@ describe('updatechecker - app - automatic (no emails)', function () {
});
});
});
describe('updatechecker - app - automatic free (email)', function () {
var APP_0 = {
id: 'appid-0',
appStoreId: 'io.cloudron.app',
installationState: appdb.ISTATE_PENDING_INSTALL,
installationProgress: null,
runState: null,
location: 'some-location-0',
manifest: {
version: '1.0.0', dockerImage: 'docker/app0', healthCheckPath: '/', httpPort: 80, title: 'app0',
tcpPorts: {
PORT: {
description: 'this is a port that i expose',
containerPort: '1234'
}
}
},
httpPort: null,
containerId: null,
portBindings: { PORT: 5678 },
healthy: null,
accessRestriction: null,
memoryLimit: 0
};
before(function (done) {
config.set('version', '1.0.0');
config.set('apiServerOrigin', 'http://localhost:4444');
config.set('provider', 'notcaas');
async.series([
database.initialize,
database._clear,
settings.initialize,
mailer._clearMailQueue,
appdb.add.bind(null, APP_0.id, APP_0.appStoreId, APP_0.manifest, APP_0.location, APP_0.portBindings, APP_0),
user.createOwner.bind(null, USER_0.username, USER_0.password, USER_0.email, USER_0.displayName, AUDIT_SOURCE),
settingsdb.set.bind(null, settings.APPSTORE_CONFIG_KEY, JSON.stringify({ userId: 'uid', cloudronId: 'cid', token: 'token' }))
], done);
});
after(cleanup);
it('offers new version', function (done) {
nock.cleanAll();
var scope = nock('http://localhost:4444')
.get('/api/v1/users/uid/cloudrons/cid/appupdate')
.query({ boxVersion: config.version(), accessToken: 'token', appId: APP_0.appStoreId, appVersion: APP_0.manifest.version })
.reply(200, { manifest: { version: '2.0.0' } } );
var scope2 = nock('http://localhost:4444')
.get('/api/v1/users/uid/cloudrons/cid/subscription')
.query({ accessToken: 'token' })
.reply(200, { subscription: { plan: { id: 'free' } } } );
updatechecker.checkAppUpdates(function (error) {
expect(!error).to.be.ok();
expect(updatechecker.getUpdateInfo().apps).to.eql({ 'appid-0': { manifest: { version: '2.0.0' } } });
expect(scope.isDone()).to.be.ok();
expect(scope2.isDone()).to.be.ok();
checkMails(1, done);
});
});
});
+50 -25
View File
@@ -27,7 +27,7 @@ var NOOP_CALLBACK = function (error) { if (error) debug(error); };
function loadState() {
var state = safe.JSON.parse(safe.fs.readFileSync(paths.UPDATE_CHECKER_FILE, 'utf8'));
return state || {};
return state || { };
}
function saveState(mailedUser) {
@@ -95,25 +95,37 @@ function checkAppUpdates(callback) {
if (oldState[app.id] === newState[app.id]) {
debug('Skipping notification of app update %s since user was already notified', app.id);
} else {
// only send notifications if update pattern is 'never'
settings.getAutoupdatePattern(function (error, result) {
if (error) return debug(error);
if (result !== constants.AUTOUPDATE_PATTERN_NEVER) return;
debug('Notifying user of app update for %s from %s to %s', app.id, app.manifest.version, updateInfo.manifest.version);
mailer.appUpdateAvailable(app, updateInfo);
});
return iteratorDone();
}
iteratorDone();
appstore.getSubscription(function (error, result) {
if (error) {
debug('Error getting subscription for %s', app.id, error);
return iteratorDone();
}
// always send notifications if user is on the free plan
if (result.plan.id === 'free' || result.plan.id === 'undecided') {
debug('Notifying user of app update for %s from %s to %s', app.id, app.manifest.version, updateInfo.manifest.version);
mailer.appUpdateAvailable(app, updateInfo);
return iteratorDone();
}
// only send notifications if update pattern is 'never'
settings.getAutoupdatePattern(function (error, result) {
if (error) {
debug(error);
} else if (result === constants.AUTOUPDATE_PATTERN_NEVER) {
debug('Notifying user of app update for %s from %s to %s', app.id, app.manifest.version, updateInfo.manifest.version);
mailer.appUpdateAvailable(app, updateInfo);
}
iteratorDone();
});
});
});
}, function () {
// preserve the latest box state information
newState.box = loadState().box;
newState.boxTimestamp = loadState().boxTimestamp;
newState.box = loadState().box; // preserve the latest box state information
saveState(newState);
callback();
});
@@ -145,21 +157,34 @@ function checkBoxUpdates(callback) {
// decide whether to send email
var state = loadState();
const NOTIFICATION_OFFSET = 1000 * 60 * 60 * 24 * 5; // 5 days
if (state.box === gBoxUpdateInfo.version && state.boxTimestamp > Date.now() - NOTIFICATION_OFFSET) {
debug('Skipping notification of box update as user was already notified within the last 5 days');
if (state.box === gBoxUpdateInfo.version) {
debug('Skipping notification of box update as user was already notified');
return callback();
}
state.boxTimestamp = Date.now();
state.box = updateInfo.version;
appstore.getSubscription(function (error, result) {
if (error) return callback(error);
mailer.boxUpdateAvailable(updateInfo.version, updateInfo.changelog);
function done() {
state.box = updateInfo.version;
saveState(state);
callback();
}
saveState(state);
// always send notifications if user is on the free plan
if (result.plan.id === 'free' || result.plan.id === 'undecided') {
mailer.boxUpdateAvailable(updateInfo.version, updateInfo.changelog);
return done();
}
callback();
// only send notifications if update pattern is 'never'
settings.getAutoupdatePattern(function (error, result) {
if (error) debug(error);
else if (result === constants.AUTOUPDATE_PATTERN_NEVER) mailer.boxUpdateAvailable(updateInfo.version, updateInfo.changelog);
done();
});
});
});
});
}
@@ -0,0 +1,354 @@
"use strict";
angular.module("ui.multiselect", ["multiselect.tpl.html"])
//from bootstrap-ui typeahead parser
.factory("optionParser", ["$parse", function($parse) {
// 00000111000000000000022200000000000000003333333333333330000000000044000
var TYPEAHEAD_REGEXP = /^\s*(.*?)(?:\s+as\s+(.*?))?\s+for\s+(?:([\$\w][\$\w\d]*))\s+in\s+(.*)$/;
return {
parse: function(input) {
var match = input.match(TYPEAHEAD_REGEXP);
if(!match) {
throw new Error("Expected typeahead specification in form of '_modelValue_ (as _label_)? for _item_ in _collection_'" + " but got '" + input + "'.");
}
return {
itemName : match[3],
source : $parse(match[4]),
viewMapper : $parse(match[2] || match[1]),
modelMapper: $parse(match[1])
};
}
};
}])
.directive("multiselect", ["$parse", "$document", "$compile", "$interpolate", "optionParser", function($parse, $document, $compile, $interpolate, optionParser) {
return {
restrict: "E",
require : "ngModel",
link : function(originalScope, element, attrs, modelCtrl) {
var exp = attrs.options;
var parsedResult = optionParser.parse(exp);
var isMultiple = attrs.multiple ? true : false;
var compareByKey = attrs.compareBy;
var headerKey = attrs.headerKey;
var dividerKey = attrs.dividerKey;
var scrollAfterRows = attrs.scrollAfterRows;
var tabindex = attrs.tabindex;
var maxWidth = attrs.maxWidth;
var required = false;
var scope = originalScope.$new();
scope.filterAfterRows = attrs.filterAfterRows;
var changeHandler = attrs.change || angular.noop;
scope.items = [];
scope.header = "Select";
scope.multiple = isMultiple;
scope.disabled = false;
scope.ulStyle = {};
if(scrollAfterRows !== undefined && parseInt(scrollAfterRows).toString() === scrollAfterRows) {
scope.ulStyle = {"max-height": (scrollAfterRows*26+14)+"px", "overflow-y": "auto", "overflow-x": "hidden"};
}
if(tabindex !== undefined && parseInt(tabindex).toString() === tabindex) {
scope.tabindex = tabindex;
}
if(maxWidth !== undefined && parseInt(maxWidth).toString() === maxWidth) {
scope.maxWidth = {"max-width": maxWidth + "px"};
}
originalScope.$on("$destroy", function() {
scope.$destroy();
});
var popUpEl = angular.element("<multiselect-popup></multiselect-popup>");
//required validator
if(attrs.required || attrs.ngRequired) {
required = true;
}
attrs.$observe("required", function(newVal) {
required = newVal;
});
//watch disabled state
scope.$watch(function() {
return $parse(attrs.ngDisabled)(originalScope);
}, function(newVal) {
scope.disabled = newVal;
});
//watch single/multiple state for dynamically change single to multiple
scope.$watch(function() {
return $parse(attrs.multiple)(originalScope);
}, function(newVal) {
isMultiple = newVal || false;
});
//watch option changes for options that are populated dynamically
scope.$watch(function() {
return parsedResult.source(originalScope);
}, function(newVal) {
if(angular.isDefined(newVal)) {
parseModel();
}
}, true);
//watch model change
scope.$watch(function() {
return modelCtrl.$modelValue;
}, function(newVal, oldVal) {
//when directive initialize, newVal usually undefined. Also, if model value already set in the controller
//for preselected list then we need to mark checked in our scope item. But we don't want to do this every time
//model changes. We need to do this only if it is done outside directive scope, from controller, for example.
if(angular.isDefined(newVal)) {
markChecked(newVal);
scope.$eval(changeHandler);
}
getHeaderText();
modelCtrl.$setValidity("required", scope.valid());
});
function parseModel() {
scope.items.length = 0;
var model = parsedResult.source(originalScope);
if(!angular.isDefined(model) || model === null) {
return;
}
for(var i = 0; i < model.length; i++) {
var local = {};
local[parsedResult.itemName] = model[i];
// calculate checked status of the option
// https://github.com/sebastianha/angular-bootstrap-multiselect/pull/4/files
var id = model[i];
var checked = false;
var modelValue = modelCtrl.$modelValue;
if (modelValue) {
if (angular.isArray(modelValue)) {
for (var j = 0; j < modelValue.length; j++)
if (modelValue[j] == id) {
checked = true;
break;
}
} else {
checked = modelValue == id;
}
}
scope.items.push({
label : parsedResult.viewMapper(local),
model : model[i],
checked: checked,
header : model[i][headerKey],
divider : model[i][dividerKey]
});
}
}
parseModel();
element.append($compile(popUpEl)(scope));
function getHeaderText() {
if(isEmpty(modelCtrl.$modelValue)) {
scope.header = attrs.msHeader || "Select";
return scope.header;
}
if(isMultiple) {
if(attrs.msSelected) {
scope.header = $interpolate(attrs.msSelected)(scope);
} else {
scope.header = modelCtrl.$modelValue.length + " " + "selected";
}
} else {
var local = {};
local[parsedResult.itemName] = modelCtrl.$modelValue;
scope.header = parsedResult.viewMapper(local);
}
}
function isEmpty(obj) {
if(obj === true || obj === false) {
return false;
}
if(!obj) {
return true;
}
if(obj.length && obj.length > 0) {
return false;
}
for(var prop in obj) {
if(obj[prop]) {
return false;
}
}
if(compareByKey !== undefined && obj[compareByKey] !== undefined) {
return false;
}
return true;
}
scope.valid = function validModel() {
if(!required) {
return true;
}
var value = modelCtrl.$modelValue;
return (angular.isArray(value) && value.length > 0) || (!angular.isArray(value) && value !== null);
};
function selectSingle(item) {
if(!item.checked) {
scope.uncheckAll();
item.checked = !item.checked;
}
setModelValue(false);
}
function selectMultiple(item) {
item.checked = !item.checked;
setModelValue(true);
}
function setModelValue(isMultiple) {
var value;
if(isMultiple) {
value = [];
angular.forEach(scope.items, function(item) {
if(item.checked) {
value.push(item.model);
}
});
} else {
angular.forEach(scope.items, function(item) {
if(item.checked) {
value = item.model;
return false;
}
});
}
modelCtrl.$setViewValue(value);
}
function markChecked(newVal) {
if(!angular.isArray(newVal)) {
angular.forEach(scope.items, function(item) {
item.checked = false;
if(compareByKey === undefined && angular.equals(item.model, newVal)) {
item.checked = true;
} else if(compareByKey !== undefined && newVal !== null && item.model[compareByKey] !== undefined && angular.equals(item.model[compareByKey], newVal[compareByKey])) {
item.checked = true;
}
});
} else {
angular.forEach(scope.items, function(item) {
item.checked = false;
angular.forEach(newVal, function(i) {
if(compareByKey === undefined && angular.equals(item.model, i)) {
item.checked = true;
} else if(compareByKey !== undefined && item.model[compareByKey] !== undefined && angular.equals(item.model[compareByKey], i[compareByKey])) {
item.checked = true;
}
});
});
}
}
scope.checkAll = function() {
if(!isMultiple) {
return;
}
angular.forEach(scope.items, function(item) {
item.checked = true;
});
setModelValue(true);
};
scope.uncheckAll = function() {
angular.forEach(scope.items, function(item) {
item.checked = false;
});
setModelValue(true);
};
scope.select = function(event, item) {
if(isMultiple === false) {
selectSingle(item);
scope.toggleSelect();
} else {
event.stopPropagation();
selectMultiple(item);
}
};
}
};
}])
.directive("multiselectPopup", ["$document", function($document) {
return {
restrict : "E",
scope : false,
replace : true,
templateUrl: "multiselect.tpl.html",
link : function(scope, element, attrs) {
scope.isVisible = false;
scope.toggleSelect = function() {
if(element.hasClass("open")) {
scope.filter = "";
element.removeClass("open");
$document.unbind("click", clickHandler);
} else {
scope.filter = "";
element.addClass("open");
$document.bind("click", clickHandler);
}
};
// $("ul.dropdown-menu").on("click", "[data-stopPropagation]", function(e) {
// e.stopPropagation();
// });
function clickHandler(event) {
if(elementMatchesAnyInArray(event.target, element.find(event.target.tagName))) {
return;
}
element.removeClass("open");
$document.unbind("click", clickHandler);
scope.$apply();
}
var elementMatchesAnyInArray = function(element, elementArray) {
for(var i = 0; i < elementArray.length; i++) {
if(element === elementArray[i]) {
return true;
}
}
return false;
};
}
};
}]);
angular.module("multiselect.tpl.html", []).run(["$templateCache", function($templateCache) {
$templateCache.put("multiselect.tpl.html",
"<div class=\"btn-group\">\n" +
" <button tabindex=\"{{tabindex}}\" title=\"{{header}}\" type=\"button\" class=\"btn btn-default dropdown-toggle\" ng-click=\"toggleSelect()\" ng-disabled=\"disabled\" ng-class=\"{'error': !valid()}\">\n" +
" <div ng-style=\"maxWidth\" style=\"padding-right: 13px; overflow: hidden; text-overflow: ellipsis;\">{{header}}</div><span class=\"caret\" style=\"position:absolute;right:10px;top:14px;\"></span>\n" +
" </button>\n" +
" <ul class=\"dropdown-menu\" style=\"margin-bottom:30px;padding-left:5px;padding-right:5px;\" ng-style=\"ulStyle\">\n" +
" <input ng-show=\"items.length > filterAfterRows\" ng-model=\"filter\" style=\"padding: 0px 3px;margin-right: 15px; margin-bottom: 4px;\" placeholder=\"Type to filter options\">" +
" <li data-stopPropagation=\"true\" ng-repeat=\"i in items | filter:filter\" ng-class=\"{'dropdown-header': i.header, 'divider': i.divider}\">\n" +
" <a ng-if=\"!i.header && !i.divider\" ng-click=\"select($event, i)\" style=\"padding:3px 10px;cursor:pointer;\">\n" +
" <i class=\"fa\" ng-class=\"{'fa-check': i.checked, 'empty': !i.checked}\"></i> {{i.label}}" +
" </a>\n" +
" <span ng-if=\"i.header\">{{i.label}}</span>" +
" </li>\n" +
" </ul>\n" +
"</div>");
}]);
+12 -30
View File
@@ -62,6 +62,10 @@
<script type="text/javascript" src="/3rdparty/bootstrap-slider/bootstrap-slider.min.js"></script>
<script type="text/javascript" src="/3rdparty/bootstrap-slider/slider.js"></script>
<!-- Anugular Multiselect -->
<!-- https://github.com/sebastianha/angular-bootstrap-multiselect -->
<script src="/3rdparty/js/angular-bootstrap-multiselect.js"></script>
<!-- Main Application -->
<script src="js/index.js"></script>
@@ -148,42 +152,15 @@
<h4 class="modal-title" id="updateModalLabel">Setup Subscription</h4>
</div>
<div class="modal-body">
<p>
You can update to the next version once you have selected a <a ng-href="{{ config.webServerOrigin + '/pricing.html' }}" target="_blank">plan</a>.
<p ng-show="config.update.box">
You can update to the next version once you have selected a <a ng-href="{{ config.webServerOrigin + '/pricing.html' }}" target="_blank">paid plan</a>.
</p>
<p>
With a paid plan, you get continuous updates for the Cloudron and apps. This ensures you are running the latest versions of apps and keeps your server secure. All paid plans come with support via <a href="mailto:support@cloudron.io">email</a> and <a target="_blank" href="https://chat.cloudron.io">live chat</a>.
</p>
</div>
<div class="modal-footer">
<a class="btn btn-success" ng-href="{{ config.webServerOrigin + '/console.html#/userprofile?view=subscriptions&email=' + appstoreConfig.profile.email }}" target="_blank">Setup Subscription</a>
</div>
</div>
</div>
</div>
<!-- Modal version 1.0 -->
<div class="modal fade" id="version1Modal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-body">
<h3>Cloudron 1.0 is here!</h3>
<p>
Your Cloudron will update to 1.0 once you have selected a <a ng-href="{{ config.webServerOrigin + '/pricing.html' }}" target="_blank">plan</a>.
</p>
<p>
With a paid plan, you get continuous updates for the Cloudron and apps just like you had until now. This ensures you are running the latest versions of apps and keeps your server secure. All paid plans come with support via <a href="mailto:support@cloudron.io">email</a> and <a target="_blank" href="https://chat.cloudron.io">live chat</a>.
</p>
<p>
With the free plan, you can keep the Cloudron and Apps updated on your own
following the instructions in our <a href="https://git.cloudron.io/cloudron/box/wikis/home" target="_blank">wiki</a>.
</p>
<p>
You can read more about our pricing changes in our <a href="https://cloudron.io/blog/2017-06-06-pricing.html" target="_blank">blog</a>.
</p>
</div>
<div class="modal-footer">
<a class="btn btn-success" ng-click="waitForPlanSelection()" ng-href="{{ config.webServerOrigin + '/console.html#/userprofile?view=subscriptions&email=' + appstoreConfig.profile.email + '&cloudronId=' + appstoreConfig.cloudronId }}" target="_blank"><i class="fa fa-circle-o-notch fa-spin" ng-show="waitingForPlanSelection"></i> Setup Subscription</a>
<a class="btn btn-success" ng-click="waitForPlanSelection()" ng-href="{{ config.webServerOrigin + '/console.html#/userprofile?view=subscriptions&email=' + appstoreConfig.profile.email + '&cloudronId=' + appstoreConfig.cloudronId }}" target="_blank" ng-disabled="waitingForPlanSelection"><i class="fa fa-circle-o-notch fa-spin" ng-show="waitingForPlanSelection"></i> Setup Subscription</a>
</div>
</div>
</div>
@@ -213,6 +190,11 @@
<span class="badge badge-success">Update available</span>
</a>
</li>
<li ng-show="appstoreConfig && !config.update.box && user.admin && (currentSubscription.plan.id === 'undecided' || currentSubscription.plan.id === 'free')">
<a ng-href="" ng-click="showSubscriptionModal()" style="cursor: pointer">
<span class="badge badge-success">Setup Subscription</span>
</a>
</li>
<li>
<a ng-class="{ active: isActive('/apps')}" href="#/apps"><i class="fa fa-cloud-download fa-fw"></i> My Apps</a>
</li>
+14
View File
@@ -404,6 +404,20 @@ angular.module('Application').service('Client', ['$http', 'md5', 'Notification',
}
};
Client.prototype.setCatchallAddresses = function (addresses, callback) {
put('/api/v1/settings/catch_all_address', { address: addresses }).success(function(data, status) {
if (status !== 200) return callback(new ClientError(status, data));
callback(null);
}).error(defaultErrorHandler(callback));
};
Client.prototype.getCatchallAddresses = function (callback) {
get('/api/v1/settings/catch_all_address').success(function(data, status) {
if (status !== 200) return callback(new ClientError(status, data));
callback(null, data.address);
}).error(defaultErrorHandler(callback));
};
Client.prototype.setBackupConfig = function (backupConfig, callback) {
post('/api/v1/settings/backup_config', backupConfig).success(function(data, status) {
if (status !== 200) return callback(new ClientError(status, data));
+1 -1
View File
@@ -9,7 +9,7 @@ if (search.accessToken) localStorage.token = search.accessToken;
// create main application module
var app = angular.module('Application', ['ngFitText', 'ngRoute', 'ngAnimate', 'ngSanitize', 'angular-md5', 'base64', 'slick', 'ui-notification', 'ui.bootstrap', 'ui.bootstrap-slider', 'ngTld']);
var app = angular.module('Application', ['ngFitText', 'ngRoute', 'ngAnimate', 'ngSanitize', 'angular-md5', 'base64', 'slick', 'ui-notification', 'ui.bootstrap', 'ui.bootstrap-slider', 'ngTld', 'ui.multiselect']);
app.config(['NotificationProvider', function (NotificationProvider) {
NotificationProvider.setOptions({
+28 -21
View File
@@ -33,8 +33,15 @@ angular.module('Application').controller('MainController', ['$scope', '$route',
};
$scope.waitingForPlanSelection = false;
$('#version1Modal').on('hide.bs.modal', function () {
$('#setupSubscriptionModal').on('hide.bs.modal', function () {
$scope.waitingForPlanSelection = false;
// check for updates to stay in sync
Client.checkForUpdates(function (error) {
if (error) return console.error(error);
Client.refreshConfig();
});
});
$scope.waitForPlanSelection = function () {
@@ -45,18 +52,29 @@ angular.module('Application').controller('MainController', ['$scope', '$route',
function checkPlan() {
if (!$scope.waitingForPlanSelection) return;
if ($scope.currentSubscription.plan.id !== 'undecided') {
$scope.waitingForPlanSelection = false;
$('#version1Modal').modal('hide');
$('#updateModal').modal('show');
} else {
$timeout(checkPlan, 1000);
}
AppStore.getSubscription($scope.appstoreConfig, function (error, result) {
if (error) return console.error(error);
$scope.currentSubscription = result;
// check again to give more immediate feedback once a subscription was setup
if (result.plan.id === 'undecided' || result.plan.id === 'free') {
$timeout(checkPlan, 5000);
} else {
$scope.waitingForPlanSelection = false;
$('#setupSubscriptionModal').modal('hide');
if ($scope.config.update && $scope.config.update.box) $('#updateModal').modal('show');
}
});
}
checkPlan();
};
$scope.showSubscriptionModal = function () {
$('#setupSubscriptionModal').modal('show');
};
$scope.showUpdateModal = function (form) {
$scope.update.error.generic = null;
$scope.update.error.password = null;
@@ -73,14 +91,6 @@ angular.module('Application').controller('MainController', ['$scope', '$route',
$('#updateModal').modal('show');
} else if (!$scope.currentSubscription || !$scope.currentSubscription.plan) {
// do nothing as we were not able to get a subscription, yet
} else if ($scope.config.update.box.version === '1.0.0') {
// special case for updating to 1.0
if ($scope.currentSubscription.plan.id === 'undecided') {
$('#version1Modal').modal('show');
} else {
// user selected a plan already, let him update
$('#updateModal').modal('show');
}
} else {
$('#updateModal').modal('show');
}
@@ -144,7 +154,7 @@ angular.module('Application').controller('MainController', ['$scope', '$route',
});
}
function getSubscription() {
$scope.getSubscription = function () {
Client.getAppstoreConfig(function (error, result) {
if (error) return console.error(error);
@@ -160,9 +170,6 @@ angular.module('Application').controller('MainController', ['$scope', '$route',
if (error) return console.error(error);
$scope.currentSubscription = result;
// check again to give more immediate feedback once a subscription was setup
if (result.plan.id === 'undecided') $timeout(getSubscription, 5000);
});
});
}
@@ -232,7 +239,7 @@ angular.module('Application').controller('MainController', ['$scope', '$route',
if ($scope.user.admin) {
runConfigurationChecks();
if ($scope.config.provider !== 'caas') getSubscription();
if ($scope.config.provider !== 'caas') $scope.getSubscription();
}
});
});
+28 -3
View File
@@ -1,9 +1,17 @@
'use strict';
// create main application module
var app = angular.module('Application', ['angular-md5', 'ui-notification', 'ngTld']);
/* global tld */
app.controller('SetupDNSController', ['$scope', '$http', 'Client', 'ngTld', function ($scope, $http, Client, ngTld) {
// create main application module
var app = angular.module('Application', ['angular-md5', 'ui-notification']);
app.filter('zoneName', function () {
return function (domain) {
return tld.getDomain(domain);
};
});
app.controller('SetupDNSController', ['$scope', '$http', 'Client', function ($scope, $http, Client) {
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
$scope.initialized = false;
@@ -12,6 +20,22 @@ app.controller('SetupDNSController', ['$scope', '$http', 'Client', 'ngTld', func
$scope.provider = '';
$scope.showDNSSetup = false;
$scope.instanceId = '';
$scope.explicitZone = search.zone || '';
$scope.isDomain = false;
$scope.isSubdomain = false;
$scope.$watch('dnsCredentials.domain', function (newVal) {
if (!newVal) {
$scope.isDomain = false;
$scope.isSubdomain = false;
} else if (!tld.getDomain(newVal) || newVal[newVal.length-1] === '.') {
$scope.isDomain = false;
$scope.isSubdomain = false;
} else {
$scope.isDomain = true;
$scope.isSubdomain = tld.getDomain(newVal) !== newVal;
}
});
// keep in sync with certs.js
$scope.dnsProvider = [
@@ -38,6 +62,7 @@ app.controller('SetupDNSController', ['$scope', '$http', 'Client', 'ngTld', func
var data = {
domain: $scope.dnsCredentials.domain,
zoneName: $scope.explicitZone,
provider: $scope.dnsCredentials.provider,
accessKeyId: $scope.dnsCredentials.accessKeyId,
secretAccessKey: $scope.dnsCredentials.secretAccessKey,
+1 -1
View File
@@ -70,7 +70,7 @@
<input type="text" class="form-control" ng-model="account.displayName" id="inputDisplayName" name="displayName" placeholder="Display Name" required autocomplete="off" autofocus>
</div>
<div ng-show="account.requireEmail" class="form-group" ng-class="{ 'has-error': setupForm.email.$dirty && setupForm.email.$invalid }">
<input type="email" class="form-control" ng-model="account.email" id="inputEmail" name="email" placeholder="Email" required autocomplete="off" tooltip-trigger="focus" uib-tooltip="This email address is local to your Cloudron and used for notifications and password reset.">
<input type="email" class="form-control" ng-model="account.email" id="inputEmail" name="email" placeholder="Email" required autocomplete="off" tooltip-trigger="focus" uib-tooltip="This email address is local to your Cloudron and used for notifications and password reset. A valid email is also required for Let's Encrypt certificates.">
</div>
<div class="form-group" ng-class="{ 'has-error': (setupForm.username.$dirty && setupForm.username.$invalid) || (!setupForm.username.$dirty && error.username) }">
<p ng-show="!setupForm.username.$dirty && error.username">{{ error.username }}</p>
+5 -6
View File
@@ -28,7 +28,6 @@
<!-- Angular directives for tldjs -->
<script src="3rdparty/js/tld.js"></script>
<script src="3rdparty/js/angular-tld.js"></script>
<!-- Setup Application -->
<script src="js/setupdns.js"></script>
@@ -55,11 +54,11 @@
<div class="col-md-10 col-md-offset-1 text-center">
<h1>Cloudron Setup</h1>
<h3>Provide a domain for your Cloudron</h3>
<p>Apps will be installed on subdomains of that domain.</p>
<p>Apps will be installed on subdomains of this domain.</p>
<div class="form-group" style="margin-bottom: 0;" ng-class="{ 'has-error': dnsCredentialsForm.domain.$dirty && dnsCredentialsForm.domain.$invalid }">
<input type="text" class="form-control" ng-model="dnsCredentials.domain" name="domain" check-tld placeholder="example.com" required autofocus ng-disabled="dnsCredentials.busy">
<input type="text" class="form-control" ng-model="dnsCredentials.domain" name="domain" placeholder="example.com" required autofocus ng-disabled="dnsCredentials.busy">
</div>
<p>&nbsp;<span ng-show="dnsCredentialsForm.domain.$error.invalidSubdomain" class="text-danger">Subdomains are <a href="https://cloudron.io/references/selfhosting.html#domain-setup" target="_blank" title="Domain documentation">not supported</a></span></p>
<p ng-show="isSubdomain" class="text-bold">Installing Cloudron on a subdomain requires an enterprise subscription.</p>
</div>
</div>
<div class="row">
@@ -78,14 +77,14 @@
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.secretAccessKey.$dirty && dnsCredentialsForm.secretAccessKey.$invalid }" ng-show="dnsCredentials.provider === 'route53'">
<input type="text" class="form-control" ng-model="dnsCredentials.secretAccessKey" name="secretAccessKey" placeholder="Secret Access Key" ng-required="dnsCredentials.provider === 'route53'" ng-disabled="dnsCredentials.busy">
<br/>
<span>{{ dnsCredentials.domain || 'The domain' }} must be hosted on <a href="https://aws.amazon.com/route53/?nc2=h_m1" target="_blank">AWS Route53</a>.</span>
<span ng-show="isDomain || explicitZone"><b>{{ explicitZone ? explicitZone : (dnsCredentials.domain | zoneName) }}</b> must be hosted on <a href="https://aws.amazon.com/route53/?nc2=h_m1" target="_blank">AWS Route53</a>.</span>
</div>
<!-- DigitalOcean -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.digitalOceanToken.$dirty && dnsCredentialsForm.digitalOceanToken.$invalid }" ng-show="dnsCredentials.provider === 'digitalocean'">
<input type="text" class="form-control" ng-model="dnsCredentials.digitalOceanToken" name="digitalOceanToken" placeholder="API Token" ng-required="dnsCredentials.provider === 'digitalocean'" ng-disabled="dnsCredentials.busy">
<br/>
<span>{{ dnsCredentials.domain || 'The domain' }} must be hosted on <a href="https://www.digitalocean.com/community/tutorials/how-to-set-up-a-host-name-with-digitalocean#step-two%E2%80%94change-your-domain-server" target="_blank">DigitalOcean</a>.</span>
<span ng-show="isDomain || explicitZone"><b>{{ explicitZone ? explicitZone : (dnsCredentials.domain | zoneName) }}</b> must be hosted on <a href="https://www.digitalocean.com/community/tutorials/how-to-set-up-a-host-name-with-digitalocean#step-two%E2%80%94change-your-domain-server" target="_blank">DigitalOcean</a>.</span>
</div>
<!-- Wildcard -->
+6 -1
View File
@@ -153,9 +153,14 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">App Feedback</h4>
<h4 class="modal-title">Missing App Feedback</h4>
</div>
<div class="modal-body">
<p>
Please see all previously requested apps <a href="https://git.cloudron.io/cloudron/app-requests/issues" target="_blank">here</a> first.
If an app was already requested, leave a comment or give a +1, to help us prioritize better.
</p>
<br/>
<fieldset>
<form name="feedbackForm" ng-submit="feedback.submit()">
<div ng-show="feedback.error" class="text-danger text-bold">{{feedback.error}}</div>
+3 -2
View File
@@ -167,8 +167,6 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
$scope.appInstall.error.location = 'This name is already taken.';
$scope.appInstallForm.location.$setPristine();
$('#appInstallLocationInput').focus();
} else if (error.statusCode === 402) {
$scope.appstoreLogin.show();
} else if (error.statusCode === 400 && error.message.indexOf('cert') !== -1 ) {
$scope.appInstall.error.cert = error.message;
$scope.appInstall.certificateFileName = '';
@@ -312,6 +310,9 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
return;
}
// check subscription right away after login
$scope.$parent.getSubscription();
fetchAppstoreConfig();
});
});
+1 -2
View File
@@ -11,8 +11,7 @@
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.customDomainId.$invalid }" uib-tooltip="{{ config.provider === 'caas' ? '' : 'Changing the domain is not yet supported' }}">
<label class="control-label" for="customDomainId">Domain name</label>
<input type="text" class="form-control" ng-model="dnsCredentials.customDomain" id="customDomainId" name="customDomainId" ng-disabled="dnsCredentials.busy || config.provider !== 'caas'" check-tld placeholder="example.com" required autofocus>
<p>&nbsp;<span ng-show="dnsCredentialsForm.customDomainId.$error.invalidSubdomain && dnsCredentials.customDomain !== config.fqdn" class="text-danger">Subdomains are <a href="https://cloudron.io/references/selfhosting.html#domain-setup" target="_blank" title="Domain documentation">not supported</a></span></p>
<input type="text" class="form-control" ng-model="dnsCredentials.customDomain" id="customDomainId" name="customDomainId" ng-disabled="dnsCredentials.busy || config.provider !== 'caas'" placeholder="example.com" required autofocus>
</div>
<div class="form-group">
+21
View File
@@ -58,6 +58,27 @@
</div>
</div>
<div class="section-header" ng-show="mailConfig.enabled && isPaying">
<div class="text-left">
<h3>Catch-all</h3>
</div>
</div>
<div class="card" style="margin-bottom: 15px;" ng-show="mailConfig.enabled && isPaying">
<div class="row">
<div class="col-md-12">
Emails sent to non existing addresses will be forwarded to the following accounts:
</div>
</div>
<br/>
<div class="row">
<div class="col-md-6">
<multiselect ng-model="catchall.addresses" options="address for address in catchall.availableAddresses" data-multiple="true"></multiselect>
<button class="btn btn-outline btn-primary" ng-disabled="catchall.busy" ng-click="catchall.submit()"><i class="fa fa-circle-o-notch fa-spin" ng-show="catchall.busy"></i> Save</button>
</div>
</div>
</div>
<div class="section-header" ng-show="dnsConfig.provider && dnsConfig.provider !== 'caas'">
<div class="text-left">
<h3>DNS Records</h3>
+60 -1
View File
@@ -1,6 +1,6 @@
'use strict';
angular.module('Application').controller('EmailController', ['$scope', '$location', '$rootScope', 'Client', function ($scope, $location, $rootScope, Client) {
angular.module('Application').controller('EmailController', ['$scope', '$location', '$rootScope', 'Client', 'AppStore', function ($scope, $location, $rootScope, Client, AppStore) {
Client.onReady(function () { if (!Client.getUserInfo().admin) $location.path('/'); });
$scope.client = Client;
@@ -17,6 +17,8 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
{ name: 'PTR', value: 'ptr' }
];
$scope.mailConfig = null;
$scope.users = [];
$scope.isPaying = false;
$scope.showView = function (view) {
// wait for dialog to be fully closed to avoid modal behavior breakage when moving to a different view already
@@ -28,6 +30,21 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
$('.modal').modal('hide');
};
$scope.catchall = {
addresses: [],
busy: false,
submit: function () {
$scope.catchall.busy = true;
Client.setCatchallAddresses($scope.catchall.addresses, function (error) {
if (error) console.error('Unable to add catchall address.', error);
$scope.catchall.busy = false;
});
}
};
$scope.email = {
refreshBusy: false,
@@ -107,9 +124,51 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
});
}
function getUsers() {
Client.getUsers(function (error, result) {
if (error) return console.error('Unable to get user listing.', error);
// only allow users with a Cloudron email address
$scope.catchall.availableAddresses = result.filter(function (u) { return !!u.email; }).map(function (u) { return u.username; });
});
}
function getCatchallAddresses() {
Client.getCatchallAddresses(function (error, result) {
if (error) return console.error('Unable to get catchall address listing.', error);
// dedupe in case to avoid angular breakage
$scope.catchall.addresses = result.filter(function(item, pos, self) {
return self.indexOf(item) == pos;
});
});
}
function getSubscription() {
if ($scope.config.provider === 'caas') {
$scope.isPaying = true;
return;
}
Client.getAppstoreConfig(function (error, result) {
if (error) return console.error(error);
if (!result.token) return;
AppStore.getSubscription(result, function (error, result) {
if (error) return console.error(error);
$scope.isPaying = result.plan.id !== 'free' && result.plan.id !== 'undecided';
});
});
}
Client.onReady(function () {
getMailConfig();
getDnsConfig();
getSubscription();
getUsers();
getCatchallAddresses();
$scope.email.refresh();
});
+4 -21
View File
@@ -221,23 +221,6 @@
</div>
</div>
<!-- Modal subscription required -->
<div class="modal" id="subscriptionRequiredModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
</div>
<div class="modal-body">
The Cloudron Email server is only available in the paid plans.<br/>
<br/>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-dismiss="modal">OK</button>
</div>
</div>
</div>
</div>
<br/>
<div class="section-header">
@@ -323,9 +306,9 @@
</div>
<div class="card" style="margin-bottom: 15px;" ng-show="backupConfig.provider !== 'caas'">
<div class="row" ng-show="currentSubscription.plan.id === 'free'">
<div class="row" ng-show="currentSubscription.plan.id === 'free' || currentSubscription.plan.id === 'undecided'">
<div class="col-xs-12">
A cloudron.io subscription will provide you with effortless automatic app and platform updates.
With a paid plan, you get continuous updates for the Cloudron and apps. This ensures you are running the latest versions of apps and keeps your server secure. All paid plans come with support via <a href="mailto:support@cloudron.io">email</a> and <a target="_blank" href="https://chat.cloudron.io">live chat</a>.
</div>
</div>
<br/>
@@ -348,8 +331,8 @@
<br/>
<div class="row">
<div class="col-xs-12">
<a class="btn btn-primary pull-right" ng-href="{{ config.webServerOrigin + '/console.html#/userprofile?view=subscriptions&email=' + appstoreConfig.profile.email + '&cloudronId=' + appstoreConfig.cloudronId }}" target="_blank" ng-show="currentSubscription.plan && currentSubscription.plan.id !== 'free'">Change Subscription</a>
<a class="btn btn-success pull-right" ng-href="{{ config.webServerOrigin + '/console.html#/userprofile?view=subscriptions&email=' + appstoreConfig.profile.email + '&cloudronId=' + appstoreConfig.cloudronId }}" target="_blank" ng-show="currentSubscription.plan.id === 'free'">Setup Subscription</a>
<a class="btn btn-primary pull-right" ng-href="{{ config.webServerOrigin + '/console.html#/userprofile?view=subscriptions&email=' + appstoreConfig.profile.email }}" target="_blank" ng-show="currentSubscription.plan && currentSubscription.plan.id !== 'free' && currentSubscription.plan.id !== 'undecided'">Configure</a>
<a class="btn btn-success pull-right" ng-href="{{ config.webServerOrigin + '/console.html#/userprofile?view=subscriptions&email=' + appstoreConfig.profile.email + '&cloudronId=' + appstoreConfig.cloudronId }}" target="_blank" ng-show="currentSubscription.plan.id === 'free' || currentSubscription.plan.id === 'undecided'">Setup Subscription</a>
</div>
</div>
</div>