Compare commits

..

135 Commits

Author SHA1 Message Date
Girish Ramakrishnan 37e3278f23 Update mail container for haraka fixes 2023-06-25 15:52:52 +05:30
Johannes Zellner 7cee40b491 filemanager: Remove back/goup button 2023-06-22 18:56:52 +02:00
Johannes Zellner fae23bd4fc filemanager: update pankow 2023-06-22 18:12:11 +02:00
Johannes Zellner 148a189bb2 filemanager: further fix the current folder entry 2023-06-22 18:11:05 +02:00
Johannes Zellner c3778f94c4 filemanager: set correct name for activeDirectory 2023-06-22 15:51:24 +02:00
Johannes Zellner b7fbffcb42 various filemanager fixes 2023-06-22 15:20:54 +02:00
Girish Ramakrishnan 6259849958 apphealth: timeout is already in msecs 2023-06-22 18:24:59 +05:30
Johannes Zellner eb767bb3b1 filemanager: add missing colon for props 2023-06-22 13:23:43 +02:00
Johannes Zellner a6f01b2455 Ensure all filemanager buttons explicitly use Noto font 2023-06-22 12:57:04 +02:00
Johannes Zellner 4fe055c3a8 oidc: automatically submit consent form
Fixes #828
2023-06-21 13:14:45 +02:00
Girish Ramakrishnan 79d9cce2e7 Fix ptr record link 2023-06-21 16:43:03 +05:30
Johannes Zellner 9fbfdd08d8 Update translation 2023-06-20 15:33:46 +02:00
Johannes Zellner 879569c661 filemanager: show busy state when extraction is in progress 2023-06-20 15:33:26 +02:00
Johannes Zellner 5814793dc1 filemanager: Integrate download and extract logic 2023-06-20 15:21:58 +02:00
Johannes Zellner 299e40c389 Allow cors for translation 2023-06-20 10:40:27 +02:00
Johannes Zellner 38860cd70c Redirect to / on dashboard 404 2023-06-19 15:02:28 +02:00
Johannes Zellner c8fe2611ba Also fix bottom bar for password reset 2023-06-19 14:08:10 +02:00
Johannes Zellner af9175b30c Better login action bar styling 2023-06-19 13:55:58 +02:00
Johannes Zellner 35453a0c2d Translate the oidc login view 2023-06-19 11:50:53 +02:00
Johannes Zellner fd91bf0498 Update translations 2023-06-18 20:19:12 +02:00
Johannes Zellner 3b02ef5591 filemanager: inject tr() for pankow 2023-06-18 20:11:48 +02:00
Johannes Zellner 2966763e9e filemanager: pankow has translation support 2023-06-18 18:35:55 +02:00
Johannes Zellner 6d7759a1af filemanager: add translation support 2023-06-18 17:39:40 +02:00
Johannes Zellner 70e7ca395d Update filemanager dependencies 2023-06-16 17:15:09 +02:00
Johannes Zellner 922c587ca9 Fix context menu closing with new pankow version 2023-06-16 17:13:45 +02:00
Johannes Zellner a555d70868 Add real info to filemanager readme 2023-06-16 12:49:47 +02:00
Johannes Zellner 6f6907363e Dashboard login view is gone and replaced with oidc 2023-06-15 18:05:06 +02:00
Girish Ramakrishnan 77d601f0cc mailbox: fix crash when editing quota of new mailboxes 2023-06-15 20:59:25 +05:30
Johannes Zellner 8e99f67fb7 use 'development' client only if apiOrigin template value is empty 2023-06-15 16:41:14 +02:00
Johannes Zellner 9d3fa94960 Add separate password reset view 2023-06-15 16:34:58 +02:00
Johannes Zellner b6739e9d77 Support local development dashboard login 2023-06-15 15:44:16 +02:00
Johannes Zellner 33c1b4ae3b oidc: also send profile with auth code
this helps us to be a bit more conforming with google and MS oidc
provider
2023-06-14 16:49:35 +02:00
Johannes Zellner 67c0a4f513 Copy selected terminal text with ctrl shift c 2023-06-13 15:27:16 +02:00
Johannes Zellner ce1181531a Update dashboard dependencies and fixup apps icon for new fontawesome 2023-06-13 13:54:34 +02:00
Girish Ramakrishnan 54682a1370 remove duplicate require 2023-06-04 18:23:26 +02:00
Girish Ramakrishnan dc5342b9fc automation tag is better 2023-06-04 18:18:22 +02:00
Girish Ramakrishnan 83bb7c475d add devops category 2023-06-04 18:11:34 +02:00
Johannes Zellner 638bdc902b Add implicit grants for dashboard 2023-06-04 17:39:31 +02:00
Johannes Zellner 874064de67 Only store dashboard accessTokens in tokensdb 2023-06-04 17:39:31 +02:00
Johannes Zellner 1f134ff070 Skip consent screen for dashboard login 2023-06-04 17:39:31 +02:00
Johannes Zellner 2c334170bd oidc dashboard login 2023-06-04 17:39:29 +02:00
Johannes Zellner 35efdf6cbd Support both sets of Hetzner nameservers 2023-05-31 18:25:09 +02:00
Girish Ramakrishnan e02f3d7064 Fix dashboard crash when installing app with no addons 2023-05-30 11:06:33 +02:00
Girish Ramakrishnan a5e83a4d84 Expose alias domains as CLOUDRON_ALIAS_DOMAINS
This can be useful for app to set them in trusted hosts. Or alternately,
show different text when accessed from different domains.
2023-05-25 11:47:41 +02:00
Girish Ramakrishnan e6ba2a6e7a replace usage of _.extend with Object.assign 2023-05-25 11:45:14 +02:00
Johannes Zellner 79dd50910c oidc: render error page instead of raw error body 2023-05-23 12:13:55 +02:00
Johannes Zellner c4d267ecb1 filemanager: add restart logic 2023-05-23 11:38:57 +02:00
Johannes Zellner 2011dd9a83 Explicitly add noto font to filemanager assets 2023-05-23 11:08:06 +02:00
Johannes Zellner b07131cd0f oidc: add password reset link to login view 2023-05-22 20:32:33 +02:00
Johannes Zellner d3fe165e2c oidc: Remove console.log in login screen 2023-05-22 20:19:30 +02:00
Johannes Zellner bf19de3a90 Fixup filemanager links 2023-05-22 16:27:48 +02:00
Johannes Zellner 58a0b3d8e7 Ensure localPath is quoted in case it contains spaces 2023-05-21 14:14:42 +02:00
Johannes Zellner 65c2ee1760 filemanager: Add logs and terminal links for apps 2023-05-16 17:48:53 +02:00
Johannes Zellner dfb0a7fee1 filemanager: update dependencies 2023-05-16 15:34:16 +02:00
Girish Ramakrishnan 7511339656 bump timeout when waiting for container
some server disks are very slow
2023-05-16 09:51:42 +02:00
Girish Ramakrishnan cb106f8a55 Fixup text when logs are missing 2023-05-16 09:36:30 +02:00
Girish Ramakrishnan 39d45b71d7 installer: remove user creation, already in init-ubuntu script 2023-05-15 21:10:29 +02:00
Girish Ramakrishnan db1fa84936 update: log history 2023-05-15 21:08:20 +02:00
Girish Ramakrishnan f83295372b updater: combine installer logs into the task file 2023-05-15 19:09:40 +02:00
Girish Ramakrishnan e6506d9458 updater: use log 2023-05-15 19:05:39 +02:00
Johannes Zellner af63dbb31d Show error when logs are gone 2023-05-15 17:49:34 +02:00
Johannes Zellner b5641cc445 Show at least basic error if task or app not found in logviewer 2023-05-15 17:20:43 +02:00
Johannes Zellner 576fb392bb Show dashboard domain change tasks like in other sections 2023-05-15 12:02:59 +02:00
Girish Ramakrishnan ff539e2669 remove crashnotifier
it's not really used
2023-05-15 11:08:00 +02:00
Girish Ramakrishnan 506d3adf70 Fix crash when querying backup mount status 2023-05-15 10:40:39 +02:00
Girish Ramakrishnan 94eb7849fe tasks: return 404 if task not found
part of #826
2023-05-15 10:16:00 +02:00
Johannes Zellner 9036b272a8 filemanager: update pankow module 2023-05-15 10:10:47 +02:00
Johannes Zellner c81467da7c filemanager: add refresh button 2023-05-15 09:57:58 +02:00
Johannes Zellner 6db3a20021 filemanager: support fallbackIcon 2023-05-15 09:26:37 +02:00
Johannes Zellner a428d6c553 filemanager: update dependencies 2023-05-15 09:02:31 +02:00
Girish Ramakrishnan b7b01d5605 domains: show current task in renewCert, syncDns 2023-05-14 11:47:21 +02:00
Girish Ramakrishnan 500d2361ec replace delay.js with timers/promises 2023-05-14 10:53:50 +02:00
Girish Ramakrishnan 75ba20201e Update modules 2023-05-14 07:23:04 +02:00
Girish Ramakrishnan b26c8d20cd network: add trusted ips
This allows the user to set trusted ips to Cloudflare or some other CDN
and have the logs have the correct IPs.

fixes #801
2023-05-13 16:15:47 +02:00
Girish Ramakrishnan 951ed4bf33 Update translations 2023-05-13 15:46:08 +02:00
Johannes Zellner 2a05ec3866 Move password-reveal.js to correct folder 2023-05-12 18:53:42 +02:00
Johannes Zellner 04f2bd1ec3 Add password-reveal feature to oidc login 2023-05-12 18:47:48 +02:00
Johannes Zellner e08116c9ad be more consistent in oidc login screen with dashboard login 2023-05-12 18:24:54 +02:00
Johannes Zellner da7fbeee3d oidc: Give proper login error feedback 2023-05-12 17:14:40 +02:00
Johannes Zellner 61aa32d8c5 App icon route is no open to public 2023-05-12 15:14:47 +02:00
Johannes Zellner 74ff5e8de4 Fix authorize for text in oidc consent screen 2023-05-12 14:01:20 +02:00
Johannes Zellner aad70a49b7 Remove dashboard button on oidc logout 2023-05-12 13:54:35 +02:00
Johannes Zellner d332bb05fa Show app name during oidc login 2023-05-12 13:51:50 +02:00
Johannes Zellner 6b6781eabb filemanager: vue is picky about the type 2023-05-12 13:32:51 +02:00
Girish Ramakrishnan 4a1cdd4ef1 Update aws-sdk and suppress maintenance mode message
https://github.com/aws/aws-sdk-js/issues/4354
2023-05-11 22:18:00 +02:00
Johannes Zellner 764a8f6a85 filemanager: Show non-dismissable dialog on fatal error 2023-05-11 18:36:09 +02:00
Johannes Zellner 22a0b84c2a filemanager: update dependencies 2023-05-11 16:45:13 +02:00
Johannes Zellner bba911165b Remove noisy openid debugs 2023-05-11 16:22:58 +02:00
Johannes Zellner 8656bea4f2 Update oidc-provider 2023-05-11 16:16:19 +02:00
Johannes Zellner 9024844449 Set favicon for OpenId views 2023-05-11 13:48:36 +02:00
Johannes Zellner 89c5b81eb0 Add very basic initial cloudron-logs helper 2023-05-11 12:30:00 +02:00
Johannes Zellner 18a7b0e615 dashboard: use sass instead of deprecated node-sass 2023-05-11 11:29:08 +02:00
Johannes Zellner 1407fbeb8c Fix syntax error in gulpfile 2023-05-11 10:57:52 +02:00
Johannes Zellner b5fc377dab Set app's fqdn as fallback logout redirect URI for oidc 2023-05-11 10:57:52 +02:00
Girish Ramakrishnan 71af16beb9 Update packages 2023-05-11 10:33:18 +02:00
Girish Ramakrishnan 96d3eda02b dashboard: update packages 2023-05-11 08:50:18 +02:00
Girish Ramakrishnan ba2a6bab68 dashboard: remove rimraf 2023-05-11 08:48:42 +02:00
Girish Ramakrishnan 092cc40da6 Fix test 2023-05-11 08:32:31 +02:00
Girish Ramakrishnan c55152c0e1 node: update to 18.16.0 2023-05-11 08:32:31 +02:00
Girish Ramakrishnan e83bb0c639 docker: update to 23.0.6 2023-05-11 08:32:31 +02:00
Johannes Zellner 318285cb07 Support pageSize customization via localStorage 2023-05-10 13:52:41 +02:00
Girish Ramakrishnan 5274e1c454 docker: registry finally has ipv6 support
https://github.com/docker/roadmap/issues/89
2023-05-10 10:14:25 +02:00
Girish Ramakrishnan 294a535c1b cloudron-support: better formatting of log link 2023-05-10 09:11:04 +02:00
Girish Ramakrishnan eaeb80e3c0 cloudron-support: add uname and lsb_release info 2023-05-10 09:08:04 +02:00
Johannes Zellner 6eb8047686 filemanager: open unsupported types in browser itself 2023-05-09 18:53:23 +02:00
Johannes Zellner db040bf293 There is no mail for filemanager 2023-05-09 10:58:29 +02:00
Girish Ramakrishnan acfc1ede6e add to changes 2023-05-09 10:55:22 +02:00
Girish Ramakrishnan 8910c76bcf Update redis to 7.0.11 2023-05-09 10:54:17 +02:00
Johannes Zellner 342093f661 filemanager: improve resource (app/volume/mail) handling 2023-05-08 18:08:11 +02:00
Johannes Zellner 9e26db3cd2 Only show disks with the correct fs type for volumes 2023-05-08 18:07:42 +02:00
Johannes Zellner a71b39ddee Start using the new filemanager 2023-05-08 16:09:33 +02:00
Johannes Zellner 0626354844 Fixup custom disk setup for volumes 2023-05-08 15:23:25 +02:00
Johannes Zellner e9d2a53aaf Add new ionos profitbricks regions 2023-05-08 14:04:46 +02:00
Girish Ramakrishnan ca59bbe1aa remove try/catch 2023-05-08 11:30:21 +02:00
Girish Ramakrishnan f505b1a553 remove log line which ends up in log file 2023-05-07 20:53:04 +02:00
Girish Ramakrishnan a237b11ff7 timezone: set default tz to UTC 2023-05-07 20:51:02 +02:00
Johannes Zellner 9a77f012d8 filemanager: Add path breadcrumbs and update dependencies 2023-05-07 17:04:07 +02:00
Johannes Zellner 36c7f779f3 filemanager: a symlink can't be opened 2023-05-07 13:50:41 +02:00
Girish Ramakrishnan b970e90178 cloudron-support: provider not needed 2023-05-05 17:18:38 +02:00
Johannes Zellner a7ea34914d Also put new task log style for backups view 2023-05-03 16:50:07 +02:00
Johannes Zellner 19e1e5861b provide more task logs for synDNS section 2023-05-03 16:33:19 +02:00
Girish Ramakrishnan e23777a642 kill a warning from npm 2023-05-03 09:15:16 +02:00
Girish Ramakrishnan a2f47f3ee2 7.5.0 changes 2023-05-02 23:08:42 +02:00
Girish Ramakrishnan 15e0f11bb9 acme: handle LE validation type cache logic
LE stores the validation type for 60 days. So, if we authorized via http previously,
we won't get a DNS challenge for that duration.

There are two ways to fix this:
* Deactivate the challenges - https://community.letsencrypt.org/t/authorization-deactivation/19860 and https://community.letsencrypt.org/t/deactivate-authorization/189526
* Just be able to handle dns or http challenge, whatever is asked. This is what this commit does. It prefers DNS challenge when possible

Other relevant threads:

https://community.letsencrypt.org/t/flush-of-authorization-cache/188043
https://community.letsencrypt.org/t/let-s-encrypt-s-vulnerability-as-a-feature-authz-reuse-and-eternal-account-key/21687
https://community.letsencrypt.org/t/http-01-validation-cache/22529
2023-05-02 23:07:32 +02:00
Johannes Zellner 1a32ea511e Use circle icons for task log status 2023-05-02 22:16:16 +02:00
Johannes Zellner ac602dc2a9 Give option to display last 10 cert renewal task logs 2023-05-02 16:55:57 +02:00
Johannes Zellner cf3fc940d2 Put all log viewer buttons in the section headers for the backup view 2023-05-02 15:02:41 +02:00
Johannes Zellner e09cac4ea1 Apply consisten section spacing to all views 2023-05-02 14:29:52 +02:00
Johannes Zellner 7c96115ea9 set constent section spacing in domains view 2023-05-02 14:12:27 +02:00
Johannes Zellner 12de353427 Make domains view also use uib-tooltips for consistency 2023-05-02 13:58:25 +02:00
Girish Ramakrishnan 057e4db6c1 use debug instead of console.error 2023-04-30 21:49:34 +02:00
Girish Ramakrishnan 883915c9d3 backups: move mount status to separate route 2023-04-30 17:21:18 +02:00
Girish Ramakrishnan 898413bfd4 convert console.log to debug 2023-04-30 10:18:48 +02:00
Girish Ramakrishnan aa02d839a7 remove console.log 2023-04-30 10:18:48 +02:00
Girish Ramakrishnan a4ba3a4dd0 import: backupConfig cannot be null 2023-04-30 10:18:48 +02:00
129 changed files with 2728 additions and 5520 deletions
+8 -3
View File
@@ -2628,7 +2628,12 @@
* Fix ipv4 vs ipv6 detection
* Fix misleading pending security updates message
[7.4.3]
* **IMPORTANT**: This is the last release of Cloudron to support Ubuntu 18.04. Please [upgrade](https://docs.cloudron.io/guides/upgrade-ubuntu-20/) to Ubuntu 20.04 (Focal Fossa).
* postgresql: fix for supporting Taiga with postgres 14
[7.5.0]
* acme: handle LE validation type cache logic
* improve viewing of logs
* redis: update to 7.0.11
* ionos profitbricks: add new regions Berlin and Logrono
* docker: update to 23.0.6
* network: trusted IPs
* fix crash when editing quota of new mailboxes
-2
View File
@@ -79,8 +79,6 @@ async function main() {
});
process.on('uncaughtException', (error) => exitSync({ error, code: 1 }));
console.log(`Cloudron is up and running. Logs are at ${paths.BOX_LOG_FILE}`); // this goes to journalctl
}
main();
-22
View File
@@ -1,22 +0,0 @@
#!/usr/bin/env node
'use strict';
const database = require('./src/database.js');
const crashNotifier = require('./src/crashnotifier.js');
// This is triggered by systemd with the crashed unit name as argument
async function main() {
if (process.argv.length !== 3) return console.error('Usage: crashnotifier.js <unitName>');
const unitName = process.argv[2];
console.log('Started crash notifier for', unitName);
// eventlog api needs the db
await database.initialize();
await crashNotifier.sendFailureLogs(unitName);
}
main();
+9 -10
View File
@@ -2,15 +2,15 @@
'use strict';
var argv = require('yargs').argv,
const argv = require('yargs').argv,
autoprefixer = require('gulp-autoprefixer'),
concat = require('gulp-concat'),
cssnano = require('gulp-cssnano'),
ejs = require('gulp-ejs'),
execSync = require('child_process').execSync,
fs = require('fs'),
gulp = require('gulp'),
rimraf = require('rimraf'),
sass = require('gulp-sass')(require('node-sass')),
sass = require('gulp-sass')(require('sass')),
serve = require('gulp-serve'),
sourcemaps = require('gulp-sourcemaps');
@@ -142,11 +142,11 @@ gulp.task('js-terminal', function () {
.pipe(gulp.dest('dist/js'));
});
gulp.task('js-login', function () {
return gulp.src(['src/js/login.js', 'src/js/utils.js'])
gulp.task('js-passwordreset', function () {
return gulp.src(['src/js/passwordreset.js', 'src/js/utils.js'])
.pipe(ejs({ apiOrigin: apiOrigin, revision: revision, appstore: appstore }, {}, { ext: '.js' }))
.pipe(sourcemaps.init())
.pipe(concat('login.js', { newLine: ';' }))
.pipe(concat('passwordreset.js', { newLine: ';' }))
.pipe(sourcemaps.write())
.pipe(gulp.dest('dist/js'));
});
@@ -187,7 +187,7 @@ gulp.task('js-restore', function () {
.pipe(gulp.dest('dist/js'));
});
gulp.task('js', gulp.series([ 'js-index', 'js-logs', 'js-filemanager', 'js-terminal', 'js-login', 'js-setupaccount', 'js-setup', 'js-setupdns', 'js-restore' ]));
gulp.task('js', gulp.series([ 'js-index', 'js-logs', 'js-filemanager', 'js-terminal', 'js-passwordreset', 'js-setupaccount', 'js-setup', 'js-setupdns', 'js-restore' ]));
// --------------
// HTML
@@ -245,8 +245,7 @@ gulp.task('timezones', function (done) {
// --------------
gulp.task('clean', function (done) {
rimraf.sync('dist');
done();
fs.rm('dist', { recursive: true, force: true }, done);
});
gulp.task('default', gulp.series(['clean', 'html', 'js', 'timezones', '3rdparty', 'translation', 'images', 'css']));
@@ -266,7 +265,7 @@ gulp.task('watch', function (done) {
gulp.watch(['src/js/logs.js', 'src/js/client.js', 'src/js/utils.js'], gulp.series(['js-logs']));
gulp.watch(['src/js/filemanager.js', 'src/js/client.js', 'src/js/utils.js', 'src/components/*.js'], gulp.series(['js-filemanager']));
gulp.watch(['src/js/terminal.js', 'src/js/client.js', 'src/js/utils.js'], gulp.series(['js-terminal']));
gulp.watch(['src/js/login.js', 'src/js/utils.js'], gulp.series(['js-login']));
gulp.watch(['src/js/passwordreset.js', 'src/js/utils.js'], gulp.series(['js-passwordreset']));
gulp.watch(['src/js/setupaccount.js', 'src/js/utils.js'], gulp.series(['js-setupaccount']));
gulp.watch(['src/js/index.js', 'src/js/client.js', 'src/js/main.js', 'src/views/*.js', 'src/js/utils.js'], gulp.series(['js-index']));
gulp.watch(['src/3rdparty/**/*'], gulp.series(['3rdparty']));
+343 -3762
View File
File diff suppressed because it is too large Load Diff
+7 -8
View File
@@ -13,9 +13,9 @@
"author": "",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@fortawesome/fontawesome-free": "^5.15.4",
"bootstrap-sass": "^3.4.1",
"chart.js": "^4.1.1",
"@fortawesome/fontawesome-free": "^6.4.0",
"bootstrap-sass": "^3.4.3",
"chart.js": "^4.3.0",
"gulp": "^4.0.2",
"gulp-autoprefixer": "^8.0.0",
"gulp-concat": "^2.6.1",
@@ -25,13 +25,12 @@
"gulp-serve": "^1.4.0",
"gulp-sourcemaps": "^3.0.0",
"moment": "^2.29.4",
"monaco-editor": "^0.34.0",
"node-sass": "^7.0.3",
"rimraf": "^3.0.2",
"xterm": "^5.1.0",
"monaco-editor": "^0.39.0",
"sass": "^1.63.3",
"xterm": "^5.2.1",
"xterm-addon-attach": "^0.8.0",
"xterm-addon-fit": "^0.7.0",
"yargs": "^17.5.1"
"yargs": "^17.7.2"
},
"eslintConfig": {
"env": {
+32
View File
@@ -0,0 +1,32 @@
// Custom library to add password show/hide icons to input element with `password-reveal` attribute
// util.js has the angular version, this is for plain js
window.addEventListener('load', function () {
var svgEye = '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="eye" class="svg-inline--fa fa-eye fa-w-18" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path fill="currentColor" d="M572.52 241.4C518.29 135.59 410.93 64 288 64S57.68 135.64 3.48 241.41a32.35 32.35 0 0 0 0 29.19C57.71 376.41 165.07 448 288 448s230.32-71.64 284.52-177.41a32.35 32.35 0 0 0 0-29.19zM288 400a144 144 0 1 1 144-144 143.93 143.93 0 0 1-144 144zm0-240a95.31 95.31 0 0 0-25.31 3.79 47.85 47.85 0 0 1-66.9 66.9A95.78 95.78 0 1 0 288 160z"></path></svg>';
var svgEyeSlash = '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="eye-slash" class="svg-inline--fa fa-eye-slash fa-w-20" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path fill="currentColor" d="M320 400c-75.85 0-137.25-58.71-142.9-133.11L72.2 185.82c-13.79 17.3-26.48 35.59-36.72 55.59a32.35 32.35 0 0 0 0 29.19C89.71 376.41 197.07 448 320 448c26.91 0 52.87-4 77.89-10.46L346 397.39a144.13 144.13 0 0 1-26 2.61zm313.82 58.1l-110.55-85.44a331.25 331.25 0 0 0 81.25-102.07 32.35 32.35 0 0 0 0-29.19C550.29 135.59 442.93 64 320 64a308.15 308.15 0 0 0-147.32 37.7L45.46 3.37A16 16 0 0 0 23 6.18L3.37 31.45A16 16 0 0 0 6.18 53.9l588.36 454.73a16 16 0 0 0 22.46-2.81l19.64-25.27a16 16 0 0 0-2.82-22.45zm-183.72-142l-39.3-30.38A94.75 94.75 0 0 0 416 256a94.76 94.76 0 0 0-121.31-92.21A47.65 47.65 0 0 1 304 192a46.64 46.64 0 0 1-1.54 10l-73.61-56.89A142.31 142.31 0 0 1 320 112a143.92 143.92 0 0 1 144 144c0 21.63-5.29 41.79-13.9 60.11z"></path></svg>';
document.querySelectorAll('[password-reveal]').forEach(function (element) {
var eye = document.createElement('i');
eye.innerHTML = svgEyeSlash;
eye.style.width = '18px';
eye.style.height = '18px';
eye.style.position = 'relative';
eye.style.float = 'right';
eye.style.marginTop = '-24px';
eye.style.marginRight = '10px';
eye.style.cursor = 'pointer';
eye.addEventListener('click', function () {
if (element.type === 'password') {
element.type = 'text';
eye.innerHTML = svgEye;
} else {
element.type = 'password';
eye.innerHTML = svgEyeSlash;
}
});
element.parentNode.style.position = 'relative';
element.parentNode.insertBefore(eye, element.nextSibling);
});
});
+11
View File
@@ -0,0 +1,11 @@
<script>
var tmp = window.location.hash.slice(1).split('&');
tmp.forEach(function (pair) {
if (pair.indexOf('access_token=') === 0) localStorage.token = pair.split('=')[1];
});
window.location.href = '/';
</script>
+1 -1
View File
@@ -159,7 +159,7 @@
</a>
</li>
<li>
<a ng-class="{ active: isActive('/apps')}" href="#/apps"><i class="fa fa-th fa-fw"></i> {{ 'apps.title' | tr }}</a>
<a ng-class="{ active: isActive('/apps')}" href="#/apps"><i class="fa fa-grip fa-fw"></i> {{ 'apps.title' | tr }}</a>
</li>
<li ng-show="user.isAtLeastAdmin">
<a ng-class="{ active: isActive('/appstore')}" href="#/appstore"><i class="fa fa-cloud-download-alt fa-fw"></i> {{ 'appstore.title' | tr }}</a>
+59 -20
View File
@@ -185,9 +185,11 @@ const REGIONS_OVH = [
{ name: 'Warsaw (WAW)', value: 'https://s3.waw.cloud.ovh.net', region: 'waw' },
];
// https://devops.ionos.com/api/s3/
// https://docs.ionos.com/cloud/managed-services/s3-object-storage/endpoints
const REGIONS_IONOS = [
{ name: 'DE', value: 'https://s3-de-central.profitbricks.com', region: 's3-de-central' }, // default
{ name: 'Frankfurt (DE)', value: 'https://s3-de-central.profitbricks.com', region: 's3-de-central' }, // default
{ name: 'Berlin (eu-central-2)', value: 'https://s3-eu-central-2.ionoscloud.com', region: 'eu-central-2' }, // default
{ name: 'Logrono (eu-south-2)', value: 'https://s3-eu-south-2.ionoscloud.com', region: 'eu-south-2' }, // default
];
// this is not used anywhere because upcloud needs endpoint URL. we detect region from the URL (https://upcloud.com/data-centres)
@@ -1008,6 +1010,14 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.getBackupMountStatus = function (callback) {
get('/api/v1/backups/mount_status', null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
callback(null, data);
});
};
Client.prototype.remountBackupStorage = function (callback) {
post('/api/v1/backups/remount', {}, null, function (error, data, status) {
if (error) return callback(error);
@@ -1118,9 +1128,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
};
Client.prototype.getBlocklist = function (callback) {
var config = {};
get('/api/v1/network/blocklist', config, function (error, data, status) {
get('/api/v1/network/blocklist', null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
callback(null, data.blocklist);
@@ -1136,6 +1144,23 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.getTrustedIps = function (callback) {
get('/api/v1/network/trusted_ips', null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
callback(null, data.trustedIps);
});
};
Client.prototype.setTrustedIps = function (trustedIps, callback) {
post('/api/v1/network/trusted_ips', { trustedIps: trustedIps }, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
callback(null);
});
};
Client.prototype.setDynamicDnsConfig = function (enabled, callback) {
post('/api/v1/settings/dynamic_dns', { enabled: enabled }, null, function (error, data, status) {
if (error) return callback(error);
@@ -1361,6 +1386,15 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.getTasksByType = function (type, callback) {
get('/api/v1/tasks?type=' + type, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
callback(null, data.tasks);
});
};
Client.prototype.getTask = function (taskId, callback) {
get('/api/v1/tasks/' + taskId, null, function (error, data, status) {
if (error) return callback(error);
@@ -1373,7 +1407,8 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
Client.prototype.getTaskLogs = function (taskId, follow, lines, callback) {
if (follow) {
var eventSource = new EventSource(client.apiOrigin + '/api/v1/tasks/' + taskId + '/logstream?lines=' + lines + '&access_token=' + token);
callback(null, eventSource);
eventSource.onerror = callback;
eventSource.onopen = function () { callback(null, eventSource); };
} else {
get('/api/v1/services/' + taskId + '/logs?lines=' + lines, null, function (error, data, status) {
if (error) return callback(error);
@@ -1510,7 +1545,8 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
Client.prototype.getPlatformLogs = function (unit, follow, lines, callback) {
if (follow) {
var eventSource = new EventSource(client.apiOrigin + '/api/v1/cloudron/logstream/' + unit + '?lines=' + lines + '&access_token=' + token);
callback(null, eventSource);
eventSource.onerror = callback;
eventSource.onopen = function () { callback(null, eventSource); };
} else {
get('/api/v1/cloudron/logs/' + unit + '?lines=' + lines, null, function (error, data, status) {
if (error) return callback(error);
@@ -1524,7 +1560,8 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
Client.prototype.getServiceLogs = function (serviceName, follow, lines, callback) {
if (follow) {
var eventSource = new EventSource(client.apiOrigin + '/api/v1/services/' + serviceName + '/logstream?lines=' + lines + '&access_token=' + token);
callback(null, eventSource);
eventSource.onerror = callback;
eventSource.onopen = function () { callback(null, eventSource); };
} else {
get('/api/v1/services/' + serviceName + '/logs?lines=' + lines, null, function (error, data, status) {
if (error) return callback(error);
@@ -1554,7 +1591,8 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
Client.prototype.getAppLogs = function (appId, follow, lines, callback) {
if (follow) {
var eventSource = new EventSource(client.apiOrigin + '/api/v1/apps/' + appId + '/logstream?lines=' + lines + '&access_token=' + token);
callback(null, eventSource);
eventSource.onerror = callback;
eventSource.onopen = function () { callback(null, eventSource); };
} else {
get('/api/v1/apps/' + appId + '/logs', null, function (error, data, status) {
if (error) return callback(error);
@@ -2624,7 +2662,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
apps = apps.concat(applinks);
async.eachLimit(apps, 20, function (app, iteratorCallback) {
app.ssoAuth = (app.manifest.addons['ldap'] || app.manifest.addons['oidc'] || app.manifest.addons['proxyAuth']) && app.sso;
app.ssoAuth = app.sso && (app.manifest.addons['ldap'] || app.manifest.addons['oidc'] || app.manifest.addons['proxyAuth']); // checking app.sso first ensures app.manifest.addons is not null
if (app.accessLevel !== 'operator' && app.accessLevel !== 'admin') { // only fetch if we have permissions
app.progress = 0;
@@ -2675,15 +2713,21 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
Client.prototype.login = function () {
this.setToken(null);
window.location.href = '/login.html?returnTo=/' + encodeURIComponent(window.location.hash);
// start oidc flow
window.location.href = this.apiOrigin + '/openid/auth?client_id=' + ('<%= apiOrigin %>' ? 'development' : 'dashboard') + '&scope=openid email profile&response_type=code token&redirect_uri=' + window.location.origin + '/authcallback.html';
};
Client.prototype.logout = function () {
var token = this.getToken();
this.setToken(null);
var that = this;
// invalidates the token
window.location.href = client.apiOrigin + '/api/v1/cloudron/logout?access_token=' + token;
// destroy oidc session in the spirit of true SSO
del('/api/v1/oidc/sessions', null, function (error, data, status) {
if (error) console.error('Failed to logout from oidc session');
that.setToken(null);
window.location.href = '/';
});
};
Client.prototype.getAppEventLog = function (appId, page, perPage, callback) {
@@ -3737,8 +3781,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
var ACTION_DYNDNS_UPDATE = 'dyndns.update';
var ACTION_SYSTEM_CRASH = 'system.crash';
var data = eventLog.data;
var errorMessage = data.errorMessage;
var details, app;
@@ -4053,9 +4095,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
case ACTION_SUPPORT_TICKET:
return 'Support ticket was created';
case ACTION_SYSTEM_CRASH:
return 'A system process crashed';
case ACTION_VOLUME_ADD:
return 'Volume "' + data.volume.name + '" was added';
+12 -5
View File
@@ -17,6 +17,7 @@ app.controller('LogsController', ['$scope', '$translate', 'Client', function ($s
$scope.lines = 100;
$scope.selectedAppInfo = null;
$scope.selectedTaskInfo = null;
$scope.error = null;
function ab2str(buf) {
return String.fromCharCode.apply(null, new Uint16Array(buf));
@@ -54,8 +55,11 @@ app.controller('LogsController', ['$scope', '$translate', 'Client', function ($s
else if ($scope.selected.type === 'task') func = Client.getTaskLogs;
else if ($scope.selected.type === 'app') func = Client.getAppLogs;
func($scope.selected.value, true /* follow */, $scope.lines, function handleLogs(error, result) {
if (error) return console.error(error);
func($scope.selected.value, true /* follow */, $scope.lines, function (error, result) {
if (error) {
$scope.$apply(function () { $scope.error = { logsGone: true }; });
return console.error('Error subscribing to logstream.', error);
}
$scope.activeEventSource = result;
result.onmessage = function handleMessage(message) {
@@ -180,13 +184,16 @@ app.controller('LogsController', ['$scope', '$translate', 'Client', function ($s
if (error) return Client.initError(error, init);
select({ id: search.id, taskId: search.taskId, appId: search.appId, crashId: search.crashId }, function (error) {
if (error) return Client.initError(error, init);
$scope.initialized = true;
if (error) {
$scope.error = { notFound: true };
return console.error('Not found.', error);
}
// now mark the Client to be ready
Client.setReady();
$scope.initialized = true;
showLogs();
});
});
@@ -57,7 +57,7 @@ translateFilterFactory.displayName = 'translateFilterFactory';
app.filter('tr', translateFilterFactory);
app.controller('LoginController', ['$scope', '$translate', '$http', function ($scope, $translate, $http) {
app.controller('PasswordResetController', ['$scope', '$translate', '$http', function ($scope, $translate, $http) {
// Stupid angular location provider either wants html5 location mode or not, do the query parsing on my own
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.indexOf('=') === -1 ? [item, true] : [item.slice(0, item.indexOf('=')), item.slice(item.indexOf('=')+1)]; }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
@@ -74,50 +74,6 @@ app.controller('LoginController', ['$scope', '$translate', '$http', function ($s
$scope.newPasswordRepeat = '';
var API_ORIGIN = '<%= apiOrigin %>' || window.location.origin;
$scope.onLogin = function () {
$scope.busy = true;
$scope.error = false;
var data = {
username: $scope.username,
password: $scope.password,
totpToken: $scope.totpToken
};
function error(data, status) {
$scope.busy = false;
$scope.error = {};
if (!data || status !== 401) return $scope.error.internal = true;
if (data.message === 'Username and password does not match') {
$scope.error.password = true;
$scope.password = '';
setTimeout(function () { $('#inputPassword').focus(); }, 200);
} else if (data.message.indexOf('totpToken') !== -1) {
$scope.error.totpToken = true;
$scope.totpToken = '';
setTimeout(function () { $('#inputTotpToken').focus(); }, 200);
} else {
$scope.error.generic = true;
}
$scope.loginForm.$setPristine();
}
$http.post(API_ORIGIN + '/api/v1/cloudron/login', data).success(function (data, status) {
if (status !== 200) return error(data, status);
localStorage.token = data.accessToken;
// prevent redirecting to random domains
var returnTo = search.returnTo || '/';
if (returnTo.indexOf('/') !== 0) returnTo = '/';
window.location.href = returnTo;
}).error(error);
};
$scope.onPasswordReset = function () {
$scope.busy = true;
@@ -170,13 +126,6 @@ app.controller('LoginController', ['$scope', '$translate', '$http', function ($s
setTimeout(function () { $('#inputPasswordResetIdentifier').focus(); }, 200);
};
$scope.showLogin = function () {
if ($scope.status) window.document.title = $scope.status.cloudronName + ' Login';
$scope.mode = 'login';
$scope.error = false;
setTimeout(function () { $('#inputUsername').focus(); }, 200);
};
$scope.showNewPassword = function () {
window.document.title = 'Set New Password';
$scope.mode = 'newPassword';
@@ -190,7 +139,6 @@ app.controller('LoginController', ['$scope', '$translate', '$http', function ($s
if (data.language) $translate.use(data.language);
if ($scope.mode === 'login') window.document.title = data.cloudronName + ' Login';
$scope.status = data;
}).error(function () {
$scope.initialized = false;
@@ -205,6 +153,6 @@ app.controller('LoginController', ['$scope', '$translate', '$http', function ($s
localStorage.token = search.accessToken || search.access_token;
window.location.href = '/';
} else {
$scope.showLogin();
$scope.showPasswordReset();
}
}]);
+8 -1
View File
@@ -280,7 +280,7 @@ angular.module('Application').controller('TerminalController', ['$scope', '$tran
$scope.terminal.focus();
};
$scope.terminalCopy = function () {
$scope.terminalCopyToClipboard = function () {
if (!$scope.terminal) return;
// execCommand('copy') would copy any selection from the page, so do this only if terminal has a selection
@@ -363,6 +363,13 @@ angular.module('Application').controller('TerminalController', ['$scope', '$tran
});
});
window.addEventListener('keydown', function (event) {
if (event.key === 'C' && event.ctrlKey) { // ctrl shift c
event.preventDefault();
$scope.terminalCopyToClipboard();
}
});
$translate([ 'terminal.title' ]).then(function (tr) {
if (tr['terminal.title'] !== 'terminal.title') window.document.title = tr['terminal.title'];
});
+4 -2
View File
@@ -65,13 +65,15 @@
<a class="offline-banner animateMe" ng-show="client.offline" ng-cloak href="https://docs.cloudron.io/troubleshooting/" target="_blank"><i class="fa fa-circle-notch fa-spin"></i> {{ 'main.offline' | tr }}</a>
<div class="animateMe ng-hide layout-root" ng-show="initialized">
<div class="logs-controls">
<div ng-show="error.notFound" class="logs-error">{{ 'logs.notFoundError' | tr }}</div>
<div ng-show="error.logsGone" class="logs-error">{{ 'logs.logsGoneError' | tr }}</div>
<div ng-hide="error" class="logs-controls">
<h3 style="display: inline-block;">{{ selected.name }}</h3>
<!-- logs actions -->
<div class="pull-right">
<a class="btn btn-primary" ng-href="{{ '/terminal.html?id=' + selected.value }}" target="_blank" ng-show="selected.type === 'app'"><i class="fa fa-terminal"></i> {{ 'terminal.title' | tr }}</a>
<a class="btn btn-primary" ng-href="{{ '/filemanager.html?type=app&id=' + selected.value }}" target="_blank" ng-show="selected.type === 'app'"><i class="fas fa-folder"></i> {{ 'filemanager.title' | tr }}</a>
<a class="btn btn-primary" ng-href="{{ '/filemanager/#/home/app/' + selected.value }}" target="_blank" ng-show="selected.type === 'app'"><i class="fas fa-folder"></i> {{ 'filemanager.title' | tr }}</a>
<a class="btn btn-primary" ng-click="clear()"><i class="fa fa-trash"></i> {{ 'logs.clear' | tr }}</a>
<a class="btn btn-primary" ng-href="{{ selected.url }}&format=short&lines=-1"><i class="fa fa-download"></i> {{ 'logs.download' | tr }}</a>
</div>
@@ -6,8 +6,8 @@
<meta http-equiv="Content-Security-Policy" content="default-src <%= apiOrigin %> 'unsafe-inline' 'unsafe-eval' 'self'; img-src <%= apiOrigin %> 'self'" />
<!-- this gets changed once we get the status (because angular has not loaded yet, we see template string for a flash) -->
<title>Cloudron Login</title>
<meta name="description" content="Cloudron Login">
<title>Cloudron Password Reset</title>
<meta name="description" content="Cloudron Password Reset">
<link id="favicon" href="<%= apiOrigin %>/api/v1/cloudron/avatar" rel="icon" type="image/png">
@@ -47,54 +47,14 @@
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-local.min.js?<%= revision %>"></script>
<!-- Setup Application -->
<script type="text/javascript" src="/js/login.js?<%= revision %>"></script>
<script type="text/javascript" src="/js/passwordreset.js?<%= revision %>"></script>
</head>
<body ng-app="Application" ng-controller="LoginController">
<body ng-app="Application" ng-controller="PasswordResetController">
<div class="layout-root ng-cloak" ng-show="initialized">
<div class="layout-content" ng-show="mode === 'login'">
<div class="card" style="padding: 20px; margin-top: 100px; max-width: 620px;">
<div class="row">
<div class="col-md-12" style="text-align: center;">
<img width="128" height="128" style="margin-top: -84px" src="<%= apiOrigin %>/api/v1/cloudron/avatar"/>
<br/>
<h1><small>{{ 'login.loginTo' | tr }}</small> {{ status.cloudronName || 'Cloudron' }}</h1>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12">
<h4 class="has-error" ng-show="error && (error.generic || error.password)">{{ 'login.errorIncorrectCredentials' | tr }}</h4>
<h4 class="has-error" ng-show="error && error.totpToken">{{ 'login.errorIncorrect2FAToken' | tr }}</h4>
<h4 class="has-error" ng-show="error && error.internal">{{ 'login.errorInternal' | tr }}</h4>
</div>
</div>
<div class="row">
<div class="col-md-12">
<form name="loginForm" ng-submit="onLogin()">
<div class="form-group">
<label class="control-label" for="inputUsername">{{ 'login.username' | tr }}</label>
<input type="text" class="form-control" id="inputUsername" name="username" ng-model="username" ng-disabled="busy" autofocus required>
</div>
<div class="form-group" ng-class="{'has-error': error.password }">
<label class="control-label" for="inputPassword">{{ 'login.password' | tr }}</label>
<input type="password" class="form-control" name="password" id="inputPassword" ng-model="password" ng-disabled="busy" required password-reveal>
</div>
<div class="form-group" ng-class="{'has-error': error.totpToken }">
<label class="control-label" for="inputTotpToken">{{ 'login.2faToken' | tr }}</label>
<input type="text" class="form-control" name="totpToken" id="inputTotpToken" ng-model="totpToken" ng-disabled="busy" value="">
</div>
<button class="btn btn-primary btn-outline pull-right" type="submit" ng-disabled="busy || loginForm.$invalid"><i class="fa fa-circle-notch fa-spin" ng-show="busy"></i> {{ 'login.signInAction' | tr }}</button>
</form>
<a ng-href="" class="hand" ng-click="showPasswordReset()">{{ 'login.resetPasswordAction' | tr }}</a>
</div>
</div>
</div>
</div>
<div class="layout-content" ng-show="mode === 'passwordReset'">
<div class="card" style="padding: 20px; margin-top: 100px; max-width: 620px;">
<div class="row">
@@ -113,9 +73,11 @@
<input type="text" class="form-control" id="inputPasswordResetIdentifier" name="passwordResetIdentifier" ng-model="passwordResetIdentifier" ng-disabled="busy" autofocus required>
</div>
<br/>
<button class="btn btn-primary btn-outline pull-right" type="submit" ng-disabled="busy || passwordResetForm.$invalid"><i class="fa fa-circle-notch fa-spin" ng-show="busy"></i> {{ 'passwordReset.resetAction' | tr }}</button>
<div class="card-form-bottom-bar">
<a href="/" class="hand">{{ 'passwordReset.backToLoginAction' | tr }}</a>
<button class="btn btn-primary btn-outline" type="submit" ng-disabled="busy || passwordResetForm.$invalid"><i class="fa fa-circle-notch fa-spin" ng-show="busy"></i> {{ 'passwordReset.resetAction' | tr }}</button>
</div>
</form>
<a ng-href="" class="hand" ng-click="showLogin()">{{ 'passwordReset.backToLoginAction' | tr }}</a>
</div>
</div>
</div>
@@ -129,7 +91,7 @@
<br/>
<h2>{{ 'passwordReset.emailSent.title' | tr }}</h2>
<br/>
<button class="btn btn-primary" ng-click="showLogin()">{{ 'passwordReset.backToLoginAction' | tr }}</button>
<a href="/" class="btn btn-primary">{{ 'passwordReset.backToLoginAction' | tr }}</a>
</div>
</div>
</div>
@@ -173,10 +135,11 @@
<label class="control-label" for="inputPasswordResetTotpToken">{{ 'login.2faToken' | tr }}</label>
<input type="text" class="form-control" name="passwordResetTotpToken" id="inputPasswordResetTotpToken" ng-model="totpToken" ng-disabled="busy" value="">
</div>
<br/>
<button class="btn btn-primary btn-outline pull-right" type="submit" ng-disabled="busy || newPasswordForm.$invalid || newPassword !== newPasswordRepeat"><i class="fa fa-circle-notch fa-spin" ng-show="busy"></i> {{ 'passwordReset.passwordChanged.submitAction' | tr }}</button>
<div class="card-form-bottom-bar">
<a href="/" class="hand">{{ 'passwordReset.backToLoginAction' | tr }}</a>
<button class="btn btn-primary btn-outline" type="submit" ng-disabled="busy || newPasswordForm.$invalid || newPassword !== newPasswordRepeat"><i class="fa fa-circle-notch fa-spin" ng-show="busy"></i> {{ 'passwordReset.passwordChanged.submitAction' | tr }}</button>
</div>
</form>
<a ng-href="" class="hand" ng-click="showLogin()">{{ 'passwordReset.backToLoginAction' | tr }}</a>
</div>
</div>
</div>
+1 -1
View File
@@ -159,7 +159,7 @@
<div class="contextMenuBackdrop">
<ul class="dropdown-menu" id="terminalContextMenu" style="position: absolute; display:none;">
<li><a href="" ng-click="terminalCopy()">{{ 'terminal.contextmenu.copy' | tr }}</a></li>
<li><a href="" ng-click="terminalCopyToClipboard()">{{ 'terminal.contextmenu.copy' | tr }}</a></li>
<li class="disabled"><a>{{ 'terminal.contextmenu.pasteInfo' | tr }}</a></li>
<li role="separator" class="divider"></li>
<li><a href="" ng-click="terminalClear()">{{ 'terminal.contextmenu.clear' | tr }}</a></li>
+25
View File
@@ -320,6 +320,10 @@ h1, h2, h3 {
z-index: 200;
}
.section-header {
margin-top: 50px;
}
.offscreen {
position: absolute;
left: -999em;
@@ -824,6 +828,19 @@ multiselect {
cursor: not-allowed;
}
// ----------------------------
// Login and password forms
// ----------------------------
.card-form-bottom-bar {
display: flex;
justify-content: space-between;
}
.card-form-bottom-bar > * {
align-self: center;
}
// ----------------------------
// Appstore view
// ----------------------------
@@ -1757,6 +1774,14 @@ tag-input {
.logs {
background: black;
.logs-error {
color: white;
width: 100%;
font-size: 18px;
text-align: center;
margin-top: 200px;
}
.logs-controls {
margin: 5px;
+50 -6
View File
@@ -51,7 +51,8 @@
"save": "Gem",
"close": "Luk",
"no": "Nej",
"yes": "Ja"
"yes": "Ja",
"delete": "Slet"
},
"username": "Brugernavn",
"displayName": "Vis navn",
@@ -87,7 +88,8 @@
"statusEnabled": "Aktiveret",
"statusDisabled": "Slået fra",
"loadingPlaceholder": "Indlæsning",
"disableAction": "Deaktiver"
"disableAction": "Deaktiver",
"settings": "Indstillinger"
},
"appstore": {
"category": {
@@ -1013,7 +1015,8 @@
"hetznerToken": "Hetzner Token",
"porkbunSecretapikey": "Hemmelig API-nøgle",
"cloudflareDefaultProxyStatus": "Aktiver proxying for nye DNS-poster",
"porkbunApikey": "API-nøgle"
"porkbunApikey": "API-nøgle",
"bunnyAccessKey": "Bunny Access Key"
},
"title": "Domæner og certs",
"addDomain": "Tilføj domæne",
@@ -1042,7 +1045,8 @@
"domainWellKnown": {
"title": "Well-Known locations på {{ domain }}"
},
"tooltipWellKnown": "Indstil well-known lokationer"
"tooltipWellKnown": "Indstil well-known lokationer",
"count": "Samlede domæner: {{ count }}"
},
"notifications": {
"markAllAsRead": "Markér alle som læst",
@@ -1817,7 +1821,9 @@
"password": "Adgangskode",
"2faToken": "2FA-token (hvis aktiveret)",
"signInAction": "Log ind",
"resetPasswordAction": "Nulstil adgangskode"
"resetPasswordAction": "Nulstil adgangskode",
"errorIncorrect2FAToken": "2FA-token er ugyldig",
"errorInternal": "Intern fejl, prøv igen senere"
},
"lang": {
"en": "English",
@@ -1831,9 +1837,47 @@
"zh_Hans": "Kinesisk (forenklet)",
"es": "Spansk",
"ru": "Russisk",
"pt": "Portugisisk"
"pt": "Portugisisk",
"da": "Dansk"
},
"supportConfig": {
"emailNotVerified": "Du bedes først bekræfte e-mailen på cloudron.io-kontoen for at sikre, at vi kan kontakte dig."
},
"oidc": {
"newClientDialog": {
"title": "Tilføj klient",
"description": "Tilføj nye OpenID connect-klientindstillinger.",
"createAction": "Opret"
},
"client": {
"name": "Navn",
"id": "Klient-id",
"secret": "Klientens secret",
"signingAlgorithm": "Signeringsalgoritme",
"loginRedirectUri": "Url til tilbagekaldelse af login (kommasepareret, hvis der er mere end én)",
"logoutRedirectUri": "Url til tilbagekaldelse af logout (valgfrit)"
},
"title": "OpenID Connect-udbyder",
"description": "Cloudron kan fungere som OpenID Connect-udbyder for interne apps og eksterne tjenester.",
"editClientDialog": {
"title": "Rediger klient {{ client }}"
},
"deleteClientDialog": {
"title": "Virkelig slette klient {{ client }}?",
"description": "Dette vil afbryde forbindelsen til alle eksterne OpenID-apps fra denne Cloudron, der bruger dette klient-id."
},
"env": {
"discoveryUrl": "URL til opdagelse",
"logoutUrl": "URL til logout",
"profileEndpoint": "Profil slutpunkt",
"keysEndpoint": "Nøgler Slutpunkt",
"tokenEndpoint": "Token slutpunkt",
"authEndpoint": "Auth-slutpunkt"
},
"clients": {
"title": "Klienter",
"newClient": "Ny klient",
"empty": "Ingen klienten endnu"
}
}
}
+2 -1
View File
@@ -1366,7 +1366,8 @@
"paste": "Einfügen",
"copy": "Kopieren",
"cut": "Ausschneiden",
"edit": "Bearbeiten"
"edit": "Bearbeiten",
"open": "Öffnen"
},
"symlink": "Symlink zu {{ target }}",
"mtime": "Geändert"
+25 -5
View File
@@ -806,7 +806,13 @@
},
"configureIpv6": {
"title": "Configure IPv6 Provider"
}
},
"trustedIps": {
"description": "HTTP headers from matching IP addresses will be trusted",
"title": "Configure Trusted IPs",
"summary": "{{ trustCount }} IPs trusted"
},
"trustedIpRanges": "Trusted IPs & Ranges "
},
"services": {
"title": "Services",
@@ -1066,7 +1072,9 @@
"logs": {
"title": "Logs",
"clear": "Clear View",
"download": "Download Full Logs"
"download": "Download Full Logs",
"notFoundError": "No such task or app",
"logsGoneError": "Log file(s) not found"
},
"terminal": {
"title": "Terminal",
@@ -1164,7 +1172,8 @@
"cut": "Cut",
"copy": "Copy",
"paste": "Paste",
"selectAll": "Select All"
"selectAll": "Select All",
"open": "Open"
},
"mtime": "Modified"
},
@@ -1179,7 +1188,17 @@
},
"status": {
"restartingApp": "restarting app"
}
},
"uploader": {
"uploading": "Uploading",
"exitWarning": "Upload still in progress. Really close this page?"
},
"textEditor": {
"undo": "Undo",
"redo": "Redo",
"save": "Save"
},
"extractionInProgress": "Extraction in progress"
},
"email": {
"backAction": "Back to Email",
@@ -1879,5 +1898,6 @@
"newClient": "New client",
"empty": "No clients yet"
}
}
},
"automation": "Automation"
}
+63 -11
View File
@@ -102,7 +102,8 @@
"pagination": {
"perPageSelector": "Mostrar {{ n }} por página",
"next": "siguiente",
"prev": "anterior"
"prev": "anterior",
"itemCount": "Encontrado {{ count }}"
},
"table": {
"date": "Fecha"
@@ -115,7 +116,8 @@
"no": "No",
"close": "Cerrar",
"save": "Guardar",
"cancel": "Cancelar"
"cancel": "Cancelar",
"delete": "Borrar"
},
"logout": "Salir",
"offline": "Cloudron está desconectado. Reconectando…",
@@ -137,7 +139,9 @@
},
"enableAction": "Habilitar",
"statusEnabled": "Habilitado",
"statusDisabled": "Deshabilitado"
"statusDisabled": "Deshabilitado",
"loadingPlaceholder": "Cargando",
"settings": "Ajustes"
},
"apps": {
"domainsFilterHeader": "Todos los Dominios",
@@ -950,7 +954,11 @@
"vultrToken": "Token Vultr",
"jitsiHostname": "Ubicación de Jitsi",
"wellKnownDescription": "Cloudron utilizará los valores para responder a las URLs <code>/.well-known/</code> . Ten en cuenta que la aplicación debe estar disponible en el dominio desnudo <code>{{ domain }}</code> para que esto funcione. Consulta <a href=\"{{docsLink}}\" target=\"_blank\">esta documentación</a> para más información.",
"hetznerToken": "Token de Hetzner"
"hetznerToken": "Token de Hetzner",
"bunnyAccessKey": "Clave de acceso Bunny",
"cloudflareDefaultProxyStatus": "Habilitar proxy para nuevos registros DNS",
"porkbunApikey": "Clave API",
"porkbunSecretapikey": "Clave API secreta"
},
"subscriptionRequired": {
"setupAction": "Configura tu suscripción",
@@ -982,7 +990,8 @@
"domainWellKnown": {
"title": "Ubicaciones Well-known de {{ domain }}"
},
"tooltipWellKnown": "Establece las ubicaciones Well-Known"
"tooltipWellKnown": "Establece las ubicaciones Well-Known",
"count": "Dominios totales: {{ count }}"
},
"app": {
"appInfo": {
@@ -1098,7 +1107,8 @@
"saveAction": "Guardar",
"description": "La configuración de esta opción anulará cualquier encabezado CSP enviado por la propia aplicación",
"title": "Política de seguridad de contenido"
}
},
"hstsPreload": "Habilitar la carga previa de HSTS para este sitio y todos los subdominios"
},
"email": {
"from": {
@@ -1207,7 +1217,8 @@
"description": "Todos los datos generados entre ahora y la última copia de seguridad conocida se perderán de forma irrevocable. Se recomienda crear una copia de seguridad de los datos actuales antes de intentar una importación.",
"title": "Importar Backup",
"uploadAction": "Subir Configuración de Backup",
"importAction": "Importar"
"importAction": "Importar",
"remotePath": "Ruta del Backup"
},
"restoreDialog": {
"warning": "Todos los datos generados entre ahora y la última copia de seguridad conocida se perderán de forma irrevocable. Se recomienda crear una copia de seguridad de los datos actuales antes de intentar una restauración.",
@@ -1324,7 +1335,8 @@
"en": "Inglés",
"es": "Español",
"ru": "Ruso",
"pt": "Portugués"
"pt": "Portugués",
"da": "Danés"
},
"system": {
"title": "Información del Sistema",
@@ -1345,7 +1357,8 @@
"title": "Uso del Disco",
"usedInfo": "{{ used }} usados de {{ size }}",
"uninstalledApp": "Aplicación desinstalada",
"volumeContent": "Este disco es el volumen <code>{{ name }}</code>"
"volumeContent": "Este disco es el volumen <code>{{ name }}</code>",
"diskSpeed": "Velocidad: {{ speed }} MB/seg"
},
"selectPeriodLabel": "Seleccionar Periodo"
},
@@ -1403,7 +1416,7 @@
"removeVolumeActionTooltip": "Borrar Volumen",
"openFileManagerActionTooltip": "Abrir Gestor de Archivos",
"name": "Nombre",
"hostPath": "Punto de montaje",
"hostPath": "Objetivo",
"addVolumeAction": "Añade un Volumen",
"title": "Volúmenes",
"description": "Los volúmenes son sistemas de archivos locales o remotos. Se pueden usar como el almacenamiento de datos principal de una aplicación o como una ubicación de almacenamiento compartida entre aplicaciones.",
@@ -1811,7 +1824,9 @@
"password": "Contraseña",
"2faToken": "Token 2FA (si está habilitado)",
"signInAction": "Iniciar sesión",
"resetPasswordAction": "Resetear contraseña"
"resetPasswordAction": "Resetear contraseña",
"errorIncorrect2FAToken": "El token 2FA es inválido",
"errorInternal": "Error interno, prueba de nuevo más tarde"
},
"newLoginEmail": {
"subject": "[<% = cloudron%>] Nuevo inicio de sesión en tu cuenta",
@@ -1827,5 +1842,42 @@
"mounts": {
"description": "Las aplicaciones pueden acceder a <a href=\"/#/volumes\">volúmenes</a> montados a través del directorio <code>/media/{volume name}</code>. Estos datos no están incluidos en la copia de seguridad de la aplicación."
}
},
"oidc": {
"newClientDialog": {
"title": "Añadir Cliente",
"description": "Agrega una nueva configuración de cliente de conexión de OpenID.",
"createAction": "Crear"
},
"client": {
"name": "Nombre",
"id": "ID de cliente",
"secret": "Secreto de cliente",
"signingAlgorithm": "Algoritmo de firma",
"loginRedirectUri": "URL de devolución de llamada de inicio de sesión (separadas por comas si hay más de una)",
"logoutRedirectUri": "URL de devolución de llamada de cierre de sesión (opcional)"
},
"title": "Proveedor de conexión OpenID",
"description": "Cloudron puede actuar como proveedor de OpenID Connect para aplicaciones internas y servicios externos.",
"editClientDialog": {
"title": "Editar cliente {{ client }}"
},
"deleteClientDialog": {
"title": "¿Realmente quieres borrar el cliente {{ client }}?",
"description": "Esto desconectará todas las aplicaciones OpenID externas de este Cloudron que utilicen este ID de cliente."
},
"env": {
"discoveryUrl": "URL de descubrimiento",
"logoutUrl": "URL de cierre de sesión",
"profileEndpoint": "Punto final del perfil",
"keysEndpoint": "Punto final de claves",
"tokenEndpoint": "Punto final del Token",
"authEndpoint": "Punto final de autenticación"
},
"clients": {
"title": "Clientes",
"newClient": "Nuevo cliente",
"empty": "No hay clientes aún"
}
}
}
+7 -3
View File
@@ -50,7 +50,8 @@
"pagination": {
"prev": "préc.",
"next": "suiv.",
"perPageSelector": "Afficher {{ n }} par page"
"perPageSelector": "Afficher {{ n }} par page",
"itemCount": "Trouvé {{ count }}"
},
"action": {
"logs": "Journaux",
@@ -85,7 +86,8 @@
"users": "Utilisateurs"
},
"disableAction": "Désactiver",
"enableAction": "Activer"
"enableAction": "Activer",
"loadingPlaceholder": "Chargement"
},
"users": {
"title": "Annuaire des utilisateurs",
@@ -1739,7 +1741,9 @@
"usageInfo": "{{ available | prettyDiskSize }}</b> sur <b>{{ size | prettyDiskSize }}</b> disponible(s)",
"mountedAt": "{{ filesystem }} <small>monté sur</small> {{ mountpoint }}",
"title": "Utilisation du disque",
"usedInfo": "{{ used }} utilisé de {{ size }}"
"usedInfo": "{{ used }} utilisé de {{ size }}",
"uninstalledApp": "Désinstaller App",
"diskSpeed": "Vitesse : {{ speed }} MB/sec"
},
"title": "Info système"
},
+61 -10
View File
@@ -38,7 +38,8 @@
"save": "Opslaan",
"close": "Sluiten",
"no": "Nee",
"yes": "Ja"
"yes": "Ja",
"delete": "Verwijder"
},
"username": "Gebruikersnaam",
"displayName": "Naam",
@@ -87,7 +88,8 @@
"enableAction": "Inschakelen",
"statusEnabled": "Ingeschakeld",
"statusDisabled": "Uitgeschakeld",
"loadingPlaceholder": "Laden"
"loadingPlaceholder": "Laden",
"settings": "Instellingen"
},
"appstore": {
"title": "App Store",
@@ -852,7 +854,8 @@
"domainWellKnown": {
"title": "Well-Known locaties van {{ domain }}"
},
"tooltipWellKnown": "Well-Known Locaties instellen"
"tooltipWellKnown": "Well-Known Locaties instellen",
"count": "Totaal domeinen: {{ count }}"
},
"app": {
"email": {
@@ -1224,7 +1227,13 @@
},
"configureIpv6": {
"title": "Configureer IPv6 aanbieder"
}
},
"trustedIps": {
"description": "HTTP headers van bijbehorende IP adressen worden vertrouwd",
"summary": "{{ trustCount }} IPs vertrouwd",
"title": "Configureer vertrouwde IPs"
},
"trustedIpRanges": "Vertrouwde IPs & bereiken "
},
"services": {
"title": "Diensten",
@@ -1393,7 +1402,9 @@
"logs": {
"title": "Logbestanden",
"clear": "Leegmaken",
"download": "Download volledige logbestanden"
"download": "Download volledige logbestanden",
"notFoundError": "Geen taak of app gevonden",
"logsGoneError": "Log bestand(en) niet gevonden"
},
"terminal": {
"title": "Terminal",
@@ -1512,7 +1523,7 @@
"backAction": "Terug naar e-mail",
"config": {
"title": "E-mailconfiguratie {{ domain }}",
"clientConfiguration": "Configureren E-mail clients"
"clientConfiguration": "Configureren E-mail programma's"
},
"incoming": {
"disableAction": "Uitschakelen",
@@ -1558,7 +1569,7 @@
"incomingPasswordUsage": "Wachtwoord van de eigenaar van de mailbox",
"enabled": "Cloudron e-mailserver is geconfigureerd voor inkomende e-mails voor dit domein.",
"disabled": "Cloudron e-mailserver ontvangt geen inkomende e-mails voor dit domein.",
"howToConnectDescription": "Gebruik onderstaande gegevens om e-mail clients in te stellen."
"howToConnectDescription": "Gebruik onderstaande gegevens om e-mail programma's in te stellen."
},
"outbound": {
"tabTitle": "Uitgaand",
@@ -1685,7 +1696,7 @@
"updateMailinglistDialog": {
"activeCheckbox": "Mailing-lijst is actief"
},
"howToConnectInfoModal": "Configureren e-mail clients",
"howToConnectInfoModal": "Configureren e-mail programma's",
"mailboxImportDialog": {
"title": "Importeer Mailboxen",
"description": "Upload een JSON of CSV bestand met een schema zoals beschreven in onze <a href=\"{{ docsLink }}\" target=\"_blank\">documentatie</a>.",
@@ -1703,7 +1714,9 @@
"password": "Wachtwoord",
"resetPasswordAction": "Herstel wachtwoord",
"2faToken": "2FA Token (indien ingeschakeld)",
"errorIncorrectCredentials": "Onjuiste gebruikersnaam of wachtwoord"
"errorIncorrectCredentials": "Onjuiste gebruikersnaam of wachtwoord",
"errorIncorrect2FAToken": "2FA token is niet geldig",
"errorInternal": "Interne fout, probeer later opnieuw"
},
"passwordReset": {
"title": "Wachtwoord herstellen",
@@ -1837,5 +1850,43 @@
"mounts": {
"description": "Apps kunnen toegang krijgen tot <a href=\"/#/volumes\">volumes</a> via <code>/media/{volume name}</code> directory. Deze data is niet opgenomen in de app backup."
}
}
},
"oidc": {
"newClientDialog": {
"title": "Client toevoegen",
"description": "Nieuwe OpenID Connect client instellingen toevoegen.",
"createAction": "Aanmaken"
},
"client": {
"name": "Naam",
"id": "Client ID",
"secret": "Client geheim",
"signingAlgorithm": "Ondertekeningsalgoritme",
"loginRedirectUri": "Login callback URL (met komma gescheiden indien meer dan één)",
"logoutRedirectUri": "Logout callback URL (optioneel)"
},
"title": "OpenID Connect aanbieder",
"description": "Cloudron kan als een OpenID Connect aanbieder voor interne apps en externe diensten fungeren.",
"editClientDialog": {
"title": "Bewerk Client {{ client }}"
},
"deleteClientDialog": {
"title": "Weet je zeker dat je Client {{ client }} wilt verwijderen?",
"description": "Hiermee worden alle externe OpenID apps met dit Client ID losgekoppeld."
},
"env": {
"discoveryUrl": "Discovery URL",
"logoutUrl": "Logout URL",
"profileEndpoint": "Profiel Eindpunt",
"keysEndpoint": "Sleutels Eindpunt",
"tokenEndpoint": "Token Eindpunt",
"authEndpoint": "Auth Eindpunt"
},
"clients": {
"title": "Clients",
"newClient": "Nieuwe Client",
"empty": "Nog geen Clients"
}
},
"automation": "Automatisering"
}
+62 -9
View File
@@ -56,7 +56,8 @@
"save": "Сохранить",
"close": "Закрыть",
"no": "Нет",
"yes": "Да"
"yes": "Да",
"delete": "Удалить"
},
"username": "Имя пользователя",
"displayName": "Отображаемое имя",
@@ -87,7 +88,8 @@
"enableAction": "Включить",
"statusEnabled": "Включено",
"statusDisabled": "Выключено",
"loadingPlaceholder": "Загрузка"
"loadingPlaceholder": "Загрузка",
"settings": "Настройки"
},
"appstore": {
"category": {
@@ -1134,7 +1136,13 @@
},
"configureIpv6": {
"title": "Настройка IPv6"
}
},
"trustedIps": {
"summary": "{{ trustCount }} IP доверены",
"title": "Настроить доверенные IP",
"description": "HTTP заголовки от совпадающих IP адресов будут доверены"
},
"trustedIpRanges": "Доверенные IP и диапазоны "
},
"services": {
"title": "Службы",
@@ -1363,7 +1371,8 @@
"hetznerToken": "Токен Hetzner",
"cloudflareDefaultProxyStatus": "Активировать прокси для новых DNS записей",
"porkbunApikey": "API Ключ",
"porkbunSecretapikey": "Secret API Ключ"
"porkbunSecretapikey": "Secret API Ключ",
"bunnyAccessKey": "Ключ доступа Bunny"
},
"addDomain": "Добавить домен",
"removeDialog": {
@@ -1380,7 +1389,8 @@
"domainWellKnown": {
"title": "Общеизвестные расположения {{ domain }}"
},
"tooltipWellKnown": "Установить общеизвестные расположения"
"tooltipWellKnown": "Установить общеизвестные расположения",
"count": "Всего доменов: {{ count }}"
},
"notifications": {
"title": "Уведомления",
@@ -1392,7 +1402,9 @@
"logs": {
"title": "Логи",
"clear": "Очистить обзор",
"download": "Скачать полные логи"
"download": "Скачать полные логи",
"notFoundError": "Задача или приложение не существует",
"logsGoneError": "Файл(ы) журнала не найден(ы)"
},
"terminal": {
"title": "Терминал",
@@ -1702,7 +1714,9 @@
"loginTo": "Войти в",
"username": "Имя пользователя",
"2faToken": "2FA Токен (если включен)",
"errorIncorrectCredentials": "Неправильное имя пользователя или пароль"
"errorIncorrectCredentials": "Неправильное имя пользователя или пароль",
"errorIncorrect2FAToken": "Неверный 2FA токен",
"errorInternal": "Внутренняя ошибка, попробуйте позже"
},
"passwordReset": {
"title": "Сброс пароля",
@@ -1776,7 +1790,8 @@
"zh_Hans": "Китайский (Упрощенный)",
"es": "Испанский",
"ru": "Русский",
"pt": "Португальский"
"pt": "Португальский",
"da": "Датский"
},
"setupAccount": {
"username": "Имя пользователя",
@@ -1835,5 +1850,43 @@
"mounts": {
"description": "Приложения могут получить доступ к смонтированным <a href=\"/#/volumes\">томам</a> по пути <code>/media/{имя тома}</code>. Данные таких томов не будут включаться в резервные копии приложения."
}
}
},
"oidc": {
"newClientDialog": {
"createAction": "Создать",
"title": "Добавить клиента",
"description": "Добавить настройки нового клиента OpenID connect."
},
"client": {
"name": "Имя",
"id": "ID Клиента",
"secret": "Секрет",
"signingAlgorithm": "Метод подписи",
"loginRedirectUri": "URL обратного вызова (если больше одного, отделите их запятой)",
"logoutRedirectUri": "URL обратного вызова для выхода из системы (необязательно)"
},
"clients": {
"title": "Клиенты",
"newClient": "Новый клиент",
"empty": "Клиенты не найдены"
},
"title": "Поставщик OpenID Сonnect",
"description": "Cloudron может выступать в качестве поставщика OpenID connect для внутренних приложений и внешних сервисов.",
"editClientDialog": {
"title": "Редактировать клиента {{ client }}"
},
"deleteClientDialog": {
"title": "Вы точно хотите удалить клиента {{ client }}?",
"description": "Это действие отключит все внешние OpenID приложения, использующие данный клиент ID, от Cloudron."
},
"env": {
"discoveryUrl": "URL обнаружения",
"logoutUrl": "URL выхода из системы",
"profileEndpoint": "Конечная точка профиля",
"keysEndpoint": "Конечная точка ключей",
"tokenEndpoint": "Конечная точка токена",
"authEndpoint": "Конечная точка аутентификации"
}
},
"automation": "Автоматизация"
}
+1 -1
View File
@@ -583,7 +583,7 @@
<div class="btn-group btn-group-sm" role="group">
<a class="btn btn-sm btn-default" ng-href="{{ '/logs.html?appId=' + app.id }}" target="_blank" uib-tooltip="{{ 'app.logsActionTooltip' | tr }}" tooltip-append-to-body="true" tooltip-placement="bottom"><i class="fas fa-align-left"></i></a>
<a class="btn btn-sm btn-default" ng-if="app.type !== APP_TYPES.PROXIED" ng-href="{{ '/terminal.html?id=' + app.id }}" target="_blank" uib-tooltip="{{ 'app.terminalActionTooltip' | tr }}" tooltip-append-to-body="true" tooltip-placement="bottom"><i class="fa fa-terminal"></i></a>
<a class="btn btn-sm btn-default" ng-if="app.manifest.addons.localstorage" ng-href="{{ '/filemanager.html?type=app&id=' + app.id }}" target="_blank" uib-tooltip="{{ 'app.filemanagerActionTooltip' | tr }}" tooltip-append-to-body="true" tooltip-placement="bottom"><i class="fas fa-folder"></i></a>
<a class="btn btn-sm btn-default" ng-if="app.manifest.addons.localstorage" ng-href="{{ '/filemanager/#/home/app/' + app.id }}" target="_blank" uib-tooltip="{{ 'app.filemanagerActionTooltip' | tr }}" tooltip-append-to-body="true" tooltip-placement="bottom"><i class="fas fa-folder"></i></a>
</div>
<div class="dropdown" style="display: inline-block">
<button class="btn btn-sm btn-default dropdown-toggle" type="button" data-toggle="dropdown" uib-tooltip="{{ 'app.docsActionTooltip' | tr }}" tooltip-append-to-body="true" tooltip-placement="bottom">
+1
View File
@@ -45,6 +45,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
// If new categories added make sure the translation below exists
$scope.categories = [
{ id: 'analytics', icon: 'fa fa-chart-line', label: 'Analytics'},
{ id: 'automation', icon: 'fa fa-robot', label: 'Automation'},
{ id: 'blog', icon: 'fa fa-font', label: 'Blog'},
{ id: 'chat', icon: 'fa fa-comments', label: 'Chat'},
{ id: 'crm', icon: 'fab fa-connectdevelop', label: 'CRM'},
+36 -20
View File
@@ -101,7 +101,7 @@
<div class="modal-body">{{ 'backups.cleanupBackups.description' | tr }}</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="submit" class="btn btn-outline btn-success pull-right" ng-click="createBackup.startCleanup()">{{ 'backups.cleanupBackups.cleanupNow' | tr }}</button>
<button type="submit" class="btn btn-outline btn-success pull-right" ng-click="cleanupBackups.start()">{{ 'backups.cleanupBackups.cleanupNow' | tr }}</button>
</div>
</div>
</div>
@@ -468,7 +468,7 @@
<div class="col-xs-6 text-right no-wrap">
<span ng-show="backupConfig.provider === 'filesystem'">{{ backupConfig.backupFolder }}</span>
<span ng-show="mountlike(backupConfig.provider)">
<i class="fa fa-circle" ng-style="{ color: backupConfig.mountStatus.state === 'active' ? '#27CE65' : '#d9534f' }" ng-show="backupConfig.mountStatus" uib-tooltip="{{ backupConfig.mountStatus.message }}"></i>
<i class="fa fa-circle" ng-style="{ color: mountStatus.state === 'active' ? '#27CE65' : '#d9534f' }" ng-show="mountStatus" uib-tooltip="{{ mountStatus.message }}"></i>
<span ng-show="backupConfig.provider === 'filesystem' || backupConfig.provider === 'ext4' || backupConfig.provider === 'xfs' || backupConfig.provider === 'mountpoint'">{{ backupConfig.mountOptions.diskPath || backupConfig.mountPoint }}{{ (backupConfig.prefix ? '/' : '') + backupConfig.prefix }}</span>
<span ng-show="backupConfig.provider === 'cifs' || backupConfig.provider === 'nfs' || backupConfig.provider === 'sshfs'">{{ backupConfig.mountOptions.host }}:{{ backupConfig.mountOptions.remoteDir }}{{ (backupConfig.prefix ? '/' : '') + backupConfig.prefix }}</span>
</span>
@@ -507,8 +507,23 @@
</div>
</div>
<div class="text-left">
<h3>{{ 'backups.schedule.title' | tr }}</h3>
<div class="text-left section-header">
<h3>
{{ 'backups.schedule.title' | tr }}
<!-- <a class="btn btn-sm btn-default pull-right" ng-href="/logs.html?taskId={{cleanupBackups.taskId}}" target="_blank" uib-tooltip="{{ 'backups.logs.showLogs' | tr }}"><i class="fas fa-align-left"></i></a> -->
<div class="btn-group btn-group-sm pull-right">
<button type="button" class="btn btn-small btn-default dropdown-toggle" ng-show="cleanupTasks.length" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" uib-tooltip="{{ 'backups.logs.showLogs' | tr }}">
<i class="fas fa-align-left"></i> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li ng-repeat="task in cleanupTasks">
<a ng-href="/logs.html?taskId={{task.id}}" target="_blank" class="text-right">
{{ task.ts | prettyLongDate }} <i class="fa" style="margin-left: 20px" ng-class="{ 'status-active fa-check-circle': !task.active && task.success, 'fa-circle-notch fa-spin': task.active, 'status-error fa-times-circle': !task.active && !task.success }"></i>
</a>
</li>
</ul>
</div>
</h3>
</div>
<div class="card" style="margin-bottom: 15px;">
@@ -532,13 +547,28 @@
<br/>
<div class="row">
<div class="col-md-12 text-right">
<button class="btn btn-default" ng-click="cleanupBackups.ask()" ng-disabled="cleanupBackups.busy" style="margin-right: 5px"><i class="fa fa-circle-notch fa-spin" ng-show="cleanupBackups.busy"></i> {{ 'backups.listing.cleanupBackups' | tr }}</button>
<button class="btn btn-outline btn-primary pull-right" ng-show="user.isAtLeastOwner" ng-click="configureScheduleAndRetention.show()">{{ 'backups.schedule.configure' | tr }}</button>
</div>
</div>
</div>
<div class="text-left">
<h3>{{ 'backups.listing.title' | tr }}</h3>
<div class="text-left section-header">
<h3>
{{ 'backups.listing.title' | tr }}
<div class="btn-group btn-group-sm pull-right">
<button type="button" class="btn btn-small btn-default dropdown-toggle" ng-show="backupTasks.length" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" uib-tooltip="{{ 'backups.logs.showLogs' | tr }}">
<i class="fas fa-align-left"></i> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li ng-repeat="task in backupTasks">
<a ng-href="/logs.html?taskId={{task.id}}" target="_blank" class="text-right">
{{ task.ts | prettyLongDate }} <i class="fa" style="margin-left: 20px" ng-class="{ 'status-active fa-check-circle': !task.active && task.success, 'fa-circle-notch fa-spin': task.active, 'status-error fa-times-circle': !task.active && !task.success }"></i>
</a>
</li>
</ul>
</div>
</h3>
</div>
<div class="card card-large">
@@ -594,23 +624,9 @@
<div class="row">
<div class="col-md-12 text-right">
<button class="btn btn-default" ng-click="createBackup.cleanupBackups()" ng-show="!createBackup.busy" style="margin-right: 5px">{{ 'backups.listing.cleanupBackups' | tr }}</button>
<button class="btn btn-outline btn-primary" ng-click="createBackup.startBackup()" ng-show="!createBackup.busy">{{ 'backups.listing.backupNow' | tr }}</button>
<button class="btn btn-outline btn-danger" ng-click="createBackup.stopTask()" ng-show="createBackup.busy">{{ 'backups.listing.stopTask' | tr:{ taskType: createBackup.taskType } }}</button>
</div>
</div>
</div>
<div class="text-left">
<h3>{{ 'backups.logs.title' | tr }}</h3>
</div>
<div class="card card-large" style="margin-bottom: 15px;">
<div class="row">
<div class="col-md-12">
<p>{{ 'backups.logs.description' | tr }}</p>
<a class="btn btn-primary pull-right" ng-href="/logs.html?taskId={{createBackup.taskId}}" ng-disabled="!createBackup.taskId" target="_blank">{{ 'backups.logs.showLogs' | tr }}</a>
</div>
</div>
</div>
</div>
+100 -27
View File
@@ -12,11 +12,13 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.config = Client.getConfig();
$scope.user = Client.getUserInfo();
$scope.memory = null; // { memory, swap }
$scope.mountStatus = null; // { state, message }
$scope.manualBackupApps = [];
$scope.backupConfig = {};
$scope.backups = [];
$scope.backupTasks = [];
$scope.cleanupTasks = [];
$scope.s3Regions = REGIONS_S3;
$scope.wasabiRegions = REGIONS_WASABI;
@@ -119,11 +121,9 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
message: '',
errorMessage: '',
taskId: '',
taskType: TASK_TYPES.TASK_BACKUP,
checkStatus: function () {
// TODO support both task types TASK_BACKUP and TASK_CLEAN_BACKUPS
Client.getLatestTaskByType($scope.createBackup.taskType, function (error, task) {
init: function () {
Client.getLatestTaskByType(TASK_TYPES.TASK_BACKUP, function (error, task) {
if (error) return console.error(error);
if (!task) return;
@@ -143,6 +143,8 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.createBackup.percent = 100; // indicates that 'result' is valid
$scope.createBackup.errorMessage = data.success ? '' : data.error.message;
getBackupTasks();
return fetchBackups();
}
@@ -158,7 +160,6 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.createBackup.percent = 0;
$scope.createBackup.message = '';
$scope.createBackup.errorMessage = '';
$scope.createBackup.taskType = TASK_TYPES.TASK_BACKUP;
Client.startBackup(function (error, taskId) {
if (error) {
@@ -177,32 +178,14 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
return;
}
$scope.createBackup.taskId = taskId;
$scope.createBackup.updateStatus();
});
},
cleanupBackups: function () {
$('#cleanupBackupsModal').modal('show');
},
startCleanup: function () {
$scope.createBackup.busy = true;
$scope.createBackup.percent = 0;
$scope.createBackup.message = '';
$scope.createBackup.errorMessage = '';
$scope.createBackup.taskType = TASK_TYPES.TASK_CLEAN_BACKUPS;
$('#cleanupBackupsModal').modal('hide');
Client.cleanupBackups(function (error, taskId) {
if (error) console.error(error);
getBackupTasks();
$scope.createBackup.taskId = taskId;
$scope.createBackup.updateStatus();
});
},
stopTask: function () {
Client.stopTask($scope.createBackup.taskId, function (error) {
if (error) {
@@ -214,6 +197,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
}
$scope.createBackup.busy = false;
getBackupTasks();
return;
}
@@ -221,6 +205,62 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
}
};
$scope.cleanupBackups = {
busy: false,
taskId: 0,
init: function () {
Client.getLatestTaskByType(TASK_TYPES.TASK_CLEAN_BACKUPS, function (error, task) {
if (error) return console.error(error);
if (!task) return;
$scope.cleanupBackups.taskId = task.id;
$scope.cleanupBackups.updateStatus();
getCleanupTasks();
});
},
updateStatus: function () {
Client.getTask($scope.cleanupBackups.taskId, function (error, data) {
if (error) return window.setTimeout($scope.cleanupBackups.updateStatus, 5000);
if (!data.active) {
$scope.cleanupBackups.busy = false;
getCleanupTasks();
fetchBackups();
return;
}
$scope.cleanupBackups.busy = true;
$scope.cleanupBackups.message = data.message;
window.setTimeout($scope.cleanupBackups.updateStatus, 3000);
});
},
ask: function () {
$('#cleanupBackupsModal').modal('show');
},
start: function () {
$scope.cleanupBackups.busy = true;
$('#cleanupBackupsModal').modal('hide');
Client.cleanupBackups(function (error, taskId) {
if (error) console.error(error);
$scope.cleanupBackups.taskId = taskId;
$scope.cleanupBackups.updateStatus();
getCleanupTasks();
});
}
};
$scope.listBackups = {
};
@@ -727,6 +767,35 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
if (error) return console.error(error);
$scope.backupConfig = backupConfig;
$scope.mountStatus = null;
if (!$scope.mountlike($scope.backupConfig.provider)) return;
Client.getBackupMountStatus(function (error, mountStatus) {
if (error) return console.error(error);
$scope.mountStatus = mountStatus;
});
});
}
function getBackupTasks() {
Client.getTasksByType(TASK_TYPES.TASK_BACKUP, function (error, tasks) {
if (error) return console.error(error);
if (!tasks.length) return;
$scope.backupTasks = tasks.slice(0, 10);
});
}
function getCleanupTasks() {
Client.getTasksByType(TASK_TYPES.TASK_CLEAN_BACKUPS, function (error, tasks) {
if (error) return console.error(error);
if (!tasks.length) return;
$scope.cleanupTasks = tasks.slice(0, 10);
});
}
@@ -742,7 +811,11 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.manualBackupApps = Client.getInstalledApps().filter(function (app) { return app.type !== APP_TYPES.LINK && !app.enableBackup; });
// show backup status
$scope.createBackup.checkStatus();
$scope.createBackup.init();
$scope.cleanupBackups.init();
getBackupTasks();
getCleanupTasks();
});
});
+53 -14
View File
@@ -331,9 +331,9 @@
{{ prettyProviderName(domain) }}
</td>
<td class="text-right no-wrap" style="vertical-align: bottom">
<button class="btn btn-xs btn-default" ng-click="domainWellKnown.show(domain)" title="{{ 'domains.tooltipWellKnown' | tr }}"><i class="fa fa-atlas"></i></button>
<button class="btn btn-xs btn-default" ng-click="domainConfigure.show(domain)" title="{{ 'domains.tooltipEdit' | tr }}"><i class="fa fa-pencil-alt"></i></button>
<button class="btn btn-xs btn-danger" ng-click="domainRemove.show(domain)" title="{{ 'domains.tooltipRemove' | tr }}" ng-disabled="domain.domain === config.adminDomain"><i class="far fa-trash-alt"></i></button>
<button class="btn btn-xs btn-default" ng-click="domainWellKnown.show(domain)" uib-tooltip="{{ 'domains.tooltipWellKnown' | tr }}"><i class="fa fa-atlas"></i></button>
<button class="btn btn-xs btn-default" ng-click="domainConfigure.show(domain)" uib-tooltip="{{ 'domains.tooltipEdit' | tr }}"><i class="fa fa-pencil-alt"></i></button>
<button class="btn btn-xs btn-danger" ng-click="domainRemove.show(domain)" uib-tooltip="{{ 'domains.tooltipRemove' | tr }}" ng-disabled="domain.domain === config.adminDomain"><i class="far fa-trash-alt"></i></button>
</td>
</tr>
</tbody>
@@ -350,8 +350,22 @@
</div>
</div>
<div class="text-left">
<h3>{{ 'domains.renewCerts.title' | tr }}</h3>
<div class="text-left section-header">
<h3>
{{ 'domains.renewCerts.title' | tr }}
<div class="btn-group btn-group-sm pull-right">
<button type="button" class="btn btn-small btn-default dropdown-toggle" ng-show="renewCerts.tasks.length" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" uib-tooltip="{{ 'domains.renewCerts.showLogsAction' | tr }}">
<i class="fas fa-align-left"></i> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li ng-repeat="task in renewCerts.tasks">
<a ng-href="/logs.html?taskId={{task.id}}" target="_blank" class="text-right">
{{ task.ts | prettyLongDate }} <i class="fa" style="margin-left: 20px" ng-class="{ 'status-active fa-check-circle': !task.active && task.success, 'fa-circle-notch fa-spin': task.active, 'status-error fa-times-circle': !task.active && !task.success }"></i>
</a>
</li>
</ul>
</div>
</h3>
</div>
<div class="card">
@@ -375,14 +389,27 @@
<p ng-hide="renewCerts.busy">
<div class="has-error" ng-show="!renewCerts.active">{{ renewCerts.errorMessage }}</div>
</p>
<a class="btn btn-primary pull-right" ng-href="/logs.html?taskId={{renewCerts.taskId}}" ng-disabled="!renewCerts.taskId" target="_blank">{{ 'domains.renewCerts.showLogsAction' | tr }}</a>
<button class="btn btn-outline btn-primary pull-right" ng-click="renewCerts.renew()" ng-disabled="renewCerts.busy" style="margin-right: 10px">{{ 'domains.renewCerts.renewAllAction' | tr }}</button>
<button class="btn btn-outline btn-primary pull-right" ng-click="renewCerts.renew()" ng-disabled="renewCerts.busy">{{ 'domains.renewCerts.renewAllAction' | tr }}</button>
</div>
</div>
</div>
<div class="text-left">
<h3>{{ 'domains.syncDns.title' | tr }}</h3>
<div class="text-left section-header">
<h3>
{{ 'domains.syncDns.title' | tr }}
<div class="btn-group btn-group-sm pull-right">
<button type="button" class="btn btn-small btn-default dropdown-toggle" ng-show="syncDns.tasks.length" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" uib-tooltip="{{ 'domains.renewCerts.showLogsAction' | tr }}">
<i class="fas fa-align-left"></i> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li ng-repeat="task in syncDns.tasks">
<a ng-href="/logs.html?taskId={{task.id}}" target="_blank" class="text-right">
{{ task.ts | prettyLongDate }} <i class="fa" style="margin-left: 20px" ng-class="{ 'status-active fa-check-circle': !task.active && task.success, 'fa-circle-notch fa-spin': task.active, 'status-error fa-times-circle': !task.active && !task.success }"></i>
</a>
</li>
</ul>
</div>
</h3>
</div>
<div class="card">
@@ -406,14 +433,27 @@
<p ng-hide="syncDns.busy">
<div class="has-error" ng-show="!syncDns.active">{{ syncDns.errorMessage }}</div>
</p>
<a class="btn btn-primary pull-right" ng-href="/logs.html?taskId={{syncDns.taskId}}" ng-disabled="!syncDns.taskId" target="_blank">{{ 'domains.syncDns.showLogsAction' | tr }}</a>
<button class="btn btn-outline btn-primary pull-right" ng-click="syncDns.sync()" ng-disabled="syncDns.busy" style="margin-right: 10px">{{ 'domains.syncDns.syncAction' | tr }}</button>
<button class="btn btn-outline btn-primary pull-right" ng-click="syncDns.sync()" ng-disabled="syncDns.busy">{{ 'domains.syncDns.syncAction' | tr }}</button>
</div>
</div>
</div>
<div class="text-left">
<h3>{{ 'domains.changeDashboardDomain.title' | tr }}</h3>
<div class="text-left section-header">
<h3>
{{ 'domains.changeDashboardDomain.title' | tr }}
<div class="btn-group btn-group-sm pull-right">
<button type="button" class="btn btn-small btn-default dropdown-toggle" ng-show="changeDashboard.tasks.length" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" uib-tooltip="{{ 'domains.renewCerts.showLogsAction' | tr }}">
<i class="fas fa-align-left"></i> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li ng-repeat="task in changeDashboard.tasks">
<a ng-href="/logs.html?taskId={{task.id}}" target="_blank" class="text-right">
{{ task.ts | prettyLongDate }} <i class="fa" style="margin-left: 20px" ng-class="{ 'status-active fa-check-circle': !task.active && task.success, 'fa-circle-notch fa-spin': task.active, 'status-error fa-times-circle': !task.active && !task.success }"></i>
</a>
</li>
</ul>
</div>
</h3>
</div>
<div class="card">
@@ -447,7 +487,6 @@
<div class="col-md-6 text-right">
<button class="btn btn-outline btn-primary" ng-click="changeDashboard.change()" ng-hide="changeDashboard.busy" ng-disabled="changeDashboard.selectedDomain.domain === changeDashboard.adminDomain.domain">{{ 'domains.changeDashboardDomain.changeAction' | tr }}</button>
<button class="btn btn-outline btn-danger" ng-click="changeDashboard.stop()" ng-show="changeDashboard.busy" style="margin-right: 10px">{{ 'domains.changeDashboardDomain.cancelAction' | tr }}</button>
<a class="btn btn-primary pull-right" ng-href="/logs.html?taskId={{changeDashboard.taskId}}" ng-show="changeDashboard.busy" target="_blank">{{ 'domains.changeDashboardDomain.showLogsAction' | tr }}</a>
</div>
</div>
</div>
+38 -42
View File
@@ -11,7 +11,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.domains = [];
$scope.ready = false;
$scope.domainSearchString = '';
$scope.pageSize = 10;
$scope.pageSize = localStorage.cloudronPageSize || 10;
$scope.currentPage = 1;
$scope.showNextPage = function () {
@@ -489,21 +489,20 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
percent: 0,
message: '',
errorMessage: '',
taskId: '',
tasks: [],
checkStatus: function () {
Client.getLatestTaskByType(TASK_TYPES.TASK_CHECK_CERTS, function (error, task) {
refreshTasks: function () {
Client.getTasksByType(TASK_TYPES.TASK_CHECK_CERTS, function (error, tasks) {
if (error) return console.error(error);
if (!task) return;
$scope.renewCerts.taskId = task.id;
$scope.renewCerts.updateStatus();
$scope.renewCerts.tasks = tasks.slice(0, 10);
if ($scope.renewCerts.tasks.length && $scope.renewCerts.tasks[0].active) $scope.renewCerts.updateStatus();
});
},
updateStatus: function () {
Client.getTask($scope.renewCerts.taskId, function (error, data) {
var taskId = $scope.renewCerts.tasks[0].id;
Client.getTask(taskId, function (error, data) {
if (error) return window.setTimeout($scope.renewCerts.updateStatus, 5000);
if (!data.active) {
@@ -512,6 +511,8 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.renewCerts.percent = 100; // indicates that 'result' is valid
$scope.renewCerts.errorMessage = data.success ? '' : data.error.message;
$scope.renewCerts.refreshTasks(); // update the tasks list dropdown
return;
}
@@ -529,15 +530,13 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.renewCerts.errorMessage = '';
// always rebuild the nginx configs when triggered via the UI. we assume user is clicking this because something is wrong
Client.renewCerts({ rebuild: true }, function (error, taskId) {
Client.renewCerts({ rebuild: true }, function (error /*, taskId */) {
if (error) {
console.error(error);
$scope.renewCerts.errorMessage = error.message;
$scope.renewCerts.busy = false;
} else {
$scope.renewCerts.taskId = taskId;
$scope.renewCerts.updateStatus();
$scope.renewCerts.refreshTasks();
}
});
}
@@ -548,21 +547,19 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
percent: 0,
message: '',
errorMessage: '',
taskId: '',
tasks: [],
checkStatus: function () {
Client.getLatestTaskByType(TASK_TYPES.TASK_SYNC_DNS_RECORDS, function (error, task) {
refreshTasks: function () {
Client.getTasksByType(TASK_TYPES.TASK_SYNC_DNS_RECORDS, function (error, tasks) {
if (error) return console.error(error);
if (!task) return;
$scope.syncDns.taskId = task.id;
$scope.syncDns.updateStatus();
$scope.syncDns.tasks = tasks.slice(0, 10);
if ($scope.syncDns.tasks.length && $scope.syncDns.tasks[0].active) $scope.syncDns.updateStatus();
});
},
updateStatus: function () {
Client.getTask($scope.syncDns.taskId, function (error, data) {
var taskId = $scope.syncDns.tasks[0].id;
Client.getTask(taskId, function (error, data) {
if (error) return window.setTimeout($scope.syncDns.updateStatus, 5000);
if (!data.active) {
@@ -571,6 +568,8 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.syncDns.percent = 100; // indicates that 'result' is valid
$scope.syncDns.errorMessage = data.success ? '' : data.error.message;
$scope.syncDns.refreshTasks(); // update the tasks list dropdown
return;
}
@@ -587,15 +586,13 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.syncDns.message = '';
$scope.syncDns.errorMessage = '';
Client.setDnsRecords({}, function (error, taskId) {
Client.setDnsRecords({}, function (error /*, taskId */) {
if (error) {
console.error(error);
$scope.syncDns.errorMessage = error.message;
$scope.syncDns.busy = false;
} else {
$scope.syncDns.taskId = taskId;
$scope.syncDns.updateStatus();
$scope.syncDns.refreshTasks();
}
});
}
@@ -649,24 +646,21 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
taskId: '',
selectedDomain: null,
adminDomain: null,
tasks: [],
refreshTasks: function () {
Client.getTasksByType(TASK_TYPES.TASK_SETUP_DNS_AND_CERT, function (error, tasks) {
if (error) return console.error(error);
$scope.changeDashboard.tasks = tasks.slice(0, 10);
if ($scope.changeDashboard.tasks.length && $scope.changeDashboard.tasks[0].active) $scope.changeDashboard.updateStatus();
});
},
stop: function () {
Client.stopTask($scope.changeDashboard.taskId, function (error) {
if (error) console.error(error);
$scope.changeDashboard.busy = false;
});
},
// this function is not called intentionally. currently, we do switching in two steps - prepare and set
// if the user refreshed the UI in the middle of prepare, then it would be awkward to resume/call 'set' when the
// user visits the UI the next time around.
checkStatus: function () {
Client.getLatestTaskByType('prepareDashboardDomain', function (error, task) {
if (error) return console.error(error);
if (!task) return;
$scope.changeDashboard.taskId = task.id;
$scope.changeDashboard.updateStatus();
$scope.changeDashboard.refreshTasks();
});
},
@@ -721,7 +715,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.changeDashboard.busy = false;
} else {
$scope.changeDashboard.taskId = taskId;
$scope.changeDashboard.updateStatus();
$scope.changeDashboard.refreshTasks();
}
});
}
@@ -734,7 +728,9 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.ready = true;
});
$scope.renewCerts.checkStatus();
$scope.renewCerts.refreshTasks();
$scope.syncDns.refreshTasks();
$scope.changeDashboard.refreshTasks();
});
document.getElementById('gcdnsKeyFileInput').onchange = readFileLocally($scope.domainConfigure.gcdnsKey, 'content', 'keyFileName');
+1 -1
View File
@@ -725,7 +725,7 @@
<div id="collapse_dns_{{ record.value }}" class="panel-collapse collapse">
<div class="panel-body">
<p ng-show="record.name === 'MX' && domain.provider === 'namecheap'">{{ 'email.dnsStatus.namecheapInfo' | tr }} <sup><a ng-href="https://docs.cloudron.io/domains/#namecheap-dns" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></p>
<p ng-show="record.name === 'PTR'">{{ 'email.dnsStatus.ptrInfo' | tr }} <sup><a ng-href="https://docs.cloudron.io/troubleshooting/#ptr-record" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></p>
<p ng-show="record.name === 'PTR'">{{ 'email.dnsStatus.ptrInfo' | tr }} <sup><a ng-href="https://docs.cloudron.io/email/#ptr-record" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></p>
<p ng-show="expectedDnsRecords[record.value].name">{{ 'email.dnsStatus.hostname' | tr }}: <b ng-click-select><tt>{{ expectedDnsRecords[record.value].name }}</tt></b></p>
<p ng-hide="expectedDnsRecords[record.value].name">{{ 'email.dnsStatus.domain' | tr }}: <b ng-click-select><tt>{{ expectedDnsRecords[record.value].domain }}</tt></b></p>
<p>{{ 'email.dnsStatus.type' | tr }}: <b ng-click-select><tt>{{ expectedDnsRecords[record.value].type }}</tt></b></p>
+7 -1
View File
@@ -64,6 +64,12 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
Client.openSubscriptionSetup($scope.$parent.subscription);
};
function updateMailUsage(mailboxName, quotaLimit) {
if (!$scope.mailUsage) $scope.mailUsage = {};
if (!$scope.mailUsage[mailboxName]) $scope.mailUsage[mailboxName] = {};
$scope.mailUsage[mailboxName].quotaLimit = quotaLimit;
}
function refreshMailUsage() {
Client.getMailUsage($scope.domain.domain, function (error, usage) {
if (error) console.error(error);
@@ -646,7 +652,7 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
}
function done() {
$scope.mailUsage[$scope.mailboxes.edit.name + '@' + $scope.domain.domain].quotaLimit = $scope.mailboxes.edit.storageQuotaEnabled ? $scope.mailboxes.edit.storageQuota : 0; // hack to avoid refresh
updateMailUsage($scope.mailboxes.edit.name + '@' + $scope.domain.domain, $scope.mailboxes.edit.storageQuotaEnabled ? $scope.mailboxes.edit.storageQuota : 0); // hack to avoid refresh
$scope.mailboxes.edit.busy = false;
$scope.mailboxes.edit.error = null;
+2 -4
View File
@@ -216,8 +216,6 @@
<div class="pull-right">
<a class="btn btn-default" ng-show="user.isAtLeastOwner" href="#/emails-queue">{{ 'emails.action.queue' | tr }}</a>
<a class="btn btn-default" ng-show="user.isAtLeastOwner" href="#/emails-eventlog">{{ 'eventlog.title' | tr }}</a>
<!-- hidden for now, until we see a purpose -->
<!-- <a class="btn btn-sm btn-default" ng-disabled="user.isAtLeastOwner" href="/filemanager.html?id=mail&type=mail" target="_blank" uib-tooltip="{{ 'app.filemanagerActionTooltip' | tr }}" tooltip-append-to-body="true" tooltip-placement="bottom"><i class="fas fa-folder"></i></a> -->
</div>
</h1>
</div>
@@ -271,7 +269,7 @@
</div>
</div>
<div class="text-left" ng-show="user.isAtLeastOwner">
<div class="text-left section-header" ng-show="user.isAtLeastOwner">
<h3>{{ 'emails.mailboxSharing.title' | tr }}</h3>
</div>
@@ -291,7 +289,7 @@
</div>
</div>
<div class="text-left" ng-show="user.isAtLeastOwner">
<div class="text-left section-header" ng-show="user.isAtLeastOwner">
<h3>{{ 'emails.settings.title' | tr }}</h3>
</div>
-2
View File
@@ -32,7 +32,6 @@ angular.module('Application').controller('EventLogController', ['$scope', '$loca
{ name: 'app.start', value: 'app.start' },
{ name: 'app.stop', value: 'app.stop' },
{ name: 'app.restart', value: 'app.restart' },
{ name: 'Apptask Crash', value: 'app.task.crash' },
{ name: 'backup.cleanup', value: 'backup.cleanup.start' },
{ name: 'backup.cleanup.finish', value: 'backup.cleanup.finish' },
{ name: 'backup.finish', value: 'backup.finish' },
@@ -74,7 +73,6 @@ angular.module('Application').controller('EventLogController', ['$scope', '$loca
{ name: 'volume.add', value: 'volume.add' },
{ name: 'volume.update', value: 'volume.update' },
{ name: 'volume.remove', value: 'volume.update' },
{ name: 'System Crash', value: 'system.crash' }
];
$scope.pageItemCount = [
+82 -46
View File
@@ -71,6 +71,32 @@
</div>
</div>
<!-- Modal Trusted IPs -->
<div class="modal fade" id="trustedIpsModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'network.trustedIps.title' | tr }}</h4>
</div>
<div class="modal-body">
<form name="trustedIpsChangeForm" role="form" novalidate ng-submit="trustedIps.submit()" autocomplete="off">
<div class="form-group">
<label class="control-label">{{ 'network.trustedIpRanges' | tr }}</label>
<p class="small">{{ 'network.trustedIps.description' | tr }}</p>
<div class="has-error" ng-show="trustedIps.error.trustedIps">{{ trustedIps.error.trustedIps }}</div>
<textarea ng-model="trustedIps.trustedIps" placeholder="{{ 'network.firewall.configure.blocklistPlaceholder' | tr }}" name="trustedIps" class="form-control" ng-class="{ 'has-error': !trustedIpsChangeForm.trustedIps.$dirty && trustedIps.error.trustedIps }" rows="4"></textarea>
</div>
<input class="ng-hide" type="submit"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="trustedIps.submit()"><i class="fa fa-circle-notch fa-spin" ng-show="trustedIps.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal IPv6 -->
<div class="modal fade" id="ipv6ConfigureModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
@@ -173,8 +199,59 @@
</div>
</div>
<!-- IPv6 -->
<div class="text-left section-header">
<h3>{{ 'network.ipv6.title' | tr }}</h3>
</div>
<div class="card">
<div class="row">
<div class="col-xs-12">
{{ 'network.ipv6.description' | tr }}
</div>
</div>
<br/>
<div class="row">
<div class="col-xs-2">
<span class="text-muted">{{ 'network.ip.provider' | tr }}</span>
</div>
<div class="col-xs-10 text-right">
<span>{{ prettyIpProviderName(ipv6Configure.provider) }}</span>
</div>
</div>
<div class="row" ng-show="ipv6Configure.provider !== 'noop'">
<div class="col-xs-2">
<span class="text-muted">{{ 'network.ip.address' | tr }}</span>
</div>
<div class="col-xs-10 text-right">
<span ng-show="ipv6Configure.ipv6">{{ ipv6Configure.ipv6 }}</span>
<span ng-show="!ipv6Configure.ipv6 && ipv6Configure.serverIPv6">{{ ipv6Configure.serverIPv6 }} ({{ 'network.ip.detected' | tr }})</span>
<span ng-show="ipv6Configure.displayError" class="text-danger">{{ ipv6Configure.displayError }}</span>
</div>
</div>
<div class="row" ng-show="ipv6Configure.ifname">
<div class="col-xs-6">
<span class="text-muted">{{ 'network.ip.interface' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ ipv6Configure.ifname }}</span>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-6 col-md-offset-6 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-click="ipv6Configure.show()">{{ 'network.ip.configure' | tr }}</button>
</div>
</div>
</div>
<!-- Firewall -->
<div class="text-left" ng-show="user.isAtLeastOwner">
<div class="text-left section-header" ng-show="user.isAtLeastOwner">
<h3>{{ 'network.firewall.title' | tr }}</h3>
</div>
@@ -187,60 +264,19 @@
<span>{{ 'network.firewall.blocklist' | tr:{ blockCount: blocklist.currentBlocklistLength } }} <a href="" ng-click="blocklist.show()"><i class="fa fa-edit text-small"></i></a></span>
</div>
</div>
</div>
<!-- IPv6 -->
<div class="text-left">
<h3>{{ 'network.ipv6.title' | tr }}</h3>
</div>
<div class="card">
<div class="row">
<div class="col-xs-12">
{{ 'network.ipv6.description' | tr }}
</div>
</div>
<br/>
<div class="row">
<div class="col-xs-2">
<span class="text-muted">{{ 'network.ip.provider' | tr }}</span>
</div>
<div class="col-xs-10 text-right">
<span>{{ prettyIpProviderName(ipv6Configure.provider) }}</span>
</div>
</div>
<div class="row" ng-show="ipv6Configure.provider !== 'noop'">
<div class="col-xs-2">
<span class="text-muted">{{ 'network.ip.address' | tr }}</span>
</div>
<div class="col-xs-10 text-right">
<span ng-show="ipv6Configure.ipv6">{{ ipv6Configure.ipv6 }}</span>
<span ng-show="!ipv6Configure.ipv6 && ipv6Configure.serverIPv6">{{ ipv6Configure.serverIPv6 }} ({{ 'network.ip.detected' | tr }})</span>
<span ng-show="ipv6Configure.displayError" class="text-danger">{{ ipv6Configure.displayError }}</span>
</div>
</div>
<div class="row" ng-show="ipv6Configure.ifname">
<div class="col-xs-6">
<span class="text-muted">{{ 'network.ip.interface' | tr }}</span>
<span class="text-muted">{{ 'network.trustedIpRanges' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ ipv6Configure.ifname }}</span>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-6 col-md-offset-6 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-click="ipv6Configure.show()">{{ 'network.ip.configure' | tr }}</button>
<span>{{ 'network.trustedIps.summary' | tr:{ trustCount: trustedIps.currentTrustedIpsLength } }} <a href="" ng-click="trustedIps.show()"><i class="fa fa-edit text-small"></i></a></span>
</div>
</div>
</div>
<div class="text-left">
<!-- Dynamic DNS -->
<div class="text-left section-header">
<h3>{{ 'network.dyndns.title' | tr }}</h3>
</div>
+45
View File
@@ -192,6 +192,50 @@ angular.module('Application').controller('NetworkController', ['$scope', '$locat
}
};
$scope.trustedIps = {
busy: false,
error: {},
trustedIps: '',
currentTrustedIps: '',
currentTrustedIpsLength: 0,
refresh: function () {
Client.getTrustedIps(function (error, result) {
if (error) return console.error(error);
$scope.trustedIps.currentTrustedIps = result;
$scope.trustedIps.currentTrustedIpsLength = result.split('\n').filter(function (l) { return l.length !== 0 && l[0] !== '#'; }).length;
});
},
show: function () {
$scope.trustedIps.error = {};
$scope.trustedIps.trustedIps = $scope.trustedIps.currentTrustedIps;
$('#trustedIpsModal').modal('show');
},
submit: function () {
$scope.trustedIps.error = {};
$scope.trustedIps.busy = true;
Client.setTrustedIps($scope.trustedIps.trustedIps, function (error) {
$scope.trustedIps.busy = false;
if (error) {
$scope.trustedIps.error.trustedIps = error.message;
$scope.trustedIps.error.ip = error.message;
$scope.trustedIpsChangeForm.$setPristine();
$scope.trustedIpsChangeForm.$setUntouched();
return;
}
$scope.trustedIps.refresh();
$('#trustedIpsModal').modal('hide');
});
}
};
$scope.sysinfo = {
busy: false,
error: {},
@@ -276,6 +320,7 @@ angular.module('Application').controller('NetworkController', ['$scope', '$locat
$scope.dyndnsConfigure.refresh();
$scope.ipv6Configure.refresh();
$scope.trustedIps.refresh();
if ($scope.user.isAtLeastOwner) $scope.blocklist.refresh();
});
+19 -6
View File
@@ -204,7 +204,7 @@
</div>
</div>
<div class="text-left">
<div class="text-left section-header">
<h3>{{ 'settings.timezone.title' | tr }}</h3>
</div>
@@ -228,7 +228,7 @@
</div>
</div>
<div class="text-left">
<div class="text-left section-header">
<h3>{{ 'settings.language.title' | tr }}</h3>
</div>
@@ -251,8 +251,22 @@
</div>
</div>
<div class="text-left">
<h3>{{ 'settings.updates.title' | tr }}</h3>
<div class="text-left section-header">
<h3>
{{ 'settings.updates.title' | tr }}
<div class="btn-group btn-group-sm pull-right">
<button type="button" class="btn btn-small btn-default dropdown-toggle" ng-show="update.tasks.length" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" uib-tooltip="{{ 'settings.updates.showLogsAction' | tr }}">
<i class="fas fa-align-left"></i> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li ng-repeat="task in update.tasks">
<a ng-href="/logs.html?taskId={{task.id}}" target="_blank" class="text-right">
{{ task.ts | prettyLongDate }} <i class="fa" style="margin-left: 20px" ng-class="{ 'status-active fa-check-circle': !task.active && task.success, 'fa-circle-notch fa-spin': task.active, 'status-error fa-times-circle': !task.active && !task.success }"></i>
</a>
</li>
</ul>
</div>
</h3>
</div>
<div class="card" style="margin-bottom: 15px;">
@@ -286,7 +300,6 @@
<div class="row" ng-show="update.busy">
<div class="col-md-12">
<p >{{ update.message }}</p>
<p class="has-error" ng-show="update.errorMessage">{{ update.errorMessage }}. <a ng-class="warning" ng-href="/logs.html?taskId={{update.taskId}}" target="_blank">{{ 'settings.updates.showLogsAction' | tr }}</a></p>
</div>
</div>
@@ -300,7 +313,7 @@
</div>
</div>
<div class="text-left">
<div class="text-left section-header">
<h3>{{ 'settings.privateDockerRegistry.title' | tr }}</h3>
</div>
+21 -18
View File
@@ -1,7 +1,7 @@
'use strict';
/* global angular:false */
/* global $:false */
/* global $:false, TASK_TYPES */
angular.module('Application').controller('SettingsController', ['$scope', '$location', '$translate', '$rootScope', '$timeout', 'Client', function ($scope, $location, $translate, $rootScope, $timeout, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
@@ -87,8 +87,16 @@ angular.module('Application').controller('SettingsController', ['$scope', '$loca
percent: 0,
message: 'Downloading',
errorMessage: '', // this shows inline
taskId: '',
skipBackup: false,
tasks: [],
refreshTasks: function () {
Client.getTasksByType(TASK_TYPES.TASK_UPDATE, function (error, tasks) {
if (error) return console.error(error);
$scope.update.tasks = tasks.slice(0, 10);
if ($scope.update.tasks.length && $scope.update.tasks[0].active) $scope.update.updateStatus();
});
},
checkNow: function () {
$scope.update.checking = true;
@@ -108,7 +116,9 @@ angular.module('Application').controller('SettingsController', ['$scope', '$loca
},
stopUpdate: function () {
Client.stopTask($scope.update.taskId, function (error) {
var taskId = $scope.update.tasks[0].id;
Client.stopTask(taskId, function (error) {
if (error) {
if (error.statusCode === 409) {
$scope.update.errorMessage = 'No update is currently in progress';
@@ -124,16 +134,6 @@ angular.module('Application').controller('SettingsController', ['$scope', '$loca
});
},
checkStatus: function () {
Client.getLatestTaskByType('update', function (error, task) {
if (error) return console.error(error);
if (!task) return;
$scope.update.taskId = task.id;
$scope.update.updateStatus();
});
},
reloadIfNeeded: function () {
Client.getStatus(function (error, status) {
if (error) return $scope.error(error);
@@ -143,7 +143,9 @@ angular.module('Application').controller('SettingsController', ['$scope', '$loca
},
updateStatus: function () {
Client.getTask($scope.update.taskId, function (error, data) {
var taskId = $scope.update.tasks[0].id;
Client.getTask(taskId, function (error, data) {
if (error) return window.setTimeout($scope.update.updateStatus, 5000);
if (!data.active) {
@@ -154,6 +156,8 @@ angular.module('Application').controller('SettingsController', ['$scope', '$loca
if (!data.errorMessage) $scope.update.reloadIfNeeded(); // assume success
$scope.update.refreshTasks(); // redundant... update the tasks list dropdown
return;
}
@@ -172,7 +176,7 @@ angular.module('Application').controller('SettingsController', ['$scope', '$loca
$scope.update.message = '';
$scope.update.errorMessage = '';
Client.update({ skipBackup: $scope.update.skipBackup }, function (error, taskId) {
Client.update({ skipBackup: $scope.update.skipBackup }, function (error /*, taskId */) {
if (error) {
$scope.update.error.generic = error.message;
$scope.update.busy = false;
@@ -181,8 +185,7 @@ angular.module('Application').controller('SettingsController', ['$scope', '$loca
$('#updateModal').modal('hide');
$scope.update.taskId = taskId;
$scope.update.updateStatus();
$scope.update.refreshTasks();
});
}
};
@@ -430,7 +433,7 @@ angular.module('Application').controller('SettingsController', ['$scope', '$loca
});
});
$scope.update.checkStatus();
$scope.update.refreshTasks();
if ($scope.user.isAtLeastOwner) getSubscription();
});
+1 -1
View File
@@ -69,7 +69,7 @@
</div>
</div>
<div class="text-left">
<div class="text-left section-header">
<h3>{{ 'support.remoteSupport.title' | tr }}</h3>
</div>
+5 -4
View File
@@ -672,6 +672,7 @@
</div>
<div class="pull-right">
<button class="btn btn-default btn-outline btn-xs" ng-click="showPrevPage()" ng-class="{ 'btn-primary': currentPage > 1 }" ng-disabled="userRefreshBusy || currentPage <= 1"><i class="fa fa-angle-double-left"></i> {{ 'main.pagination.prev' | tr }}</button>
<span style="margin: 0 5px; line-height: 1.5; font-size: 12px;">{{ currentPage }}</span>
<button class="btn btn-default btn-outline btn-xs" ng-click="showNextPage()" ng-class="{ 'btn-primary': users.length > pageItems }" ng-disabled="userRefreshBusy || users.length < pageItems">{{ 'main.pagination.next' | tr }} <i class="fa fa-angle-double-right"></i></button>
</div>
</div>
@@ -728,7 +729,7 @@
</div>
</div>
<div class="text-left" style="margin-top: 50px;" ng-show="user.isAtLeastAdmin">
<div class="text-left section-header" ng-show="user.isAtLeastAdmin">
<h3>{{ 'users.settings.title' | tr }}</h3>
</div>
@@ -762,7 +763,7 @@
</div>
<div class="text-left" style="margin-top: 50px;" ng-show="user.isAtLeastAdmin">
<div class="text-left section-header" ng-show="user.isAtLeastAdmin">
<h3>{{ 'users.externalLdap.title' | tr }}</h3>
</div>
@@ -921,7 +922,7 @@
</div>
</div>
<div class="text-left" style="margin-top: 50px;" ng-show="user.isAtLeastAdmin">
<div class="text-left section-header" ng-show="user.isAtLeastAdmin">
<h3>{{ 'users.exposedLdap.title' | tr }}</h3>
</div>
@@ -976,7 +977,7 @@
</div>
</div>
<div class="text-left" style="margin-top: 50px;" ng-show="user.isAtLeastAdmin">
<div class="text-left section-header" ng-show="user.isAtLeastAdmin">
<h3>{{ 'oidc.title' | tr }}</h3>
</div>
+1 -1
View File
@@ -42,7 +42,7 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
$scope.userSearchString = '';
$scope.currentPage = 1;
$scope.pageItems = 15;
$scope.pageItems = localStorage.cloudronPageSize || 15;
$scope.userRefreshBusy = true;
$scope.userStates = [
+8 -4
View File
@@ -34,10 +34,14 @@
<input type="text" class="form-control" ng-model="volumeAdd.hostPath" name="hostPath" ng-disabled="volumeAdd.busy" placeholder="/mnt/data" ng-required="volumeAdd.mountType === 'mountpoint'" autofocus>
</div>
<div class="form-group" ng-show="volumeAdd.mountType === 'ext4' || volumeAdd.mountType === 'xfs'">
<div class="form-group" ng-show="volumeAdd.mountType === 'ext4'">
<label class="control-label">{{ 'volumes.addVolumeDialog.diskPath' | tr }}</label>
<select class="form-control" ng-model="volumeAdd.diskPath" ng-options="item.path as item.label for item in blockDevices track by item.path"></select>
<input type="text" class="form-control" style="margin-top: 5px;" ng-show="volumeAdd.diskPath.path === 'custom'" ng-model="volumeAdd.customDiskPath" ng-disabled="volumeAdd.busy" placeholder="/dev/disk/by-uuid/uuid" ng-required="(volumeAdd.mountType === 'ext4' || volumeAdd.mountType === 'xfs') && volumeAdd.diskPath.path === 'custom'">
<select class="form-control" ng-model="volumeAdd.ext4Disk" ng-options="item as item.label for item in ext4BlockDevices track by item.path" ng-required="volumeAdd.mountType === 'ext4'"></select>
</div>
<div class="form-group" ng-show="volumeAdd.mountType === 'xfs'">
<label class="control-label">{{ 'volumes.addVolumeDialog.diskPath' | tr }}</label>
<select class="form-control" ng-model="volumeAdd.xfsDisk" ng-options="item as item.label for item in xfsBlockDevices track by item.path" ng-required="volumeAdd.mountType === 'xfs'"></select>
</div>
<div class="form-group" ng-show="volumeAdd.mountType === 'cifs' || volumeAdd.mountType === 'nfs' || volumeAdd.mountType === 'sshfs'">
@@ -153,7 +157,7 @@
<td class="text-left wrap-table-cell hidden-xs hidden-sm" ng-show="volume.mountType === 'mountpoint' || volume.mountType === 'filesystem'">{{ volume.hostPath }}</td>
<td class="text-right no-wrap" style="vertical-align: bottom">
<button class="btn btn-xs btn-default" ng-click="remount(volume)" ng-show="isMountProvider(volume.mountType)" ng-disabled="volume.remounting" uib-tooltip="{{ 'volumes.remountActionTooltip' | tr }}"><i class="fa fa-sync-alt" ng-class="{ 'fa-spin': volume.remounting }"></i></button>
<a class="btn btn-xs btn-default" ng-href="{{ '/filemanager.html?type=volume&id=' + volume.id }}" target="_blank" uib-tooltip="{{ 'volumes.openFileManagerActionTooltip' | tr }}"><i class="fas fa-folder"></i></a>
<a class="btn btn-xs btn-default" ng-href="{{ '/filemanager/#/home/volume/' + volume.id }}" target="_blank" uib-tooltip="{{ 'volumes.openFileManagerActionTooltip' | tr }}"><i class="fas fa-folder"></i></a>
<button class="btn btn-xs btn-danger" ng-click="volumeRemove.show(volume)" uib-tooltip="{{ 'volumes.removeVolumeActionTooltip' | tr }}"><i class="far fa-trash-alt"></i></button>
</td>
</tr>
+14 -12
View File
@@ -88,8 +88,8 @@ angular.module('Application').controller('VolumesController', ['$scope', '$locat
remoteDir: '',
username: '',
password: '',
diskPath: {}, // { path, type }
customDiskPath: '',
ext4Disk: null, // { path, type }
xfsDisk: null, // { path, type }
user: '',
seal: false,
port: 22,
@@ -105,8 +105,8 @@ angular.module('Application').controller('VolumesController', ['$scope', '$locat
$scope.volumeAdd.remoteDir = '';
$scope.volumeAdd.username = '';
$scope.volumeAdd.password = '';
$scope.volumeAdd.diskPath = {};
$scope.volumeAdd.customDiskPath = '';
$scope.volumeAdd.ext4Disk = null;
$scope.volumeAdd.xfsDisk = null;
$scope.volumeAdd.user = '';
$scope.volumeAdd.seal = false;
$scope.volumeAdd.port = 22;
@@ -119,7 +119,8 @@ angular.module('Application').controller('VolumesController', ['$scope', '$locat
show: function () {
$scope.volumeAdd.reset();
$scope.blockDevices = [];
$scope.ext4BlockDevices = [];
$scope.xfsBlockDevices = [];
Client.getBlockDevices(function (error, result) {
if (error) console.error('Failed to list blockdevices:', error);
@@ -130,11 +131,8 @@ angular.module('Application').controller('VolumesController', ['$scope', '$locat
// amend label for UI
result.forEach(function (d) { d.label = d.path; });
// add custom fake option
result.push({ path: 'custom', label: 'Custom Path' });
$scope.blockDevices = result;
$scope.volumeAdd.diskPath = $scope.blockDevices[0];
$scope.ext4BlockDevices = result.filter(function (d) { return d.type === 'ext4'; });
$scope.xfsBlockDevices = result.filter(function (d) { return d.type === 'xfs'; });
$('#volumeAddModal').modal('show');
});
@@ -167,9 +165,13 @@ angular.module('Application').controller('VolumesController', ['$scope', '$locat
user: $scope.volumeAdd.user,
privateKey: $scope.volumeAdd.privateKey,
};
} else if ($scope.volumeAdd.mountType === 'ext4' || $scope.volumeAdd.mountType === 'xfs') {
} else if ($scope.volumeAdd.mountType === 'ext4') {
mountOptions = {
diskPath: $scope.volumeAdd.diskPath === 'custom' ? $scope.volumeAdd.customDiskPath : $scope.volumeAdd.diskPath
diskPath: $scope.volumeAdd.ext4Disk.path
};
} else if ($scope.volumeAdd.mountType === 'xfs') {
mountOptions = {
diskPath: $scope.volumeAdd.xfsDisk.path
};
} else if ($scope.volumeAdd.mountType === 'mountpoint' || $scope.volumeAdd.mountType === 'filesystem') {
mountOptions = {
+8 -4
View File
@@ -1,7 +1,11 @@
# Vue 3 + Vite
# Dashboard Filemanager
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Local development via:
## Recommended IDE Setup
```
VITE_API_ORIGIN=my.nebulon.space npm run dev
```
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
It requires an access token in `localStorage.token`.
The default local url looks like `http://localhost:5173/#/home/app/<appId>`
+192 -109
View File
@@ -8,24 +8,26 @@
"name": "my-vue-app",
"version": "0.0.0",
"dependencies": {
"@fontsource/noto-sans": "^5.0.3",
"combokeys": "^3.0.1",
"filesize": "^10.0.7",
"pankow": "^0.1.2",
"pankow": "^0.3.1",
"primeicons": "^6.0.1",
"primevue": "^3.27.0",
"primevue": "^3.29.2",
"superagent": "^8.0.9",
"vue": "^3.2.47",
"vue-router": "^4.1.6"
"vue": "^3.3.4",
"vue-i18n": "^9.2.2",
"vue-router": "^4.2.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.1",
"vite": "^4.3.3"
"@vitejs/plugin-vue": "^4.2.3",
"vite": "^4.3.9"
}
},
"node_modules/@babel/parser": {
"version": "7.20.15",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.20.15.tgz",
"integrity": "sha512-DI4a1oZuf8wC+oAJA9RW6ga3Zbe8RZFt7kD9i4qAspz3I/yHet1VvC3DiSy/fsUvv5pvJuNPh0LPOdCcqinDPg==",
"version": "7.22.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.5.tgz",
"integrity": "sha512-DFZMC9LJUG9PLOclRC32G63UXwzqS2koQC8dkx+PLdmt1xSePYpbT/NbsrJy8Q/muXz7o/h/d4A7Fuyixm559Q==",
"bin": {
"parser": "bin/babel-parser.js"
},
@@ -385,6 +387,73 @@
"node": ">=12"
}
},
"node_modules/@fontsource/noto-sans": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/@fontsource/noto-sans/-/noto-sans-5.0.3.tgz",
"integrity": "sha512-x6M139l0kSik4GcIquZk30yj6fjwBRzqdjcnqSAwCJ0AGk32TqZd1OysHrew31IzHUxUmPoq3YByO+4pDPRBxg=="
},
"node_modules/@intlify/core-base": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.2.2.tgz",
"integrity": "sha512-JjUpQtNfn+joMbrXvpR4hTF8iJQ2sEFzzK3KIESOx+f+uwIjgw20igOyaIdhfsVVBCds8ZM64MoeNSx+PHQMkA==",
"dependencies": {
"@intlify/devtools-if": "9.2.2",
"@intlify/message-compiler": "9.2.2",
"@intlify/shared": "9.2.2",
"@intlify/vue-devtools": "9.2.2"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/@intlify/devtools-if": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/@intlify/devtools-if/-/devtools-if-9.2.2.tgz",
"integrity": "sha512-4ttr/FNO29w+kBbU7HZ/U0Lzuh2cRDhP8UlWOtV9ERcjHzuyXVZmjyleESK6eVP60tGC9QtQW9yZE+JeRhDHkg==",
"dependencies": {
"@intlify/shared": "9.2.2"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/@intlify/message-compiler": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.2.2.tgz",
"integrity": "sha512-IUrQW7byAKN2fMBe8z6sK6riG1pue95e5jfokn8hA5Q3Bqy4MBJ5lJAofUsawQJYHeoPJ7svMDyBaVJ4d0GTtA==",
"dependencies": {
"@intlify/shared": "9.2.2",
"source-map": "0.6.1"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/@intlify/shared": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.2.2.tgz",
"integrity": "sha512-wRwTpsslgZS5HNyM7uDQYZtxnbI12aGiBZURX3BTR9RFIKKRWpllTsgzHWvj3HKm3Y2Sh5LPC1r0PDCKEhVn9Q==",
"engines": {
"node": ">= 14"
}
},
"node_modules/@intlify/vue-devtools": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/@intlify/vue-devtools/-/vue-devtools-9.2.2.tgz",
"integrity": "sha512-+dUyqyCHWHb/UcvY1MlIpO87munedm3Gn6E9WWYdWrMuYLcoIoOEVDWSS8xSwtlPU+kA+MEQTP6Q1iI/ocusJg==",
"dependencies": {
"@intlify/core-base": "9.2.2",
"@intlify/shared": "9.2.2"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.4.15",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
"integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg=="
},
"node_modules/@types/node": {
"version": "18.14.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.2.tgz",
@@ -394,9 +463,9 @@
"peer": true
},
"node_modules/@vitejs/plugin-vue": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.2.1.tgz",
"integrity": "sha512-ZTZjzo7bmxTRTkb8GSTwkPOYDIP7pwuyV+RV53c9PYUouwcbkIZIvWvNWlX2b1dYZqtOv7D6iUAnJLVNGcLrSw==",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.2.3.tgz",
"integrity": "sha512-R6JDUfiZbJA9cMiguQ7jxALsgiprjBeHL5ikpXfJCH62pPHtI+JdJ5xWj6Ev73yXSlYl86+blXn1kZHQ7uElxw==",
"dev": true,
"engines": {
"node": "^14.18.0 || >=16.0.0"
@@ -407,49 +476,49 @@
}
},
"node_modules/@vue/compiler-core": {
"version": "3.2.47",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.47.tgz",
"integrity": "sha512-p4D7FDnQb7+YJmO2iPEv0SQNeNzcbHdGByJDsT4lynf63AFkOTFN07HsiRSvjGo0QrxR/o3d0hUyNCUnBU2Tig==",
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.3.4.tgz",
"integrity": "sha512-cquyDNvZ6jTbf/+x+AgM2Arrp6G4Dzbb0R64jiG804HRMfRiFXWI6kqUVqZ6ZR0bQhIoQjB4+2bhNtVwndW15g==",
"dependencies": {
"@babel/parser": "^7.16.4",
"@vue/shared": "3.2.47",
"@babel/parser": "^7.21.3",
"@vue/shared": "3.3.4",
"estree-walker": "^2.0.2",
"source-map": "^0.6.1"
"source-map-js": "^1.0.2"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.2.47",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.47.tgz",
"integrity": "sha512-dBBnEHEPoftUiS03a4ggEig74J2YBZ2UIeyfpcRM2tavgMWo4bsEfgCGsu+uJIL/vax9S+JztH8NmQerUo7shQ==",
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.3.4.tgz",
"integrity": "sha512-wyM+OjOVpuUukIq6p5+nwHYtj9cFroz9cwkfmP9O1nzH68BenTTv0u7/ndggT8cIQlnBeOo6sUT/gvHcIkLA5w==",
"dependencies": {
"@vue/compiler-core": "3.2.47",
"@vue/shared": "3.2.47"
"@vue/compiler-core": "3.3.4",
"@vue/shared": "3.3.4"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.2.47",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.47.tgz",
"integrity": "sha512-rog05W+2IFfxjMcFw10tM9+f7i/+FFpZJJ5XHX72NP9eC2uRD+42M3pYcQqDXVYoj74kHMSEdQ/WmCjt8JFksQ==",
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.3.4.tgz",
"integrity": "sha512-6y/d8uw+5TkCuzBkgLS0v3lSM3hJDntFEiUORM11pQ/hKvkhSKZrXW6i69UyXlJQisJxuUEJKAWEqWbWsLeNKQ==",
"dependencies": {
"@babel/parser": "^7.16.4",
"@vue/compiler-core": "3.2.47",
"@vue/compiler-dom": "3.2.47",
"@vue/compiler-ssr": "3.2.47",
"@vue/reactivity-transform": "3.2.47",
"@vue/shared": "3.2.47",
"@babel/parser": "^7.20.15",
"@vue/compiler-core": "3.3.4",
"@vue/compiler-dom": "3.3.4",
"@vue/compiler-ssr": "3.3.4",
"@vue/reactivity-transform": "3.3.4",
"@vue/shared": "3.3.4",
"estree-walker": "^2.0.2",
"magic-string": "^0.25.7",
"magic-string": "^0.30.0",
"postcss": "^8.1.10",
"source-map": "^0.6.1"
"source-map-js": "^1.0.2"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.2.47",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.47.tgz",
"integrity": "sha512-wVXC+gszhulcMD8wpxMsqSOpvDZ6xKXSVWkf50Guf/S+28hTAXPDYRTbLQ3EDkOP5Xz/+SY37YiwDquKbJOgZw==",
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.3.4.tgz",
"integrity": "sha512-m0v6oKpup2nMSehwA6Uuu+j+wEwcy7QmwMkVNVfrV9P2qE5KshC6RwOCq8fjGS/Eak/uNb8AaWekfiXxbBB6gQ==",
"dependencies": {
"@vue/compiler-dom": "3.2.47",
"@vue/shared": "3.2.47"
"@vue/compiler-dom": "3.3.4",
"@vue/shared": "3.3.4"
}
},
"node_modules/@vue/devtools-api": {
@@ -458,60 +527,60 @@
"integrity": "sha512-o9KfBeaBmCKl10usN4crU53fYtC1r7jJwdGKjPT24t348rHxgfpZ0xL3Xm/gLUYnc0oTp8LAmrxOeLyu6tbk2Q=="
},
"node_modules/@vue/reactivity": {
"version": "3.2.47",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.2.47.tgz",
"integrity": "sha512-7khqQ/75oyyg+N/e+iwV6lpy1f5wq759NdlS1fpAhFXa8VeAIKGgk2E/C4VF59lx5b+Ezs5fpp/5WsRYXQiKxQ==",
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.3.4.tgz",
"integrity": "sha512-kLTDLwd0B1jG08NBF3R5rqULtv/f8x3rOFByTDz4J53ttIQEDmALqKqXY0J+XQeN0aV2FBxY8nJDf88yvOPAqQ==",
"dependencies": {
"@vue/shared": "3.2.47"
"@vue/shared": "3.3.4"
}
},
"node_modules/@vue/reactivity-transform": {
"version": "3.2.47",
"resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.47.tgz",
"integrity": "sha512-m8lGXw8rdnPVVIdIFhf0LeQ/ixyHkH5plYuS83yop5n7ggVJU+z5v0zecwEnX7fa7HNLBhh2qngJJkxpwEEmYA==",
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.3.4.tgz",
"integrity": "sha512-MXgwjako4nu5WFLAjpBnCj/ieqcjE2aJBINUNQzkZQfzIZA4xn+0fV1tIYBJvvva3N3OvKGofRLvQIwEQPpaXw==",
"dependencies": {
"@babel/parser": "^7.16.4",
"@vue/compiler-core": "3.2.47",
"@vue/shared": "3.2.47",
"@babel/parser": "^7.20.15",
"@vue/compiler-core": "3.3.4",
"@vue/shared": "3.3.4",
"estree-walker": "^2.0.2",
"magic-string": "^0.25.7"
"magic-string": "^0.30.0"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.2.47",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.2.47.tgz",
"integrity": "sha512-RZxbLQIRB/K0ev0K9FXhNbBzT32H9iRtYbaXb0ZIz2usLms/D55dJR2t6cIEUn6vyhS3ALNvNthI+Q95C+NOpA==",
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.3.4.tgz",
"integrity": "sha512-R+bqxMN6pWO7zGI4OMlmvePOdP2c93GsHFM/siJI7O2nxFRzj55pLwkpCedEY+bTMgp5miZ8CxfIZo3S+gFqvA==",
"dependencies": {
"@vue/reactivity": "3.2.47",
"@vue/shared": "3.2.47"
"@vue/reactivity": "3.3.4",
"@vue/shared": "3.3.4"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.2.47",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.2.47.tgz",
"integrity": "sha512-ArXrFTjS6TsDei4qwNvgrdmHtD930KgSKGhS5M+j8QxXrDJYLqYw4RRcDy1bz1m1wMmb6j+zGLifdVHtkXA7gA==",
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.3.4.tgz",
"integrity": "sha512-Aj5bTJ3u5sFsUckRghsNjVTtxZQ1OyMWCr5dZRAPijF/0Vy4xEoRCwLyHXcj4D0UFbJ4lbx3gPTgg06K/GnPnQ==",
"dependencies": {
"@vue/runtime-core": "3.2.47",
"@vue/shared": "3.2.47",
"csstype": "^2.6.8"
"@vue/runtime-core": "3.3.4",
"@vue/shared": "3.3.4",
"csstype": "^3.1.1"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.2.47",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.2.47.tgz",
"integrity": "sha512-dN9gc1i8EvmP9RCzvneONXsKfBRgqFeFZLurmHOveL7oH6HiFXJw5OGu294n1nHc/HMgTy6LulU/tv5/A7f/LA==",
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.3.4.tgz",
"integrity": "sha512-Q6jDDzR23ViIb67v+vM1Dqntu+HUexQcsWKhhQa4ARVzxOY2HbC7QRW/ggkDBd5BU+uM1sV6XOAP0b216o34JQ==",
"dependencies": {
"@vue/compiler-ssr": "3.2.47",
"@vue/shared": "3.2.47"
"@vue/compiler-ssr": "3.3.4",
"@vue/shared": "3.3.4"
},
"peerDependencies": {
"vue": "3.2.47"
"vue": "3.3.4"
}
},
"node_modules/@vue/shared": {
"version": "3.2.47",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.47.tgz",
"integrity": "sha512-BHGyyGN3Q97EZx0taMQ+OLNuZcW3d37ZEVmEAyeoA9ERdGvm9Irc/0Fua8SNyOtV1w6BS4q25wbMzJujO9HIfQ=="
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.3.4.tgz",
"integrity": "sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ=="
},
"node_modules/asap": {
"version": "2.0.6",
@@ -562,9 +631,9 @@
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw=="
},
"node_modules/csstype": {
"version": "2.6.21",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz",
"integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w=="
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
},
"node_modules/debug": {
"version": "4.3.4",
@@ -755,11 +824,14 @@
}
},
"node_modules/magic-string": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
"integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==",
"version": "0.30.0",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.0.tgz",
"integrity": "sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==",
"dependencies": {
"sourcemap-codec": "^1.4.8"
"@jridgewell/sourcemap-codec": "^1.4.13"
},
"engines": {
"node": ">=12"
}
},
"node_modules/methods": {
@@ -801,9 +873,9 @@
}
},
"node_modules/monaco-editor": {
"version": "0.37.1",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.37.1.tgz",
"integrity": "sha512-jLXEEYSbqMkT/FuJLBZAVWGuhIb4JNwHE9kPTorAVmsdZ4UzHAfgWxLsVtD7pLRFaOwYPhNG9nUCpmFL1t/dIg=="
"version": "0.39.0",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.39.0.tgz",
"integrity": "sha512-zhbZ2Nx93tLR8aJmL2zI1mhJpsl87HMebNBM6R8z4pLfs8pj604pIVIVwyF1TivcfNtIPpMXL+nb3DsBmE/x6Q=="
},
"node_modules/ms": {
"version": "2.1.2",
@@ -844,13 +916,13 @@
}
},
"node_modules/pankow": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/pankow/-/pankow-0.1.2.tgz",
"integrity": "sha512-JrVaqnIKzH762AAjxAyRMW4T/Fm0DhN90aT57Geukb2g8WE7qhBlSOgcFCFu+4U9SGUSy3mIRJaq1K1jdjFXiA==",
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/pankow/-/pankow-0.3.1.tgz",
"integrity": "sha512-/h5TuUI4M8XiCPXLqJjF95ZGIvq8KVt9N2ExHqzMo01YMkYVFFU4hyS7pudCtlS6u0+syDWUWL/qml8mIhJOrw==",
"dependencies": {
"filesize": "^10.0.7",
"monaco-editor": "^0.37.1",
"primevue": "^3.27.0",
"monaco-editor": "^0.39.0",
"primevue": "^3.29.2",
"superagent": "^8.0.9"
}
},
@@ -892,9 +964,9 @@
"integrity": "sha512-KDeO94CbWI4pKsPnYpA1FPjo79EsY9I+M8ywoPBSf9XMXoe/0crjbUK7jcQEDHuc0ZMRIZsxH3TYLv4TUtHmAA=="
},
"node_modules/primevue": {
"version": "3.27.0",
"resolved": "https://registry.npmjs.org/primevue/-/primevue-3.27.0.tgz",
"integrity": "sha512-oVJl8vLGNb6t5nXN41mnjR5V9Cc/eHVvmtRWiNgIC1db6OW3Qo7y2LaDEmXps/wdxX/FuJ7nuPHAZI4y8tvGyQ==",
"version": "3.29.2",
"resolved": "https://registry.npmjs.org/primevue/-/primevue-3.29.2.tgz",
"integrity": "sha512-zMk1w5AySLR6ipWH2fONKtDabHrvRZhZFI1OdoiBDdMhZpARmWuCXwE/ZFusUh5Te0RBEd3iVCiizjUkSRraRQ==",
"peerDependencies": {
"vue": "^3.0.0"
}
@@ -972,12 +1044,6 @@
"node": ">=0.10.0"
}
},
"node_modules/sourcemap-codec": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
"integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==",
"deprecated": "Please use @jridgewell/sourcemap-codec instead"
},
"node_modules/superagent": {
"version": "8.0.9",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-8.0.9.tgz",
@@ -999,9 +1065,9 @@
}
},
"node_modules/vite": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.3.3.tgz",
"integrity": "sha512-MwFlLBO4udZXd+VBcezo3u8mC77YQk+ik+fbc0GZWGgzfbPP+8Kf0fldhARqvSYmtIWoAJ5BXPClUbMTlqFxrA==",
"version": "4.3.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.3.9.tgz",
"integrity": "sha512-qsTNZjO9NoJNW7KnOrgYwczm0WctJ8m/yqYAMAK9Lxt4SoySUfS5S8ia9K7JHpa3KEeMfyF8LoJ3c5NeBJy6pg==",
"dev": true,
"dependencies": {
"esbuild": "^0.17.5",
@@ -1047,23 +1113,40 @@
}
},
"node_modules/vue": {
"version": "3.2.47",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.2.47.tgz",
"integrity": "sha512-60188y/9Dc9WVrAZeUVSDxRQOZ+z+y5nO2ts9jWXSTkMvayiWxCWOWtBQoYjLeccfXkiiPZWAHcV+WTPhkqJHQ==",
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.3.4.tgz",
"integrity": "sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==",
"dependencies": {
"@vue/compiler-dom": "3.2.47",
"@vue/compiler-sfc": "3.2.47",
"@vue/runtime-dom": "3.2.47",
"@vue/server-renderer": "3.2.47",
"@vue/shared": "3.2.47"
"@vue/compiler-dom": "3.3.4",
"@vue/compiler-sfc": "3.3.4",
"@vue/runtime-dom": "3.3.4",
"@vue/server-renderer": "3.3.4",
"@vue/shared": "3.3.4"
}
},
"node_modules/vue-i18n": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.2.2.tgz",
"integrity": "sha512-yswpwtj89rTBhegUAv9Mu37LNznyu3NpyLQmozF3i1hYOhwpG8RjcjIFIIfnu+2MDZJGSZPXaKWvnQA71Yv9TQ==",
"dependencies": {
"@intlify/core-base": "9.2.2",
"@intlify/shared": "9.2.2",
"@intlify/vue-devtools": "9.2.2",
"@vue/devtools-api": "^6.2.1"
},
"engines": {
"node": ">= 14"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue-router": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.1.6.tgz",
"integrity": "sha512-DYWYwsG6xNPmLq/FmZn8Ip+qrhFEzA14EI12MsMgVxvHFDYvlr4NXpVF5hrRH1wVcDP8fGi5F4rxuJSl8/r+EQ==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.2.2.tgz",
"integrity": "sha512-cChBPPmAflgBGmy3tBsjeoe3f3VOSG6naKyY5pjtrqLGbNEXdzCigFUHgBvp9e3ysAtFtEx7OLqcSDh/1Cq2TQ==",
"dependencies": {
"@vue/devtools-api": "^6.4.5"
"@vue/devtools-api": "^6.5.0"
},
"funding": {
"url": "https://github.com/sponsors/posva"
+8 -6
View File
@@ -9,17 +9,19 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource/noto-sans": "^5.0.3",
"combokeys": "^3.0.1",
"filesize": "^10.0.7",
"pankow": "^0.1.2",
"pankow": "^0.3.1",
"primeicons": "^6.0.1",
"primevue": "^3.27.0",
"primevue": "^3.29.2",
"superagent": "^8.0.9",
"vue": "^3.2.47",
"vue-router": "^4.1.6"
"vue": "^3.3.4",
"vue-i18n": "^9.2.2",
"vue-router": "^4.2.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.1",
"vite": "^4.3.3"
"@vitejs/plugin-vue": "^4.2.3",
"vite": "^4.3.9"
}
}
+8 -2
View File
@@ -1,6 +1,6 @@
<template>
<div class="preview-panel">
<img :src="item.previewUrl || item.icon" :alt="item.name" :class="{'shadow': item.previewUrl }"/>
<img :src="item.previewUrl || item.icon" :alt="item.name" :class="{'shadow': item.previewUrl }" @error="iconError($event)"/>
<p>{{ item.name }}</p>
</div>
</template>
@@ -10,7 +10,13 @@
export default {
name: 'PreviewPanel',
props: {
item: Object
item: Object,
fallbackIcon: String
},
methods: {
iconError(event) {
event.target.src = this.fallbackIcon;
}
}
};
+104
View File
@@ -0,0 +1,104 @@
// keep in sync with box/src/apps.js
const ISTATES = {
PENDING_INSTALL: 'pending_install',
PENDING_CLONE: 'pending_clone',
PENDING_CONFIGURE: 'pending_configure',
PENDING_UNINSTALL: 'pending_uninstall',
PENDING_RESTORE: 'pending_restore',
PENDING_IMPORT: 'pending_import',
PENDING_UPDATE: 'pending_update',
PENDING_BACKUP: 'pending_backup',
PENDING_RECREATE_CONTAINER: 'pending_recreate_container', // env change or addon change
PENDING_LOCATION_CHANGE: 'pending_location_change',
PENDING_DATA_DIR_MIGRATION: 'pending_data_dir_migration',
PENDING_RESIZE: 'pending_resize',
PENDING_DEBUG: 'pending_debug',
PENDING_START: 'pending_start',
PENDING_STOP: 'pending_stop',
PENDING_RESTART: 'pending_restart',
ERROR: 'error',
INSTALLED: 'installed'
};
const HSTATES = {
HEALTHY: 'healthy',
UNHEALTHY: 'unhealthy',
ERROR: 'error',
DEAD: 'dead'
};
const RSTATES ={
RUNNING: 'running',
STOPPED: 'stopped'
};
const ERROR = {
ACCESS_DENIED: 'Access Denied',
ALREADY_EXISTS: 'Already Exists',
BAD_FIELD: 'Bad Field',
COLLECTD_ERROR: 'Collectd Error',
CONFLICT: 'Conflict',
DATABASE_ERROR: 'Database Error',
DNS_ERROR: 'DNS Error',
DOCKER_ERROR: 'Docker Error',
EXTERNAL_ERROR: 'External Error',
FS_ERROR: 'FileSystem Error',
INTERNAL_ERROR: 'Internal Error',
LOGROTATE_ERROR: 'Logrotate Error',
NETWORK_ERROR: 'Network Error',
NOT_FOUND: 'Not found',
REVERSEPROXY_ERROR: 'ReverseProxy Error',
TASK_ERROR: 'Task Error',
UNKNOWN_ERROR: 'Unknown Error' // only used for portin,
};
const ROLES = {
OWNER: 'owner',
ADMIN: 'admin',
MAIL_MANAGER: 'mailmanager',
USER_MANAGER: 'usermanager',
USER: 'user'
};
// sync up with tasks.js
const TASK_TYPES = {
TASK_APP: 'app',
TASK_BACKUP: 'backup',
TASK_UPDATE: 'update',
TASK_CHECK_CERTS: 'checkCerts',
TASK_SETUP_DNS_AND_CERT: 'setupDnsAndCert',
TASK_CLEAN_BACKUPS: 'cleanBackups',
TASK_SYNC_EXTERNAL_LDAP: 'syncExternalLdap',
TASK_CHANGE_MAIL_LOCATION: 'changeMailLocation',
TASK_SYNC_DNS_RECORDS: 'syncDnsRecords',
TASK_UPDATE_DISK_USAGE: 'updateDiskUsage',
};
const APP_TYPES = {
APP: 'app', //default
LINK: 'link',
PROXIED: 'proxied'
};
// named exports
export {
APP_TYPES,
ERROR,
HSTATES,
ISTATES,
RSTATES,
ROLES,
TASK_TYPES
};
// default export
export default {
APP_TYPES,
ERROR,
HSTATES,
ISTATES,
RSTATES,
ROLES,
TASK_TYPES
};
+42
View File
@@ -1,12 +1,17 @@
import { createApp } from 'vue';
import { createI18n } from 'vue-i18n';
import './style.css';
import "@fontsource/noto-sans";
import 'primevue/resources/themes/saga-blue/theme.css';
import 'primevue/resources/primevue.min.css';
import 'primeicons/primeicons.css';
import PrimeVue from 'primevue/config';
import ConfirmationService from 'primevue/confirmationservice';
import superagent from 'superagent';
import { createRouter, createWebHashHistory } from 'vue-router';
@@ -26,8 +31,45 @@ const router = createRouter({
routes,
});
const translations = {};
const i18n = createI18n({
locale: 'en', // set locale
fallbackLocale: 'en', // set fallback locale
messages: translations
});
// https://vue-i18n.intlify.dev/guide/advanced/lazy.html
(async function loadLanguages() {
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
async function loadLanguage(lang) {
try {
const result = await superagent.get(`${API_ORIGIN}/translation/${lang}.json`);
// we do not deliver as application/json :/
translations[lang] = JSON.parse(result.text);
} catch (e) {
console.error(`Failed to load language file for ${lang}`, e);
}
}
await loadLanguage('en');
const locale = window.localStorage.NG_TRANSLATE_LANG_KEY;
if (locale && locale !== 'en') {
await loadLanguage(locale);
if (i18n.mode === 'legacy') {
i18n.global.locale = locale;
} else {
i18n.global.locale.value = locale;
}
}
})();
const app = createApp(App);
app.use(i18n);
app.use(router);
app.use(PrimeVue, { ripple: true });
app.use(ConfirmationService);
+8
View File
@@ -84,6 +84,14 @@ export function createDirectoryModel(origin, accessToken, api) {
.send({ action: 'chown', uid: uid, recursive: true })
.query({ access_token: accessToken });
},
async extract(path) {
await superagent.put(`${origin}/api/v1/${api}/files/${path}`)
.send({ action: 'extract' })
.query({ access_token: accessToken });
},
async download(path) {
window.open(`${origin}/api/v1/${api}/files/${path}?download=true&access_token=${accessToken}`);
},
async save(filePath, content) {
const file = new File([content], 'file');
await superagent.post(`${origin}/api/v1/${api}/files/${filePath}`)
+4
View File
@@ -40,3 +40,7 @@ a:hover, a:focus {
opacity: 0;
transform: scale(1.1);
}
.p-button {
font-family: Noto Sans !important;
}
-196
View File
@@ -1,196 +0,0 @@
import { filesize } from 'filesize';
function prettyDate(value) {
var date = new Date(value),
diff = (((new Date()).getTime() - date.getTime()) / 1000),
day_diff = Math.floor(diff / 86400);
if (isNaN(day_diff) || day_diff < 0)
return;
return day_diff === 0 && (
diff < 60 && 'just now' ||
diff < 120 && '1 min ago' ||
diff < 3600 && Math.floor( diff / 60 ) + ' min ago' ||
diff < 7200 && '1 hour ago' ||
diff < 86400 && Math.floor( diff / 3600 ) + ' hours ago') ||
day_diff === 1 && 'Yesterday' ||
day_diff < 7 && day_diff + ' days ago' ||
day_diff < 31 && Math.ceil( day_diff / 7 ) + ' weeks ago' ||
day_diff < 365 && Math.round( day_diff / 30 ) + ' months ago' ||
Math.round( day_diff / 365 ) + ' years ago';
}
function prettyLongDate(value) {
if (!value) return 'unkown';
var date = new Date(value);
return `${date.getDate()}.${date.getMonth() + 1}.${date.getFullYear()} ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`;
}
function prettyFileSize(value) {
if (typeof value !== 'number') return 'unkown';
return filesize(value);
}
function sanitize(path) {
path = '/' + path;
return path.replace(/\/+/g, '/');
}
function encode(path) {
return path.split('/').map(encodeURIComponent).join('/');
}
function decode(path) {
return path.split('/').map(decodeURIComponent).join('/');
}
// TODO create share links instead of using access token
function getDirectLink(entry) {
if (entry.share) {
let link = window.location.origin + '/api/v1/shares/' + entry.share.id + '?type=raw&path=' + encodeURIComponent(entry.filePath);
return link;
} else {
return window.location.origin + '/api/v1/files?type=raw&path=' + encodeURIComponent(entry.filePath);
}
}
// TODO the url might actually return a 412 in which case we have to keep reloading
function getPreviewUrl(entry) {
if (!entry.previewUrl) return '';
return entry.previewUrl;
}
function getShareLink(shareId) {
return window.location.origin + '/api/v1/shares/' + shareId + '?type=raw';
}
function download(entries, name) {
if (!entries.length) return;
if (entries.length === 1) {
if (entries[0].share) window.location.href = '/api/v1/shares/' + entries[0].share.id + '?type=download&path=' + encodeURIComponent(entries[0].filePath);
else window.location.href = '/api/v1/files?type=download&path=' + encodeURIComponent(entries[0].filePath);
return;
}
const params = new URLSearchParams();
// be a bit smart about the archive name and folder tree
const folderPath = entries[0].filePath.slice(0, -entries[0].fileName.length);
const archiveName = name || folderPath.slice(folderPath.slice(0, -1).lastIndexOf('/')+1).slice(0, -1);
params.append('name', archiveName);
params.append('skipPath', folderPath);
params.append('entries', JSON.stringify(entries.map(function (entry) {
return {
filePath: entry.filePath,
shareId: entry.share ? entry.share.id : undefined
};
})));
window.location.href = '/api/v1/download?' + params.toString();
}
function getFileTypeGroup(entry) {
return entry.mimeType.split('/')[0];
}
// simple extension detection, does not work with double extension like .tar.gz
function getExtension(entry) {
if (entry.isFile) return entry.fileName.slice(entry.fileName.lastIndexOf('.') + 1);
return '';
}
function copyToClipboard(value) {
var elem = document.createElement('input');
elem.value = value;
document.body.append(elem);
elem.select();
document.execCommand('copy');
elem.remove();
}
function clearSelection() {
if(document.selection && document.selection.empty) {
document.selection.empty();
} else if(window.getSelection) {
var sel = window.getSelection();
sel.removeAllRanges();
}
}
function urlSearchQuery() {
return decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
}
// those paths contain the internal type and path reference eg. shares/:shareId/folder/filename or files/folder/filename
function parseResourcePath(resourcePath) {
var result = {
type: '',
path: '',
shareId: '',
apiPath: '',
resourcePath: ''
};
if (resourcePath.indexOf('files/') === 0) {
result.type = 'files';
result.path = resourcePath.slice('files'.length) || '/';
result.apiPath = '/api/v1/files';
result.resourcePath = result.type + result.path;
} else if (resourcePath.indexOf('shares/') === 0) {
result.type = 'shares';
result.shareId = resourcePath.split('/')[1];
result.path = resourcePath.slice((result.type + '/' + result.shareId).length) || '/';
result.apiPath = '/api/v1/shares/' + result.shareId;
// without shareId we show the root (share listing)
result.resourcePath = result.type + '/' + (result.shareId ? (result.shareId + result.path) : '');
} else {
console.error('Unknown resource path', resourcePath);
}
return result;
}
function getEntryIdentifier(entry) {
return (entry.share ? (entry.share.id + '/') : '') + entry.filePath;
}
function entryListSort(list, prop, desc) {
var tmp = list.sort(function (a, b) {
var av = a[prop];
var bv = b[prop];
if (typeof av === 'string') return (av.toUpperCase() < bv.toUpperCase()) ? -1 : 1;
else return (av < bv) ? -1 : 1;
});
if (desc) return tmp;
return tmp.reverse();
}
export {
getDirectLink,
getPreviewUrl,
getShareLink,
getFileTypeGroup,
prettyDate,
prettyLongDate,
prettyFileSize,
sanitize,
encode,
decode,
download,
getExtension,
copyToClipboard,
clearSelection,
urlSearchQuery,
parseResourcePath,
getEntryIdentifier,
entryListSort
};
+154 -96
View File
@@ -1,26 +1,36 @@
<template>
<MainLayout>
<template #dialogs>
<Dialog v-model:visible="fatalError" modal header="Error" :closable="false" :closeOnEscape="false">
<p>{{ fatalError }}</p>
</Dialog>
<Dialog v-model:visible="extractInProgress" modal :header="$t('filemanager.extractionInProgress')" :closable="false" :closeOnEscape="false">
<div style="text-align: center;">
<ProgressSpinner style="width: 50px; height: 50px"/>
</div>
</Dialog>
<!-- have to use v-model instead of : bind - https://github.com/primefaces/primevue/issues/815 -->
<Dialog v-model:visible="newFileDialog.visible" modal :style="{ width: '50vw' }" @show="onDialogShow('newFileDialogNameInput')">
<template #header>
<label class="dialog-header" for="newFileDialogNameInput">New file name</label>
<label class="dialog-header" for="newFileDialogNameInput">{{ $t('filemanager.newFileDialog.title') }}</label>
</template>
<template #default>
<form @submit="onNewFileDialogSubmit" @submit.prevent>
<InputText class="dialog-single-input" id="newFileDialogNameInput" v-model="newFileDialog.name" :disabled="newFileDialog.busy" required/>
<Button class="dialog-single-input-submit" type="submit" label="Create" :loading="newFileDialog.busy" :disabled="newFileDialog.busy || !newFileDialog.name"/>
<Button class="dialog-single-input-submit" type="submit" :label="$t('filemanager.newFileDialog.create')" :loading="newFileDialog.busy" :disabled="newFileDialog.busy || !newFileDialog.name"/>
</form>
</template>
</Dialog>
<Dialog v-model:visible="newFolderDialog.visible" modal :style="{ width: '50vw' }" @show="onDialogShow('newFolderDialogNameInput')">
<template #header>
<label class="dialog-header" for="newFolderDialogNameInput">New folder name</label>
<label class="dialog-header" for="newFolderDialogNameInput">{{ $t('filemanager.newDirectoryDialog.title') }}</label>
</template>
<template #default>
<form @submit="onNewFolderDialogSubmit" @submit.prevent>
<InputText class="dialog-single-input" id="newFolderDialogNameInput" v-model="newFolderDialog.name" :disabled="newFolderDialog.busy" required/>
<Button class="dialog-single-input-submit" type="submit" label="Create" :loading="newFolderDialog.busy" :disabled="newFolderDialog.busy || !newFolderDialog.name"/>
<Button class="dialog-single-input-submit" type="submit" :label="$t('filemanager.newFileDialog.create')" :loading="newFolderDialog.busy" :disabled="newFolderDialog.busy || !newFolderDialog.name"/>
</form>
</template>
</Dialog>
@@ -28,15 +38,17 @@
<template #header>
<TopBar class="navbar">
<template #left>
<Button icon="pi pi-chevron-left" @click="onGoUp()" text :disabled="cwd === '/'"/>
<span style="margin-left: 20px;">{{ cwd }}</span>
<Button icon="pi pi-refresh" @click="onRefresh()" text :loading="busyRefresh" style="margin-right: 5px;"/>
<PathBreadcrumbs :path="cwd" :activate-handler="onActivateBreadcrumb"/>
</template>
<template #right>
<Button type="button" label="New" icon="pi pi-plus" @click="onCreateMenu" aria-haspopup="true" aria-controls="create_menu" style="margin-right: 10px" />
<Button type="button" :label="$t('filemanager.toolbar.new')" icon="pi pi-plus" @click="onCreateMenu" aria-haspopup="true" aria-controls="create_menu" style="margin-right: 5px" />
<Menu ref="createMenu" id="create_menu" :model="createMenuModel" :popup="true" />
<Button type="button" label="Upload" icon="pi pi-upload" @click="onUploadMenu" aria-haspopup="true" aria-controls="upload_menu" style="margin-right: 10px" />
<Button type="button" :label="$t('filemanager.toolbar.upload')" icon="pi pi-upload" @click="onUploadMenu" aria-haspopup="true" aria-controls="upload_menu" style="margin-right: 5px" />
<Menu ref="uploadMenu" id="upload_menu" :model="uploadMenuModel" :popup="true" />
<Dropdown v-model="activeResource" filter :options="resourcesDropdownModel" optionLabel="label" optionGroupLabel="label" optionGroupChildren="items" dataKey="id" @change="onAppChange" placeholder="Select an App or Volume" style="margin-right: 10px" />
<a class="p-button p-button-secondary" style="margin-left: 20px; margin-right: 5px;" :href="'/logs.html?appId=' + resourceId" target="_blank" v-show="resourceType === 'app'"><span class="p-button-icon p-button-icon-left pi pi-align-left"></span> {{ $t('filemanager.toolbar.openLogs') }}</a>
<a class="p-button p-button-secondary" style="margin-right: 5px;" :href="'/terminal.html?id=' + resourceId" target="_blank" v-show="resourceType === 'app'"><span class="p-button-icon p-button-icon-left pi pi-desktop"></span> {{ $t('filemanager.toolbar.openTerminal') }}</a>
<Button type="button" :label="$t('filemanager.toolbar.restartApp')" severity="secondary" icon="pi pi-sync" @click="onRestartApp" :loading="busyRestart" v-show="resourceType === 'app'"/>
</template>
</TopBar>
</template>
@@ -55,6 +67,8 @@
:copy-handler="copyHandler"
:cut-handler="cutHandler"
:paste-handler="pasteHandler"
:download-handler="downloadHandler"
:extract-handler="extractHandler"
:new-file-handler="onNewFile"
:new-folder-handler="onNewFolder"
:upload-file-handler="onUploadFile"
@@ -63,10 +77,12 @@
:items="items"
:clipboard="clipboard"
:owners-model="ownersModel"
:fallback-icon="fallbackIcon"
:tr="$t"
/>
</div>
<div class="main-view-col" style="max-width: 300px;">
<PreviewPanel :item="activeItem || activeDirectoryItem"/>
<PreviewPanel :item="activeItem || activeDirectoryItem" :fallback-icon="fallbackIcon"/>
</div>
</div>
</template>
@@ -75,6 +91,7 @@
ref="fileUploader"
:upload-handler="uploadHandler"
@finished="onUploadFinished"
:tr="$t"
/>
<BottomBar />
</template>
@@ -87,14 +104,16 @@ import superagent from 'superagent';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import Dropdown from 'primevue/dropdown';
import InputText from 'primevue/inputtext';
import Menu from 'primevue/menu';
import ProgressSpinner from 'primevue/progressspinner';
import { useConfirm } from 'primevue/useconfirm';
import { DirectoryView, TopBar, BottomBar, MainLayout, FileUploader } from 'pankow';
import { sanitize, buildFilePath } from 'pankow/utils';
import { DirectoryView, TopBar, PathBreadcrumbs, BottomBar, MainLayout, FileUploader } from 'pankow';
import { sanitize, buildFilePath, sleep } from 'pankow/utils';
import { ISTATES } from '../constants.js';
import PreviewPanel from '../components/PreviewPanel.vue';
import { createDirectoryModel } from '../models/DirectoryModel.js';
@@ -109,17 +128,23 @@ export default {
Button,
Dialog,
DirectoryView,
Dropdown,
FileUploader,
InputText,
MainLayout,
Menu,
PathBreadcrumbs,
PreviewPanel,
ProgressSpinner,
TopBar
},
data() {
return {
fallbackIcon: '/mime-types/none.svg',
cwd: '/',
busyRefresh: false,
busyRestart: false,
fatalError: false,
extractInProgress: false,
activeItem: null,
activeDirectoryItem: {},
items: [],
@@ -130,12 +155,9 @@ export default {
},
accessToken: localStorage.token,
apiOrigin: API_ORIGIN || '',
apps: [],
volumes: [],
resources: [],
resourcesDropdownModel: [],
selectedAppId: '',
activeResource: null,
title: 'Cloudron',
resourceType: '',
resourceId: '',
visible: true,
newFileDialog: {
visible: false,
@@ -162,20 +184,20 @@ export default {
}],
// contextMenuModel will have activeItem attached if any command() is called
createMenuModel: [{
label: 'File',
label: () => this.$t('filemanager.toolbar.newFile'),
icon: 'pi pi-file',
command: this.onNewFile
}, {
label: 'Folder',
label: () => this.$t('filemanager.toolbar.newFolder'),
icon: 'pi pi-folder',
command: this.onNewFolder
}],
uploadMenuModel: [{
label: 'File',
label: () => this.$t('filemanager.toolbar.uploadFile'),
icon: 'pi pi-file',
command: this.onUploadFile
}, {
label: 'Folder',
label: () => this.$t('filemanager.toolbar.newFolder'),
icon: 'pi pi-folder',
command: this.onUploadFolder
}]
@@ -183,7 +205,7 @@ export default {
},
watch: {
cwd(newCwd, oldCwd) {
if (this.activeResource) this.$router.push(`/home/${this.activeResource.type}/${this.activeResource.id}${this.cwd}`);
if (this.resourceType && this.resourceId) this.$router.push(`/home/${this.resourceType}/${this.resourceId}${this.cwd}`);
this.loadCwd();
}
},
@@ -238,8 +260,13 @@ export default {
this.activeItem = items[0] || null;
this.selectedItems = items;
},
onGoUp() {
this.cwd = sanitize(this.cwd.split('/').slice(0, -1).join('/'));
onActivateBreadcrumb(path) {
this.cwd = sanitize(path);
},
async onRefresh() {
this.busyRefresh = true;
await this.loadCwd();
setTimeout(() => { this.busyRefresh = false; }, 500);
},
async onDrop(targetFolder, dataTransfer) {
const fullTargetFolder = sanitize(this.cwd + '/' + targetFolder);
@@ -276,9 +303,10 @@ export default {
},
onItemActivated(item) {
if (!item) return;
if (item.mimeType === 'inode/symlink') return;
if (item.type === 'directory') this.cwd = sanitize(this.cwd + '/' + item.name);
else this.$router.push(`/viewer/${this.activeResource.type}/${this.activeResource.id}${sanitize(this.cwd + '/' + item.name)}`);
else this.$router.push(`/viewer/${this.resourceType}/${this.resourceId}${sanitize(this.cwd + '/' + item.name)}`);
},
async deleteHandler(files) {
if (!files) return;
@@ -327,6 +355,15 @@ export default {
this.clipboard = {};
await this.loadCwd();
},
async downloadHandler(file) {
await this.directoryModel.download(buildFilePath(this.cwd, file.name));
},
async extractHandler(file) {
this.extractInProgress = true;
await this.directoryModel.extract(buildFilePath(this.cwd, file.name));
await this.loadCwd();
this.extractInProgress = false;
},
async uploadHandler(targetDir, file, progressHandler) {
await this.directoryModel.upload(targetDir, file, progressHandler);
await this.loadCwd();
@@ -335,8 +372,8 @@ export default {
this.items = await this.directoryModel.listFiles(this.cwd);
const tmp = this.cwd.split('/').slice(1);
let name = this.activeResource.fqdn;
if (tmp.length > 1) name = tmp[tmp.length-2];
let name = this.title;
if (tmp.length >= 1 && tmp[tmp.length-1]) name = tmp[tmp.length-1];
this.activeDirectoryItem = {
id: name,
@@ -346,87 +383,101 @@ export default {
icon: `${BASE_URL}mime-types/inode-directory.svg`
};
},
async loadResource(resource) {
this.activeResource = resource;
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, resource.type === 'volume' ? `volumes/${resource.id}` : `apps/${resource.id}`);
this.loadCwd();
async onRestartApp() {
if (this.resourceType !== 'app') return;
this.busyRestart = true;
let error, result;
try {
result = await superagent.post(`${this.apiOrigin}/api/v1/apps/${this.resourceId}/restart`).query({ access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 202) {
console.error(`Failed to restart app ${this.resourceId}`, error || result.statusCode);
return;
}
while(true) {
let error, result;
try {
result = await superagent.get(`${this.apiOrigin}/api/v1/apps/${this.resourceId}`).query({ access_token: this.accessToken });
} catch (e) {
error = e;
}
if (result && result.statusCode === 200 && result.body.installationState === ISTATES.INSTALLED) break;
await sleep(2000);
}
this.busyRestart = false;
}
},
async mounted() {
useConfirm();
// load all apps
let error, result;
try {
result = await superagent.get(`${this.apiOrigin}/api/v1/apps`).query({ access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 200) {
console.error('Failed to list apps', error || result.statusCode);
this.apps = [];
} else {
this.apps = result.body ? result.body.apps.filter(a => !!a.manifest.addons.localstorage) : [];
}
this.apps.forEach(function (a) { a.type = 'app'; a.label = a.fqdn; });
// load all volumes
try {
result = await superagent.get(`${this.apiOrigin}/api/v1/volumes`).query({ access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 200) {
console.error('Failed to list volumes', error || result.statusCode);
this.volumes = [];
} else {
this.volumes = result.body ? result.body.volumes : [];
}
this.volumes.forEach(function (a) { a.type = 'volume'; a.label = a.name; });
this.resources = this.apps.concat(this.volumes);
this.resourcesDropdownModel = [{
label: 'Apps',
items: this.apps
}, {
label: 'Volumes',
items: this.volumes
}];
const type = this.$route.params.type || 'app';
const resourceId = this.$route.params.resourceId;
const cwd = this.$route.params.cwd;
if (type === 'volume') {
this.activeResource = this.volumes.find(a => a.id === resourceId);
if (!this.activeResource) this.activeResource = this.volumes[0];
if (!this.activeResource) return console.error('Unable to find volumes', resourceId);
} else if (type === 'app') {
this.activeResource = this.apps.find(a => a.id === resourceId);
if (!this.activeResource) this.activeResource = this.apps[0];
if (!this.activeResource) return console.error('Unable to find app', resourceId);
if (type === 'app') {
let error, result;
try {
result = await superagent.get(`${this.apiOrigin}/api/v1/apps/${resourceId}`).query({ access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 200) {
console.error(`Invalid resource ${type} ${resourceId}`, error || result.statusCode);
this.fatalError = `Invalid resource ${type} ${resourceId}`;
return;
}
this.title = result.body.label || result.body.fqdn;
} else if (type === 'volume') {
let error, result;
try {
result = await superagent.get(`${this.apiOrigin}/api/v1/volumes/${resourceId}`).query({ access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 200) {
console.error(`Invalid resource ${type} ${resourceId}`, error || result.statusCode);
this.fatalError = `Invalid resource ${type} ${resourceId}`;
return;
}
this.title = result.body.name;
} else {
this.activeResource = this.apps[0];
}
if (!this.activeResource) {
console.error('Not able to load apps or volumes. Cannot continue');
this.fatalError = `Unsupported type ${type}`;
return;
}
this.cwd = sanitize('/' + (this.$route.params.cwd ? this.$route.params.cwd.join('/') : '/'));
window.document.title = this.title + ' - File Manager';
this.loadResource(this.activeResource);
this.cwd = sanitize('/' + (cwd ? cwd.join('/') : '/'));
this.resourceType = type;
this.resourceId = resourceId;
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, type === 'volume' ? `volumes/${resourceId}` : `apps/${resourceId}`);
this.loadCwd();
this.$watch(() => this.$route.params, (toParams, previousParams) => {
if (toParams.type === 'volume') {
this.activeResource = this.volumes.find(a => a.id === toParams.resourceId);
} else if (toParams.type === 'app') {
this.activeResource = this.apps.find(a => a.id === toParams.resourceId);
} else {
console.error(`Unknown type ${toParams.type}`);
if (toParams.type !== 'app' && toParams.type !== 'volume') {
this.fatalError = `Unknown type ${toParams.type}`;
return;
}
if ((toParams.type !== this.resourceType) || (toParams.resourceId !== this.resourceId)) {
this.resourceType = toParams.type;
this.resourceId = toParams.resourceId;
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, toParams.type === 'volume' ? `volumes/${toParams.resourceId}` : `apps/${toParams.resourceId}`);
}
this.cwd = toParams.cwd ? `/${toParams.cwd.join('/')}` : '/';
@@ -467,4 +518,11 @@ export default {
margin-top: 5px;
}
a.p-button:hover {
text-decoration: none;
background: #0d89ec;
color: #ffffff;
border-color: #0d89ec;
}
</style>
+2
View File
@@ -4,6 +4,7 @@
v-show="active === 'textEditor'"
:save-handler="saveHandler"
@close="onClose"
:tr="$t"
/>
<ImageViewer ref="imageViewer" v-show="active === 'imageViewer'" @close="onClose"/>
</div>
@@ -67,6 +68,7 @@ export default {
} else {
console.warn(`no editor or viewer found for ${this.item.mimeType}`, this.item);
this.active = '';
window.location.replace(this.directoryModel.getFileUrl(this.filePath));
}
}
};
+341 -385
View File
File diff suppressed because it is too large Load Diff
+15 -15
View File
@@ -17,9 +17,9 @@
},
"dependencies": {
"@google-cloud/dns": "^3.0.2",
"@google-cloud/storage": "^6.9.4",
"@google-cloud/storage": "^6.10.1",
"async": "^3.2.4",
"aws-sdk": "^2.1343.0",
"aws-sdk": "^2.1377.0",
"basic-auth": "^2.0.1",
"body-parser": "^1.20.2",
"cloudron-manifestformat": "^5.20.0",
@@ -36,45 +36,45 @@
"ejs": "^3.1.9",
"express": "^4.18.2",
"ipaddr.js": "^2.0.1",
"jose": "^4.13.1",
"jsdom": "^21.1.1",
"jose": "^4.14.4",
"jsdom": "^22.0.0",
"jsonwebtoken": "^9.0.0",
"ldapjs": "^2.3.3",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"moment-timezone": "^0.5.42",
"moment-timezone": "^0.5.43",
"morgan": "^1.10.0",
"multiparty": "^4.2.3",
"mysql": "^2.18.1",
"nodemailer": "^6.9.1",
"nodemailer": "^6.9.2",
"nsyslog-parser": "^0.10.1",
"oidc-provider": "^7.14.3",
"qrcode": "^1.5.1",
"oidc-provider": "^8.2.1",
"qrcode": "^1.5.3",
"readdirp": "^3.6.0",
"safetydance": "^2.2.0",
"semver": "^7.3.8",
"semver": "^7.5.1",
"speakeasy": "^2.0.0",
"superagent": "^8.0.9",
"tar-fs": "github:cloudron-io/tar-fs#ignore_stat_error",
"tldjs": "^2.3.1",
"ua-parser-js": "^1.0.34",
"ua-parser-js": "^1.0.35",
"underscore": "^1.13.6",
"uuid": "^9.0.0",
"validator": "^13.9.0",
"ws": "^8.13.0",
"xml2js": "^0.4.23"
"xml2js": "^0.5.0"
},
"devDependencies": {
"commander": "^10.0.0",
"commander": "^10.0.1",
"easy-table": "^1.2.0",
"eslint": "^8.36.0",
"eslint": "^8.40.0",
"expect.js": "*",
"hock": "^1.4.1",
"js2xmlparser": "^5.0.0",
"mocha": "^10.2.0",
"mock-aws-s3": "git+https://github.com/cloudron-io/mock-aws-s3.git",
"nock": "^13.3.0",
"ssh2": "^1.11.0",
"nock": "^13.3.1",
"ssh2": "^1.13.0",
"yesno": "^0.4.0"
},
"scripts": {
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
set -eu -o pipefail
# This script tails common logs
tail -f /home/yellowtent/platformdata/logs/box.log
+8 -8
View File
@@ -107,11 +107,14 @@ echo -n "Generating Cloudron Support stats..."
# clear file
rm -rf $OUT
echo -e $LINE"DASHBOARD DOMAIN"$LINE >> $OUT
mysql -NB -uroot -ppassword -e "SELECT value FROM box.settings WHERE name='admin_fqdn'" &>> $OUT 2>/dev/null || true
echo -e $LINE"Linux"$LINE >> $OUT
uname -nar &>> $OUT
echo -e $LINE"PROVIDER"$LINE >> $OUT
cat /etc/cloudron/PROVIDER &>> $OUT || true
echo -e $LINE"Ubuntu"$LINE >> $OUT
lsb_release -a &>> $OUT
echo -e $LINE"Dashboard Domain"$LINE >> $OUT
mysql -NB -uroot -ppassword -e "SELECT value FROM box.settings WHERE name='admin_fqdn'" &>> $OUT 2>/dev/null || true
echo -e $LINE"Docker container"$LINE >> $OUT
if ! timeout --kill-after 10s 15s docker ps -a &>> $OUT 2>&1; then
@@ -151,7 +154,4 @@ echo -n "Uploading information..."
paste_key=$(curl -X POST ${PASTEBIN}/documents --silent --data-binary "@$OUT" | python3 -c "import sys, json; print(json.load(sys.stdin)['key'])")
echo "Done"
echo ""
echo "Please email the following link to support@cloudron.io :"
echo ""
echo "${PASTEBIN}/${paste_key}"
echo -e "\nPlease email the following link to support@cloudron.io : ${PASTEBIN}/${paste_key}"
+3 -3
View File
@@ -36,8 +36,8 @@ if ! $(cd "${SOURCE_DIR}" && git diff --exit-code >/dev/null); then
exit 1
fi
if [[ "$(node --version)" != "v16.18.1" ]]; then
echo "This script requires node 16.18.1"
if [[ "$(node --version)" != "v18.16.0" ]]; then
echo "This script requires node 18.16.0"
exit 1
fi
@@ -62,7 +62,7 @@ mv "${bundle_dir}/filemanager/dist" "${bundle_dir}/dashboard/dist/filemanager"
rm -rf "${bundle_dir}/filemanager"
echo "==> Installing toplevel node modules"
(cd "${bundle_dir}" && npm install --omit=dev --no-optional)
(cd "${bundle_dir}" && npm install --omit=dev --omit=optional)
echo "==> Create final tarball"
(cd "${bundle_dir}" && tar czf "${bundle_file}" .)
+9 -24
View File
@@ -72,8 +72,8 @@ readonly is_update=$(systemctl is-active -q box && echo "yes" || echo "no")
log "Updating from $(cat $box_src_dir/VERSION 2>/dev/null) to $(cat $box_src_tmp_dir/VERSION 2>/dev/null)"
# https://docs.docker.com/engine/installation/linux/ubuntulinux/
readonly docker_version=20.10.21
readonly containerd_version=1.6.10-1
readonly docker_version="23.0.6"
readonly containerd_version="1.6.21-1"
if ! which docker 2>/dev/null || [[ $(docker version --format {{.Client.Version}}) != "${docker_version}" ]]; then
log "installing/updating docker"
@@ -83,8 +83,8 @@ if ! which docker 2>/dev/null || [[ $(docker version --format {{.Client.Version}
# there are 3 packages for docker - containerd, CLI and the daemon (https://download.docker.com/linux/ubuntu/dists/jammy/pool/stable/amd64/)
$curl -sL "https://download.docker.com/linux/ubuntu/dists/${ubuntu_codename}/pool/stable/amd64/containerd.io_${containerd_version}_amd64.deb" -o /tmp/containerd.deb
$curl -sL "https://download.docker.com/linux/ubuntu/dists/${ubuntu_codename}/pool/stable/amd64/docker-ce-cli_${docker_version}~3-0~ubuntu-${ubuntu_codename}_amd64.deb" -o /tmp/docker-ce-cli.deb
$curl -sL "https://download.docker.com/linux/ubuntu/dists/${ubuntu_codename}/pool/stable/amd64/docker-ce_${docker_version}~3-0~ubuntu-${ubuntu_codename}_amd64.deb" -o /tmp/docker.deb
$curl -sL "https://download.docker.com/linux/ubuntu/dists/${ubuntu_codename}/pool/stable/amd64/docker-ce-cli_${docker_version}-1~ubuntu.${ubuntu_version}~${ubuntu_codename}_amd64.deb" -o /tmp/docker-ce-cli.deb
$curl -sL "https://download.docker.com/linux/ubuntu/dists/${ubuntu_codename}/pool/stable/amd64/docker-ce_${docker_version}-1~ubuntu.${ubuntu_version}~${ubuntu_codename}_amd64.deb" -o /tmp/docker.deb
log "installing docker"
prepare_apt_once
@@ -115,7 +115,8 @@ elif [[ "${ubuntu_version}" == "18.04" ]]; then
fi
fi
readonly node_version=16.18.1
readonly old_node_version=16.18.1
readonly node_version=18.16.0
if ! which node 2>/dev/null || [[ "$(node --version)" != "v${node_version}" ]]; then
log "installing/updating node ${node_version}"
mkdir -p /usr/local/node-${node_version}
@@ -124,7 +125,7 @@ if ! which node 2>/dev/null || [[ "$(node --version)" != "v${node_version}" ]];
rm /tmp/node.tar.gz
ln -sf /usr/local/node-${node_version}/bin/node /usr/bin/node
ln -sf /usr/local/node-${node_version}/bin/npm /usr/bin/npm
rm -rf /usr/local/node-16.14.2
rm -rf /usr/local/node-${old_node_version}
fi
# obsolete module
@@ -151,33 +152,17 @@ log "downloading new addon images"
images=$(node -e "let i = require('${box_src_tmp_dir}/src/infra_version.js'); console.log(i.baseImages.map(function (x) { return x.tag; }).join(' '), Object.keys(i.images).map(function (x) { return i.images[x].tag; }).join(' '));")
log "\tPulling docker images: ${images}"
if ! curl --fail --connect-timeout 10 --max-time 10 https://ipv4.api.cloudron.io/api/v1/helper/public_ip; then
docker_registry=registry.ipv6.docker.com
else
docker_registry=registry-1.docker.io
fi
log "\tUsing ${docker_registry} as the docker registry"
for image in ${images}; do
while ! docker pull "${docker_registry}/${image}"; do # this pulls the image using the sha256
while ! docker pull "registry.docker.com/${image}"; do # this pulls the image using the sha256
log "Could not pull ${image}"
sleep 5
done
while ! docker pull "${docker_registry}/${image%@sha256:*}"; do # this will tag the image for readability
while ! docker pull "registry.docker.com/${image%@sha256:*}"; do # this will tag the image for readability
log "Could not pull ${image%@sha256:*}"
sleep 5
done
done
log "creating cloudron-support user"
if ! id cloudron-support 2>/dev/null; then
useradd --system --comment "Cloudron Support (support@cloudron.io)" --create-home --no-user-group --shell /bin/bash cloudron-support
fi
log "locking the ${user} account"
usermod --shell /usr/sbin/nologin "${user}"
passwd --lock "${user}"
if [[ "${is_update}" == "yes" ]]; then
log "stop box service for update"
${box_src_dir}/setup/stop.sh
+2 -1
View File
@@ -24,6 +24,7 @@ readonly ubuntu_version=$(lsb_release -rs)
cp -f "${script_dir}/../scripts/cloudron-support" /usr/bin/cloudron-support
cp -f "${script_dir}/../scripts/cloudron-translation-update" /usr/bin/cloudron-translation-update
cp -f "${script_dir}/../scripts/cloudron-logs" /usr/bin/cloudron-logs
# this needs to match the cloudron/base:2.0.0 gid
if ! getent group media; then
@@ -66,7 +67,6 @@ mkdir -p "${PLATFORM_DATA_DIR}/backup"
mkdir -p "${PLATFORM_DATA_DIR}/logs/backup" \
"${PLATFORM_DATA_DIR}/logs/updater" \
"${PLATFORM_DATA_DIR}/logs/tasks" \
"${PLATFORM_DATA_DIR}/logs/crash" \
"${PLATFORM_DATA_DIR}/logs/collectd"
mkdir -p "${PLATFORM_DATA_DIR}/update"
mkdir -p "${PLATFORM_DATA_DIR}/sftp/ssh" # sftp keys
@@ -166,6 +166,7 @@ mkdir -p "${PLATFORM_DATA_DIR}/nginx/applications/dashboard"
mkdir -p "${PLATFORM_DATA_DIR}/nginx/cert"
cp "${script_dir}/start/nginx/nginx.conf" "${PLATFORM_DATA_DIR}/nginx/nginx.conf"
cp "${script_dir}/start/nginx/mime.types" "${PLATFORM_DATA_DIR}/nginx/mime.types"
touch "${PLATFORM_DATA_DIR}/nginx/trusted.ips"
if ! grep -q "^Restart=" /etc/systemd/system/multi-user.target.wants/nginx.service; then
# default nginx service file does not restart on crash
echo -e "\n[Service]\nRestart=always\n" >> /etc/systemd/system/multi-user.target.wants/nginx.service
+1 -2
View File
@@ -1,4 +1,4 @@
# logrotate config for app, crash, addon and task logs
# logrotate config for app, addon and task logs
# man 7 glob
/home/yellowtent/platformdata/logs/graphite/*.log
@@ -8,7 +8,6 @@
/home/yellowtent/platformdata/logs/postgresql/*.log
/home/yellowtent/platformdata/logs/sftp/*.log
/home/yellowtent/platformdata/logs/redis-*/*.log
/home/yellowtent/platformdata/logs/crash/*.log
/home/yellowtent/platformdata/logs/collectd/*.log
/home/yellowtent/platformdata/logs/turn/*.log
/home/yellowtent/platformdata/logs/updater/*.log {
+1
View File
@@ -38,6 +38,7 @@ http {
# zones for rate limiting
limit_req_zone $binary_remote_addr zone=admin_login:10m rate=10r/s; # 10 request a second
include trusted.ips;
include applications/*.conf;
include applications/*/*.conf;
}
-3
View File
@@ -19,9 +19,6 @@ yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/rmaddondir.sh
Defaults!/home/yellowtent/box/src/scripts/reboot.sh env_keep="HOME BOX_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/reboot.sh
Defaults!/home/yellowtent/box/src/scripts/collectlogs.sh env_keep="HOME BOX_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/collectlogs.sh
Defaults!/home/yellowtent/box/src/scripts/update.sh env_keep="HOME BOX_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/update.sh
-1
View File
@@ -1,6 +1,5 @@
[Unit]
Description=Cloudron Admin
OnFailure=crashnotifier@%n.service
After=mysql.service nginx.service
; As cloudron-resize-fs is a one-shot, the Wants= automatically ensures that the service *finishes*
Wants=cloudron-resize-fs.service
@@ -1,15 +0,0 @@
# http://northernlightlabs.se/systemd.status.mail.on.unit.failure
[Unit]
Description=Cloudron Crash Notifier for %i
# otherwise, systemd will kill this unit immediately as nobody requires it
StopWhenUnneeded=false
[Service]
Type=idle
WorkingDirectory=/home/yellowtent/box
ExecStart="/home/yellowtent/box/crashnotifierservice.js" %I
Environment="HOME=/home/yellowtent" "USER=yellowtent" "DEBUG=box*,connect-lastmile" "BOX_ENV=cloudron" "NODE_ENV=production"
KillMode=process
User=yellowtent
Group=yellowtent
MemoryLimit=50M
+25 -29
View File
@@ -21,8 +21,7 @@ const assert = require('assert'),
promiseRetry = require('./promise-retry.js'),
superagent = require('superagent'),
safe = require('safetydance'),
users = require('./users.js'),
_ = require('underscore');
users = require('./users.js');
const CA_PROD_DIRECTORY_URL = 'https://acme-v02.api.letsencrypt.org/directory',
CA_STAGING_DIRECTORY_URL = 'https://acme-staging-v02.api.letsencrypt.org/directory';
@@ -43,7 +42,7 @@ function Acme2(fqdn, domainObject, email) {
const prod = domainObject.tlsConfig.provider.match(/.*-prod/) !== null; // matches 'le-prod' or 'letsencrypt-prod'
this.caDirectory = prod ? CA_PROD_DIRECTORY_URL : CA_STAGING_DIRECTORY_URL;
this.directory = {};
this.performHttpAuthorization = domainObject.provider.match(/noop|manual|wildcard/) !== null;
this.forceHttpAuthorization = domainObject.provider.match(/noop|manual|wildcard/) !== null;
this.wildcard = !!domainObject.tlsConfig.wildcard;
this.domain = domainObject.domain;
@@ -58,7 +57,7 @@ function Acme2(fqdn, domainObject, email) {
this.certName = this.cn.replace('*.', '_.');
debug(`Acme2: will get cert for fqdn: ${this.fqdn} cn: ${this.cn} certName: ${this.certName} wildcard: ${this.wildcard} http: ${this.performHttpAuthorization}`);
debug(`Acme2: will get cert for fqdn: ${this.fqdn} cn: ${this.cn} certName: ${this.certName} wildcard: ${this.wildcard} http: ${this.forceHttpAuthorization}`);
}
// urlsafe base64 encoding (jose)
@@ -114,7 +113,7 @@ Acme2.prototype.sendSignedRequest = async function (url, payload) {
debug(`sendSignedRequest: using nonce ${nonce} for url ${url}`);
const protected64 = b64(JSON.stringify(_.extend({ }, header, { nonce: nonce })));
const protected64 = b64(JSON.stringify(Object.assign({}, header, { nonce: nonce })));
const signer = crypto.createSign('RSA-SHA256');
signer.update(protected64 + '.' + payload64, 'utf8');
@@ -370,13 +369,8 @@ Acme2.prototype.downloadCertificate = async function (certUrl) {
});
};
Acme2.prototype.prepareHttpChallenge = async function (authorization) {
assert.strictEqual(typeof authorization, 'object');
debug(`prepareHttpChallenge: challenges: ${JSON.stringify(authorization)}`);
const httpChallenges = authorization.challenges.filter(function(x) { return x.type === 'http-01'; });
if (httpChallenges.length === 0) throw new BoxError(BoxError.ACME_ERROR, 'no http challenges');
const challenge = httpChallenges[0];
Acme2.prototype.prepareHttpChallenge = async function (challenge) {
assert.strictEqual(typeof challenge, 'object');
debug(`prepareHttpChallenge: preparing for challenge ${JSON.stringify(challenge)}`);
@@ -386,8 +380,6 @@ Acme2.prototype.prepareHttpChallenge = async function (authorization) {
debug(`prepareHttpChallenge: writing ${keyAuthorization} to ${challengeFilePath}`);
if (!safe.fs.writeFileSync(challengeFilePath, keyAuthorization)) throw new BoxError(BoxError.FS_ERROR, `Error writing challenge: ${safe.error.message}`);
return challenge;
};
Acme2.prototype.cleanupHttpChallenge = async function (challenge) {
@@ -416,13 +408,10 @@ function getChallengeSubdomain(cn, domain) {
return challengeSubdomain;
}
Acme2.prototype.prepareDnsChallenge = async function (cn, authorization) {
assert.strictEqual(typeof authorization, 'object');
Acme2.prototype.prepareDnsChallenge = async function (cn, challenge) {
assert.strictEqual(typeof challenge, 'object');
debug(`prepareDnsChallenge: challenges: ${JSON.stringify(authorization)}`);
const dnsChallenges = authorization.challenges.filter(function(x) { return x.type === 'dns-01'; });
if (dnsChallenges.length === 0) throw new BoxError(BoxError.ACME_ERROR, 'no dns challenges');
const challenge = dnsChallenges[0];
debug(`prepareDnsChallenge: preparing for challenge: ${JSON.stringify(challenge)}`);
const keyAuthorization = this.getKeyAuthorization(challenge.token);
const shasum = crypto.createHash('sha256');
@@ -436,8 +425,6 @@ Acme2.prototype.prepareDnsChallenge = async function (cn, authorization) {
await dns.upsertDnsRecords(challengeSubdomain, this.domain, 'TXT', [ `"${txtValue}"` ]);
await dns.waitForDnsRecord(challengeSubdomain, this.domain, 'TXT', txtValue, { times: 200 });
return challenge;
};
Acme2.prototype.cleanupDnsChallenge = async function (cn, challenge) {
@@ -460,22 +447,31 @@ Acme2.prototype.prepareChallenge = async function (cn, authorization) {
assert.strictEqual(typeof cn, 'string');
assert.strictEqual(typeof authorization, 'object');
debug(`prepareChallenge: http: ${this.performHttpAuthorization} cn: ${cn} authorization: ${JSON.stringify(authorization)}`);
debug(`prepareChallenge: http: ${this.forceHttpAuthorization} cn: ${cn} authorization: ${JSON.stringify(authorization)}`);
if (this.performHttpAuthorization) {
return await this.prepareHttpChallenge(authorization);
} else {
return await this.prepareDnsChallenge(cn, authorization);
// validation is cached by LE for 60 days or so. if a user switches from non-wildcard DNS (http challenge) to programmatic DNS (dns challenge), then
// LE remembers the challenge type and won't give us a dns challenge for 60 days!
// https://letsencrypt.org/docs/faq/#i-successfully-renewed-a-certificate-but-validation-didn-t-happen-this-time-how-is-that-possible
const dnsChallenges = authorization.challenges.filter(function(x) { return x.type === 'dns-01'; });
const httpChallenges = authorization.challenges.filter(function(x) { return x.type === 'http-01'; });
if (this.forceHttpAuthorization || dnsChallenges.length === 0) {
if (httpChallenges.length === 0) throw new BoxError(BoxError.ACME_ERROR, 'no http challenges');
await this.prepareHttpChallenge(httpChallenges[0]);
return httpChallenges[0];
}
await this.prepareDnsChallenge(cn, dnsChallenges[0]);
return dnsChallenges[0];
};
Acme2.prototype.cleanupChallenge = async function (cn, challenge) {
assert.strictEqual(typeof cn, 'string');
assert.strictEqual(typeof challenge, 'object');
debug(`cleanupChallenge: http: ${this.performHttpAuthorization}`);
debug(`cleanupChallenge: http: ${this.forceHttpAuthorization}`);
if (this.performHttpAuthorization) {
if (this.forceHttpAuthorization) {
await this.cleanupHttpChallenge(challenge);
} else {
await this.cleanupDnsChallenge(cn, challenge);
+1 -1
View File
@@ -93,7 +93,7 @@ async function checkAppHealth(app, options) {
.set('User-Agent', 'Mozilla (CloudronHealth)') // required for some apps (e.g. minio)
.redirects(0)
.ok(() => true)
.timeout(options.timeout * 1000));
.timeout(options.timeout));
if (healthCheckError) {
await apps.appendLogLine(app, `=> Healtheck error: ${healthCheckError}`);
+3 -3
View File
@@ -112,11 +112,11 @@ async function detectMetaInfo(applink) {
debug(`detectMetaInfo: found icon: ${favicon}`);
const [error, response] = await safe(superagent.get(favicon));
if (error) console.error(`Failed to fetch icon ${favicon}: `, error);
if (error) debug(`Failed to fetch icon ${favicon}: `, error);
else if (response.ok && response.headers['content-type'] === 'image/png') applink.icon = response.body;
else console.error(`Failed to fetch icon ${favicon}: statusCode=${response.status}`);
else debug(`Failed to fetch icon ${favicon}: statusCode=${response.status}`);
} else {
console.error(`Unable to find a suitable icon for ${applink.upstreamUri}`);
debug(`Unable to find a suitable icon for ${applink.upstreamUri}`);
}
}
+15 -15
View File
@@ -1243,7 +1243,7 @@ async function addTask(appId, installationState, task, auditSource) {
const taskId = await tasks.add(tasks.TASK_APP, [ appId, args ]);
const [updateError] = await safe(setTask(appId, _.extend({ installationState, taskId, error: null }, values), { requiredState, requireNullTaskId }));
const [updateError] = await safe(setTask(appId, Object.assign({ installationState, taskId, error: null }, values), { requiredState, requireNullTaskId }));
if (updateError && updateError.reason === BoxError.NOT_FOUND) throw new BoxError(BoxError.BAD_STATE, 'Another task is scheduled for this app'); // could be because app went away OR a taskId exists
if (updateError) throw updateError;
@@ -1378,9 +1378,9 @@ async function install(data, auditSource) {
}
const locations = [{ subdomain, domain, type: exports.LOCATION_TYPE_PRIMARY }]
.concat(secondaryDomains.map(ad => _.extend(ad, { type: exports.LOCATION_TYPE_SECONDARY })))
.concat(redirectDomains.map(ad => _.extend(ad, { type: exports.LOCATION_TYPE_REDIRECT })))
.concat(aliasDomains.map(ad => _.extend(ad, { type: exports.LOCATION_TYPE_ALIAS })));
.concat(secondaryDomains.map(ad => Object.assign(ad, { type: exports.LOCATION_TYPE_SECONDARY })))
.concat(redirectDomains.map(ad => Object.assign(ad, { type: exports.LOCATION_TYPE_REDIRECT })))
.concat(aliasDomains.map(ad => Object.assign(ad, { type: exports.LOCATION_TYPE_ALIAS })));
error = await validateLocations(locations);
if (error) throw error;
@@ -1426,7 +1426,7 @@ async function install(data, auditSource) {
const taskId = await addTask(appId, app.installationState, task, auditSource);
const newApp = _.extend({}, _.omit(app, 'icon'), { appStoreId, manifest, subdomain, domain, portBindings });
const newApp = Object.assign({}, _.omit(app, 'icon'), { appStoreId, manifest, subdomain, domain, portBindings });
newApp.fqdn = dns.fqdn(newApp.subdomain, newApp.domain);
newApp.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
newApp.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
@@ -1484,7 +1484,7 @@ async function setUpstreamUri(app, upstreamUri, auditSource) {
const error = validateUpstreamUri(upstreamUri);
if (error) throw error;
await reverseProxy.writeAppConfigs(_.extend({}, app, { upstreamUri }));
await reverseProxy.writeAppConfigs(Object.assign({}, app, { upstreamUri }));
await update(appId, { upstreamUri });
@@ -1755,7 +1755,7 @@ async function setReverseProxyConfig(app, reverseProxyConfig, auditSource) {
assert.strictEqual(typeof reverseProxyConfig, 'object');
assert.strictEqual(typeof auditSource, 'object');
reverseProxyConfig = _.extend({ robotsTxt: null, csp: null, hstsPreload: false }, reverseProxyConfig);
reverseProxyConfig = Object.assign({ robotsTxt: null, csp: null, hstsPreload: false }, reverseProxyConfig);
const appId = app.id;
let error = validateCsp(reverseProxyConfig.csp);
@@ -1764,7 +1764,7 @@ async function setReverseProxyConfig(app, reverseProxyConfig, auditSource) {
error = validateRobotsTxt(reverseProxyConfig.robotsTxt);
if (error) throw error;
await reverseProxy.writeAppConfigs(_.extend({}, app, { reverseProxyConfig }));
await reverseProxy.writeAppConfigs(Object.assign({}, app, { reverseProxyConfig }));
await update(appId, { reverseProxyConfig });
@@ -1852,9 +1852,9 @@ async function setLocation(app, data, auditSource) {
}
const locations = [{ subdomain: values.subdomain, domain: values.domain, type: exports.LOCATION_TYPE_PRIMARY }]
.concat(values.secondaryDomains.map(ad => _.extend(ad, { type: exports.LOCATION_TYPE_SECONDARY })))
.concat(values.redirectDomains.map(ad => _.extend(ad, { type: exports.LOCATION_TYPE_REDIRECT })))
.concat(values.aliasDomains.map(ad => _.extend(ad, { type: exports.LOCATION_TYPE_ALIAS })));
.concat(values.secondaryDomains.map(ad => Object.assign(ad, { type: exports.LOCATION_TYPE_SECONDARY })))
.concat(values.redirectDomains.map(ad => Object.assign(ad, { type: exports.LOCATION_TYPE_REDIRECT })))
.concat(values.aliasDomains.map(ad => Object.assign(ad, { type: exports.LOCATION_TYPE_ALIAS })));
error = await validateLocations(locations);
if (error) throw error;
@@ -1876,7 +1876,7 @@ async function setLocation(app, data, auditSource) {
values.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
values.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, _.extend({ appId, app, taskId }, values));
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, Object.assign({ appId, app, taskId }, values));
return { taskId };
}
@@ -2085,7 +2085,7 @@ async function repair(app, data, auditSource) {
} else {
errorState = exports.ISTATE_PENDING_CONFIGURE;
if (data.dockerImage) {
let newManifest = _.extend({}, app.manifest, { dockerImage: data.dockerImage });
let newManifest = Object.assign({}, app.manifest, { dockerImage: data.dockerImage });
task.values.manifest = newManifest;
}
}
@@ -2269,7 +2269,7 @@ async function clone(app, data, user, auditSource) {
const secondaryDomains = translateSecondaryDomains(data.secondaryDomains || {});
const locations = [{ subdomain, domain, type: exports.LOCATION_TYPE_PRIMARY }]
.concat(secondaryDomains.map(ad => _.extend(ad, { type: exports.LOCATION_TYPE_SECONDARY })));
.concat(secondaryDomains.map(ad => Object.assign(ad, { type: exports.LOCATION_TYPE_SECONDARY })));
error = await validateLocations(locations);
if (error) throw error;
@@ -2327,7 +2327,7 @@ async function clone(app, data, user, auditSource) {
};
const taskId = await addTask(newAppId, exports.ISTATE_PENDING_CLONE, task, auditSource);
const newApp = _.extend({}, _.omit(obj, 'icon'), { appStoreId, manifest, subdomain, domain, portBindings });
const newApp = Object.assign({}, _.omit(obj, 'icon'), { appStoreId, manifest, subdomain, domain, portBindings });
newApp.fqdn = dns.fqdn(newApp.subdomain, newApp.domain);
newApp.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
newApp.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
+4 -4
View File
@@ -258,7 +258,7 @@ async function moveDataDir(app, targetVolumeId, targetVolumePrefix) {
assert(targetVolumePrefix === null || typeof targetVolumePrefix === 'string');
const resolvedSourceDir = await apps.getStorageDir(app);
const resolvedTargetDir = await apps.getStorageDir(_.extend({}, app, { storageVolumeId: targetVolumeId, storageVolumePrefix: targetVolumePrefix }));
const resolvedTargetDir = await apps.getStorageDir(Object.assign({}, app, { storageVolumeId: targetVolumeId, storageVolumePrefix: targetVolumePrefix }));
debug(`moveDataDir: migrating data from ${resolvedSourceDir} to ${resolvedTargetDir}`);
@@ -363,9 +363,9 @@ async function install(app, args, progressCallback) {
await progressCallback({ percent: 65, message: 'Downloading backup and restoring addons' });
await services.setupAddons(app, app.manifest.addons);
await services.clearAddons(app, app.manifest.addons);
const backupConfig = restoreConfig.backupConfig; // can be null
const backupConfig = restoreConfig.backupConfig;
let mountObject = null;
if (backupConfig && mounts.isManagedProvider(backupConfig.provider)) {
if (mounts.isManagedProvider(backupConfig.provider)) {
await progressCallback({ percent: 70, message: 'Setting up mount for importing' });
mountObject = { // keep this in sync with importApp in apps.js
name: `appimport-${app.id}`,
@@ -525,7 +525,7 @@ async function migrateDataDir(app, args, progressCallback) {
// re-setup addons since this creates the localStorage destination
await progressCallback({ percent: 50, message: 'Setting up addons' });
await services.setupAddons(_.extend({}, app, { storageVolumeId: newStorageVolumeId, storageVolumePrefix: newStorageVolumePrefix }), app.manifest.addons);
await services.setupAddons(Object.assign({}, app, { storageVolumeId: newStorageVolumeId, storageVolumePrefix: newStorageVolumePrefix }), app.manifest.addons);
await progressCallback({ percent: 60, message: 'Moving data dir' });
await moveDataDir(app, newStorageVolumeId, newStorageVolumePrefix);
+19
View File
@@ -27,6 +27,7 @@ exports = module.exports = {
testProviderConfig,
remount,
getMountStatus,
BACKUP_IDENTIFIER_BOX: 'box',
BACKUP_IDENTIFIER_MAIL: 'mail',
@@ -50,6 +51,7 @@ const assert = require('assert'),
eventlog = require('./eventlog.js'),
hat = require('./hat.js'),
locker = require('./locker.js'),
mounts = require('./mounts.js'),
path = require('path'),
paths = require('./paths.js'),
safe = require('safetydance'),
@@ -361,3 +363,20 @@ async function remount(auditSource) {
await storage.api(backupConfig.provider).remount(backupConfig);
}
async function getMountStatus() {
const backupConfig = await settings.getBackupConfig();
let hostPath;
if (mounts.isManagedProvider(backupConfig.provider)) {
hostPath = paths.MANAGED_BACKUP_MOUNT_DIR;
} else if (backupConfig.provider === 'mountpoint') {
hostPath = backupConfig.mountPoint;
} else if (backupConfig.provider === 'filesystem') {
hostPath = backupConfig.backupFolder;
} else {
throw new BoxError(BoxError.BAD_STATE, 'Backup location is not a mount');
}
return await mounts.getStatus(backupConfig.provider, hostPath); // { state, message }
}
+1 -1
View File
@@ -61,7 +61,7 @@ async function checkPreconditions(backupConfig, dataLayout) {
let used = 0;
for (const localPath of dataLayout.localPaths()) {
debug(`checkPreconditions: getting disk usage of ${localPath}`);
const result = safe.child_process.execSync(`du -Dsb ${localPath}`, { encoding: 'utf8' });
const result = safe.child_process.execSync(`du -Dsb "${localPath}"`, { encoding: 'utf8' });
if (!result) throw new BoxError(BoxError.FS_ERROR, `du error: ${safe.error.message}`);
used += parseInt(result, 10);
}
+3 -4
View File
@@ -4,8 +4,7 @@
const assert = require('assert'),
HttpError = require('connect-lastmile').HttpError,
util = require('util'),
_ = require('underscore');
util = require('util');
exports = module.exports = BoxError;
@@ -28,7 +27,7 @@ function BoxError(reason, errorOrMessage, override) {
} else { // error object
this.message = errorOrMessage.message;
this.nestedError = errorOrMessage;
_.extend(this, override); // copy enumerable properies
Object.assign(this, override); // copy enumerable properies
}
}
util.inherits(BoxError, Error);
@@ -70,7 +69,7 @@ BoxError.TIMEOUT = 'Timeout';
BoxError.TRY_AGAIN = 'Try Again';
BoxError.prototype.toPlainObject = function () {
return _.extend({}, { message: this.message, reason: this.reason }, this.details);
return Object.assign({}, { message: this.message, reason: this.reason }, this.details);
};
// this is a class method for now in case error is not a BoxError
+4 -14
View File
@@ -35,11 +35,9 @@ const apps = require('./apps.js'),
constants = require('./constants.js'),
cron = require('./cron.js'),
debug = require('debug')('box:cloudron'),
delay = require('./delay.js'),
dns = require('./dns.js'),
dockerProxy = require('./dockerproxy.js'),
eventlog = require('./eventlog.js'),
execSync = require('child_process').execSync,
fs = require('fs'),
logs = require('./logs.js'),
mail = require('./mail.js'),
@@ -55,6 +53,7 @@ const apps = require('./apps.js'),
shell = require('./shell.js'),
sysinfo = require('./sysinfo.js'),
tasks = require('./tasks.js'),
timers = require('timers/promises'),
users = require('./users.js');
const REBOOT_CMD = path.join(__dirname, 'scripts/reboot.sh');
@@ -86,7 +85,7 @@ async function onActivated(options) {
// disable responding to api calls via IP to not leak domain info. this is carefully placed as the last item, so it buys
// the UI some time to query the dashboard domain in the restore code path
await delay(30000);
await timers.setTimeout(30000);
await reverseProxy.writeDefaultConfig({ activated :true });
}
@@ -224,7 +223,6 @@ async function getLogs(unit, options) {
let logFile = '';
if (unit === 'box') logFile = path.join(paths.LOG_DIR, 'box.log'); // box.log is at the top
else if (unit.startsWith('crash-')) logFile = path.join(paths.CRASH_LOG_DIR, unit.slice(6) + '.log');
else throw new BoxError(BoxError.BAD_FIELD, `No such unit '${unit}'`);
const cp = logs.tail([logFile], { lines: options.lines, follow: options.follow });
@@ -337,21 +335,13 @@ async function updateDiskUsage() {
}
async function getBlockDevices() {
let info;
const info = safe.JSON.parse(safe.child_process.execSync('lsblk --paths --json --list --fs', { encoding: 'utf8' }));
if (!info) throw new BoxError(BoxError.INTERNAL_ERROR, safe.error.message);
try {
info = JSON.parse(execSync('lsblk --paths --json --list --fs', { encoding: 'utf8' }));
} catch (e) {
console.error('Failed to list disks:', e);
throw new BoxError(BoxError.INTERNAL_ERROR, e);
}
// filter only for ext4 and xfs disks
const devices = info.blockdevices.filter(d => d.fstype === 'ext4' || d.fstype === 'xfs');
debug(`getBlockDevices: Found ${devices.length} devices. ${devices.map(d => d.name).join(', ')}`);
// convert to fixed format
return devices.map(function (d) {
return {
path: d.name,
-53
View File
@@ -1,53 +0,0 @@
'use strict';
exports = module.exports = {
sendFailureLogs
};
const assert = require('assert'),
AuditSource = require('./auditsource.js'),
child_process = require('child_process'),
eventlog = require('./eventlog.js'),
safe = require('safetydance'),
path = require('path'),
paths = require('./paths.js'),
util = require('util');
const COLLECT_LOGS_CMD = path.join(__dirname, 'scripts/collectlogs.sh');
const CRASH_LOG_TIMESTAMP_OFFSET = 1000 * 60 * 60; // 60 min
const CRASH_LOG_TIMESTAMP_FILE = '/tmp/crashlog.timestamp';
async function collectLogs(unitName) {
assert.strictEqual(typeof unitName, 'string');
const logs = child_process.execSync(`sudo ${COLLECT_LOGS_CMD} ${unitName}`, { encoding: 'utf8' });
return logs;
}
async function sendFailureLogs(unitName) {
assert.strictEqual(typeof unitName, 'string');
// check if we already sent a mail in the last CRASH_LOG_TIME_OFFSET window
const timestamp = safe.fs.readFileSync(CRASH_LOG_TIMESTAMP_FILE, 'utf8');
if (timestamp && (parseInt(timestamp) + CRASH_LOG_TIMESTAMP_OFFSET) > Date.now()) {
console.log('Crash log already sent within window');
return;
}
let [error, logs] = await safe(collectLogs(unitName));
if (error) {
console.error('Failed to collect logs.', error);
logs = util.format('Failed to collect logs.', error);
}
const crashId = `${new Date().toISOString()}`;
console.log(`Creating crash log for ${unitName} with id ${crashId}`);
if (!safe.fs.writeFileSync(path.join(paths.CRASH_LOG_DIR, `${crashId}.log`), logs)) console.log(`Failed to stash logs to ${crashId}.log:`, safe.error);
[error] = await safe(eventlog.add(eventlog.ACTION_PROCESS_CRASH, AuditSource.HEALTH_MONITOR, { processName: unitName, crashId: crashId }));
if (error) console.log(`Error sending crashlog. Logs stashed at ${crashId}.log`);
safe.fs.writeFileSync(CRASH_LOG_TIMESTAMP_FILE, String(Date.now()));
}
-13
View File
@@ -1,13 +0,0 @@
'use strict';
exports = module.exports = delay;
const assert = require('assert');
function delay(msecs) {
assert.strictEqual(typeof msecs, 'number');
return new Promise(function (resolve) {
setTimeout(resolve, msecs);
});
}
+2 -3
View File
@@ -7,8 +7,7 @@ exports = module.exports = {
const assert = require('assert'),
constants = require('./constants.js'),
dns = require('dns'),
safe = require('safetydance'),
_ = require('underscore');
safe = require('safetydance');
// a note on TXT records. It doesn't have quotes ("") at the DNS level. Those quotes
// are added for DNS server software to enclose spaces. Such quotes may also be returned
@@ -20,7 +19,7 @@ async function resolve(hostname, rrtype, options) {
const defaultOptions = { server: '127.0.0.1', timeout: 5000 }; // unbound runs on 127.0.0.1
const resolver = new dns.promises.Resolver();
options = _.extend({ }, defaultOptions, options);
options = Object.assign({}, defaultOptions, options);
// Only use unbound on a Cloudron
if (constants.CLOUDRON) resolver.setServers([ options.server ]);
+2 -1
View File
@@ -242,7 +242,8 @@ async function verifyDomainConfig(domainObject) {
if (error || !nameservers) throw new BoxError(BoxError.BAD_FIELD, error ? error.message : 'Unable to get nameservers');
// https://docs.hetzner.com/dns-console/dns/general/dns-overview#the-hetzner-online-name-servers-are
if (nameservers.map(function (n) { return n.toLowerCase(); }).indexOf('oxygen.ns.hetzner.com') === -1) {
const nsMap = nameservers.map(function (n) { return n.toLowerCase(); });
if (!nsMap.includes('oxygen.ns.hetzner.com') && !nsMap.includes('ns1.your-server.de')) {
debug('verifyDomainConfig: %j does not contain Hetzner NS', nameservers);
throw new BoxError(BoxError.BAD_FIELD, 'Domain nameservers are not set to Hetzner');
}
+3
View File
@@ -10,6 +10,9 @@ exports = module.exports = {
verifyDomainConfig
};
// https://github.com/aws/aws-sdk-js/issues/4354
require('aws-sdk/lib/maintenance_mode_message').suppress = true;
const assert = require('assert'),
AWS = require('aws-sdk'),
BoxError = require('../boxerror.js'),
+8 -7
View File
@@ -37,7 +37,6 @@ const apps = require('./apps.js'),
BoxError = require('./boxerror.js'),
constants = require('./constants.js'),
debug = require('debug')('box:docker'),
delay = require('./delay.js'),
Docker = require('dockerode'),
paths = require('./paths.js'),
promiseRetry = require('./promise-retry.js'),
@@ -46,8 +45,8 @@ const apps = require('./apps.js'),
shell = require('./shell.js'),
safe = require('safetydance'),
system = require('./system.js'),
volumes = require('./volumes.js'),
_ = require('underscore');
timers = require('timers/promises'),
volumes = require('./volumes.js');
const DOCKER_SOCKET_PATH = '/var/run/docker.sock';
const gConnection = new Docker({ socketPath: DOCKER_SOCKET_PATH });
@@ -278,6 +277,8 @@ async function createSubcontainer(app, name, cmd, options) {
`CLOUDRON_APP_DOMAIN=${domain}`
];
if (app.manifest.multiDomain) stdEnv.push(`CLOUDRON_ALIAS_DOMAINS=${app.aliasDomains.map(ad => ad.fqdn).join(',')}`);
const secondaryDomainsEnv = app.secondaryDomains.map(sd => `${sd.environmentVariable}=${sd.fqdn}`);
const portEnv = [];
@@ -318,7 +319,7 @@ async function createSubcontainer(app, name, cmd, options) {
app.manifest.runtimeDirs.forEach(dir => runtimeVolumes[dir] = {});
}
let containerOptions = {
const containerOptions = {
name: name, // for referencing containers
Tty: isAppContainer,
Image: app.manifest.dockerImage,
@@ -405,9 +406,9 @@ async function createSubcontainer(app, name, cmd, options) {
];
}
containerOptions = _.extend(containerOptions, options);
const mergedOptions = Object.assign({}, containerOptions, options);
const [createError, container] = await safe(gConnection.createContainer(containerOptions));
const [createError, container] = await safe(gConnection.createContainer(mergedOptions));
if (createError && createError.statusCode === 409) throw new BoxError(BoxError.ALREADY_EXISTS, createError);
if (createError) throw new BoxError(BoxError.DOCKER_ERROR, createError);
@@ -648,7 +649,7 @@ async function update(name, memory, memorySwap) {
for (let times = 0; times < 10; times++) {
const [error] = await safe(shell.promises.spawn(`update(${name})`, '/usr/bin/docker', args, { }));
if (!error) return;
await delay(60 * 1000);
await timers.setTimeout(60 * 1000);
}
throw new BoxError(BoxError.DOCKER_ERROR, 'Unable to update container');
+2 -3
View File
@@ -17,8 +17,7 @@ const apps = require('./apps.js'),
path = require('path'),
paths = require('./paths.js'),
safe = require('safetydance'),
util = require('util'),
_ = require('underscore');
util = require('util');
let gHttpServer = null;
@@ -67,7 +66,7 @@ function attachDockerRequest(req, res, next) {
function containersCreate(req, res, next) {
safe.set(req.body, 'HostConfig.NetworkMode', 'cloudron'); // overwrite the network the container lives in
safe.set(req.body, 'NetworkingConfig', {}); // drop any custom network configs
safe.set(req.body, 'Labels', _.extend({ }, safe.query(req.body, 'Labels'), { appId: req.app.id, isCloudronManaged: String(false) })); // overwrite the app id to track containers of an app
safe.set(req.body, 'Labels', Object.assign({}, safe.query(req.body, 'Labels'), { appId: req.app.id, isCloudronManaged: String(false) })); // overwrite the app id to track containers of an app
safe.set(req.body, 'HostConfig.LogConfig', { Type: 'syslog', Config: { 'tag': req.app.id, 'syslog-address': 'udp://127.0.0.1:2514', 'syslog-format': 'rfc5424' }});
const appDataDir = path.join(paths.APPS_DATA_DIR, req.app.id, 'data');
+1 -1
View File
@@ -85,7 +85,7 @@ exports = module.exports = {
ACTION_SUPPORT_TICKET: 'support.ticket',
ACTION_SUPPORT_SSH: 'support.ssh',
ACTION_PROCESS_CRASH: 'system.crash'
ACTION_PROCESS_CRASH: 'system.crash' // obsolete
};
const assert = require('assert'),
+1 -1
View File
@@ -87,7 +87,7 @@ async function getClient(externalLdapConfig, options) {
// ensure we don't just crash
client.on('error', function (error) {
console.error('ExternalLdap client error:', error);
debug('getClient: ExternalLdap client error:', error);
reject(new BoxError(BoxError.EXTERNAL_ERROR, error));
});
+3 -3
View File
@@ -17,10 +17,10 @@ exports = module.exports = {
'images': {
'turn': { repo: 'cloudron/turn', tag: 'cloudron/turn:1.5.0@sha256:c59a6da9ea55073ede1ba6329739fca72eddf64c3a3c10280bcc5b7fb8197865' },
'mysql': { repo: 'cloudron/mysql', tag: 'cloudron/mysql:3.3.7@sha256:5c8fe784859a5bc8c839712d8b52427247a54bce9126fb2d50ca2535e6330647' },
'postgresql': { repo: 'cloudron/postgresql', tag: 'cloudron/postgresql:5.0.7@sha256:4be3401b9d1374d1e165bdbd1a49ea8cdee748f15f180538306637868abffbac' },
'postgresql': { repo: 'cloudron/postgresql', tag: 'cloudron/postgresql:5.0.6@sha256:94bc17f8e9daf8de01c9676bc6c9ac4d791cc10b1a602d9647a8f545ea5568fc' },
'mongodb': { repo: 'cloudron/mongodb', tag: 'cloudron/mongodb:4.3.7@sha256:6217723c33f1555fdaf5064a4ee87ab582523ac24fe15fafe9838b137e185296' },
'redis': { repo: 'cloudron/redis', tag: 'cloudron/redis:3.4.5@sha256:83dad2697791f358be75e2fc686840800b26aafc697b1250e8457c97ece67a47' },
'mail': { repo: 'cloudron/mail', tag: 'cloudron/mail:3.8.3@sha256:877a1afb99e8cae8c82d5a2fca77840425eb7fafc24360fdd1c9c299e41bcfeb' },
'redis': { repo: 'cloudron/redis', tag: 'cloudron/redis:3.5.0@sha256:ee6da2599a72afaec1d80c41db9b5fe79c882fb920195659e871501ea2e94d18' },
'mail': { repo: 'cloudron/mail', tag: 'cloudron/mail:3.8.6@sha256:c88fc3502828dc3c15f39b10e2b949a447a682a686854ac358a8983ac0999ed3' },
'graphite': { repo: 'cloudron/graphite', tag: 'cloudron/graphite:3.3.0@sha256:005addac7e7576f3960b562404ce59442bc861626af0ae0f5122484f5bfcbbc1' },
'sftp': { repo: 'cloudron/sftp', tag: 'cloudron/sftp:3.7.2@sha256:a6f81d4dbbb90f6d57d30722f860d431cdba67c3500fb327878d29c6bb6357d2' }
}
+1 -1
View File
@@ -529,7 +529,7 @@ async function checkRblStatus(domain) {
debug(`checkRblStatus: ${domain} (flippedIp: ${flippedIp}) is in the blacklist of ${JSON.stringify(rblServer)}`);
const result = _.extend({ }, rblServer);
const result = Object.assign({}, rblServer);
const [error2, txtRecords] = await safe(dig.resolve(flippedIp + '.' + rblServer.dns, 'TXT', DNS_OPTIONS));
result.txtRecords = error2 || !txtRecords ? 'No txt record' : txtRecords.map(x => x.join(''));
+1 -1
View File
@@ -2,7 +2,7 @@
exports = module.exports = {
getBlocklist,
setBlocklist
setBlocklist,
};
const assert = require('assert'),
+11
View File
@@ -269,9 +269,20 @@ server {
# client_max_body_size 1m;
# }
location @dashboarderrorredirect {
return 302 /;
}
location / {
root <%= sourceDir %>/dashboard/dist;
index index.html index.htm;
error_page 404 = @dashboarderrorredirect;
}
# Cross domain translation access for local development and login page
location ~ ^/translation/ {
root <%= sourceDir %>/dashboard/dist;
add_header "Access-Control-Allow-Origin" "*";
}
# Cross domain webfont access for proxy auth login page https://github.com/h5bp/server-configs/issues/85
+130 -59
View File
@@ -31,6 +31,8 @@ const assert = require('assert'),
jose = require('jose'),
safe = require('safetydance'),
settings = require('./settings.js'),
tokens = require('./tokens.js'),
translation = require('./translation.js'),
url = require('url'),
users = require('./users.js'),
util = require('util');
@@ -41,6 +43,9 @@ const OIDC_CLIENTS_FIELDS = [ 'id', 'secret', 'name', 'appId', 'loginRedirectUri
const ROUTE_PREFIX = '/openid';
const DEFAULT_TOKEN_SIGNATURE_ALGORITHM='RS256';
const DASHBOARD_CLIENT_ID = 'dashboard';
const DEV_CLIENT_ID = 'development';
let gHttpServer = null;
// -----------------------------
@@ -63,8 +68,6 @@ async function clientsAdd(id, data) {
assert.strictEqual(typeof data.appId, 'string');
assert(data.tokenSignatureAlgorithm === 'RS256' || data.tokenSignatureAlgorithm === 'EdDSA');
debug(`clientsAdd: id:${id} secret:${data.secret} name:${data.name} appId:${data.appId} loginRedirectUri:${data.loginRedirectUri} logoutRedirectUri:${data.logoutRedirectUri} tokenSignatureAlgorithm:${data.tokenSignatureAlgorithm}`);
const query = `INSERT INTO ${OIDC_CLIENTS_TABLE_NAME} (id, secret, name, appId, loginRedirectUri, logoutRedirectUri, tokenSignatureAlgorithm) VALUES (?, ?, ?, ?, ?, ?, ?)`;
const args = [ id, data.secret, data.name, data.appId, data.loginRedirectUri, data.logoutRedirectUri, data.tokenSignatureAlgorithm ];
@@ -76,7 +79,25 @@ async function clientsAdd(id, data) {
async function clientsGet(id) {
assert.strictEqual(typeof id, 'string');
debug(`clientsGet: id:${id}`);
if (id === DASHBOARD_CLIENT_ID) {
return {
id: DASHBOARD_CLIENT_ID,
secret: 'notused',
application_type: 'web',
response_types: ['code', 'code token'],
grant_types: ['authorization_code', 'implicit'],
loginRedirectUri: settings.dashboardOrigin() + '/authcallback.html'
};
} else if (id === DEV_CLIENT_ID) {
return {
id: DEV_CLIENT_ID,
secret: 'notused',
application_type: 'native', // have to use native here to support plaintext http, this however makes it impossible to skip consent screen
response_types: ['code', 'code token'],
grant_types: ['authorization_code', 'implicit'],
loginRedirectUri: 'http://localhost:4000/authcallback.html'
};
}
const result = await database.query(`SELECT ${OIDC_CLIENTS_FIELDS} FROM ${OIDC_CLIENTS_TABLE_NAME} WHERE id = ?`, [ id ]);
if (result.length === 0) return null;
@@ -93,8 +114,6 @@ async function clientsUpdate(id, data) {
assert.strictEqual(typeof data.appId, 'string');
assert(data.tokenSignatureAlgorithm === 'RS256' || data.tokenSignatureAlgorithm === 'EdDSA');
debug(`clientsUpdate: id:${id} secret:${data.secret} name:${data.name} appId:${data.appId} loginRedirectUri:${data.loginRedirectUri} logoutRedirectUri:${data.logoutRedirectUri} tokenSignatureAlgorithm:${data.tokenSignatureAlgorithm}`);
const result = await database.query(`UPDATE ${OIDC_CLIENTS_TABLE_NAME} SET secret=?, name=?, appId=?, loginRedirectUri=?, logoutRedirectUri=?, tokenSignatureAlgorithm=? WHERE id = ?`, [ data.secret, data.name, data.appId, data.loginRedirectUri, data.logoutRedirectUri, data.tokenSignatureAlgorithm, id]);
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'client not found');
}
@@ -131,7 +150,8 @@ function load(modelName) {
try {
data = JSON.parse(fs.readFileSync(filePath), 'utf8');
} catch (e) {
debug(`load: failed to read ${filePath}, start with new one. %o`, e);
if (e.code === 'ENOENT') debug(`load: failed to read ${filePath}, start with new one.`);
else debug(`load: failed to read ${filePath}, use in-memory. %o`, e);
}
DATA_STORE[modelName] = data;
@@ -146,9 +166,9 @@ function save(modelName) {
debug(`save: model ${modelName} to ${filePath}.`);
try {
fs.writeFileSync(filePath, JSON.stringify(DATA_STORE[modelName]), 'utf8');
fs.writeFileSync(filePath, JSON.stringify(DATA_STORE[modelName], null, 2), 'utf8');
} catch (e) {
debug(`revokeByUserId: failed to write ${filePath}`, e);
debug(`save: failed to write ${filePath}`, e);
}
}
@@ -159,8 +179,6 @@ function save(modelName) {
async function revokeByUserId(userId) {
assert.strictEqual(typeof userId, 'string');
debug(`revokeByUserId: userId:${userId}`);
function revokeObjects(modelName) {
load(modelName);
@@ -195,9 +213,11 @@ class CloudronAdapter {
constructor(name) {
this.name = name;
debug(`Creating storage adapter for ${name}`);
debug(`Creating OpenID storage adapter for ${name}`);
if (this.name !== 'Client') {
if (this.name === 'Client') {
return;
} else {
load(name);
}
}
@@ -215,10 +235,19 @@ class CloudronAdapter {
*
*/
async upsert(id, payload, expiresIn) {
debug(`[${this.name}] upsert id:${id} expiresIn:${expiresIn}`, payload);
if (this.name === 'Client') {
console.log('WARNING!! this should not happen as it is stored in our db');
debug('upsert: this should not happen as it is stored in our db');
} else if (this.name === 'AccessToken' && (payload.clientId === DASHBOARD_CLIENT_ID || payload.clientId === DEV_CLIENT_ID)) {
const clientId = payload.clientId;
const identifier = payload.accountId;
const expires = Date.now() + constants.DEFAULT_TOKEN_EXPIRATION_MSECS;
const accessToken = id;
const [error] = await safe(tokens.add({ clientId, identifier, expires, accessToken }));
if (error) {
console.log('Error adding access token', error);
throw error;
}
} else {
DATA_STORE[this.name][id] = { id, expiresIn, payload, consumed: false };
save(this.name);
@@ -236,28 +265,27 @@ class CloudronAdapter {
*
*/
async find(id) {
debug(`[${this.name}] find id:${id}`);
if (this.name === 'Client') {
const [error, client] = await safe(clientsGet(id));
if (error) {
console.log('Error getting client', error);
debug('find: error getting client', error);
return null;
}
if (!client) return null;
debug(`[${this.name}] find id:${id}`, client);
const tmp = {};
tmp.application_type = 'native'; // default is web but we want more flexible redirectUris and this is only used in https://github.com/panva/node-oidc-provider/blob/03c9bc513860e68ee7be84f99bfc9dc930b224e8/lib/helpers/client_schema.js#L53
tmp.application_type = client.application_type || 'native'; // default is web but we want more flexible redirectUris and this is only used in https://github.com/panva/node-oidc-provider/blob/03c9bc513860e68ee7be84f99bfc9dc930b224e8/lib/helpers/client_schema.js#L536
tmp.client_id = id;
tmp.client_secret = client.secret;
tmp.id_token_signed_response_alg = client.tokenSignatureAlgorithm || 'RS256';
if (client.response_types) tmp.response_types = client.response_types;
if (client.grant_types) tmp.grant_types = client.grant_types;
if (client.appId) {
const [error, app] = await safe(apps.get(client.appId));
if (error || !app) {
console.error(`oidc: Unkown app for client with appId ${client.appId}`);
debug(`find: Unknown app for client with appId ${client.appId}`);
return null;
}
@@ -272,12 +300,26 @@ class CloudronAdapter {
if (client.logoutRedirectUri) tmp.post_logout_redirect_uris = [ client.logoutRedirectUri ];
}
return tmp;
} else if (this.name === 'AccessToken') {
debug('find: we dont support finding AccessTokens', id);
const [error, result] = await safe(tokens.getByAccessToken(id));
if (error || !result) {
debug(`find: Unknown accessToken for id ${id} maybe oidc internal?`);
if (!DATA_STORE[this.name][id]) return null;
return DATA_STORE[this.name][id].payload;
}
const tmp = {
accountId: result.identifier,
clientId: result.clientId
};
return tmp;
} else {
if (!DATA_STORE[this.name][id]) return null;
debug(`[${this.name}] find id:${id}`, DATA_STORE[this.name][id]);
return DATA_STORE[this.name][id].payload;
}
}
@@ -308,10 +350,8 @@ class CloudronAdapter {
*
*/
async findByUid(uid) {
debug(`[${this.name}] findByUid uid:${uid}`);
if (this.name === 'Client') {
console.log('WARNING!! this should not happen as it is stored in our db');
if (this.name === 'Client' || this.name === 'AccessToken') {
debug('findByUid: this should not happen as it is stored in our db');
} else {
for (let d in DATA_STORE[this.name]) {
if (DATA_STORE[this.name][d].payload.uid === uid) return DATA_STORE[this.name][d].payload;
@@ -333,10 +373,10 @@ class CloudronAdapter {
*
*/
async consume(id) {
debug(`[${this.name}] consume id:${id}`);
debug(`[${this.name}] consume: ${id}`);
if (this.name === 'Client') {
console.log('WARNING!! this should not happen as it is stored in our db');
debug('consume: this should not happen as it is stored in our db');
} else {
if (DATA_STORE[this.name][id]) DATA_STORE[this.name][id].consumed = true;
save(this.name);
@@ -354,10 +394,10 @@ class CloudronAdapter {
*
*/
async destroy(id) {
debug(`[${this.name}] destroy id:${id}`);
debug(`[${this.name}] destroy: ${id}`);
if (this.name === 'Client') {
console.log('WARNING!! this should not happen as it is stored in our db');
debug('destroy: this should not happen as it is stored in our db');
} else {
delete DATA_STORE[this.name][id];
save(this.name);
@@ -375,10 +415,10 @@ class CloudronAdapter {
*
*/
async revokeByGrantId(grantId) {
debug(`[${this.name}] revokeByGrantId grantId:${grantId}`);
debug(`[${this.name}] revokeByGrantId: ${grantId}`);
if (this.name === 'Client') {
console.log('WARNING!! this should not happen as it is stored in our db');
debug('revokeByGrantId: this should not happen as it is stored in our db');
} else {
for (let d in DATA_STORE[this.name]) {
if (DATA_STORE[this.name][d].grantId === grantId) {
@@ -397,33 +437,48 @@ function renderInteractionPage(provider) {
assert.strictEqual(typeof provider, 'object');
return async function (req, res, next) {
const translationAssets = await translation.getTranslations();
try {
const { uid, prompt, params, session } = await provider.interactionDetails(req, res);
debug(`route interaction get uid:${uid} prompt.name:${prompt.name} client_id:${params.client_id} session:${session}`);
const client = await clientsGet(params.client_id);
let app = null;
if (client.appId) app = await apps.get(client.appId);
switch (prompt.name) {
case 'login': {
return res.render('login', {
const options = {
submitUrl: `${ROUTE_PREFIX}/interaction/${uid}/login`,
iconUrl: '/api/v1/cloudron/avatar',
name: client?.name || 'Cloudron'
});
};
if (app) {
options.name = app.label || app.fqdn;
options.iconUrl = app.iconUrl;
}
const template = fs.readFileSync(__dirname + '/oidc_templates/login.ejs', 'utf-8');
const html = ejs.render(translation.translate(template, translationAssets.translations || {}, translationAssets.fallback || {}), options);
return res.send(html);
}
case 'consent': {
const options = {
hasAccess: false,
submitUrl: '',
iconUrl: '/api/v1/cloudron/avatar',
name: client?.name || ''
};
// check if user has access to the app if client refers to an app
if (client.appId) {
const app = await apps.get(client.appId);
if (app) {
const user = await users.get(session.accountId);
options.name = app.label || app.fqdn;
options.iconUrl = app.iconUrl;
options.hasAccess = apps.canAccess(app, user);
} else {
options.hasAccess = true;
@@ -437,10 +492,8 @@ function renderInteractionPage(provider) {
return undefined;
}
} catch (error) {
debug('route interaction get error');
console.log(error);
return next(error);
debug('route interaction get error', error);
return res.render('error', { errorMessage: error.error_description || 'Internal error' });
}
};
}
@@ -452,12 +505,9 @@ function interactionLogin(provider) {
const [detailsError, details] = await safe(provider.interactionDetails(req, res));
if (detailsError) return next(new HttpError(500, detailsError));
const uid = details.uid;
const prompt = details.prompt;
const name = prompt.name;
debug(`route interaction login post uid:${uid} prompt.name:${name}`);
assert.equal(name, 'login');
if (!req.body.username || typeof req.body.username !== 'string') return next(new HttpError(400, 'A username must be non-empty string'));
@@ -470,9 +520,9 @@ function interactionLogin(provider) {
const [verifyError, user] = await safe(verifyFunc(username, password, users.AP_WEBADMIN, { totpToken }));
if (verifyError && verifyError.reason === BoxError.INVALID_CREDENTIALS) return next(new HttpError(401, verifyError.message));
if (verifyError && verifyError.reason === BoxError.NOT_FOUND) return next(new HttpError(401, 'Unauthorized'));
if (verifyError && verifyError.reason === BoxError.NOT_FOUND) return next(new HttpError(401, 'Username and password does not match'));
if (verifyError) return next(new HttpError(500, verifyError));
if (!user) return next(new HttpError(401, 'Unauthorized'));
if (!user) return next(new HttpError(401, 'Username and password does not match'));
// TODO we may have to check what else the Account class provides, in which case we have to map those things
const result = {
@@ -564,8 +614,6 @@ function interactionAbort(provider) {
assert.strictEqual(typeof provider, 'object');
return async function (req, res, next) {
debug('route interaction abort');
try {
const result = {
error: 'access_denied',
@@ -587,8 +635,6 @@ function interactionAbort(provider) {
* or not return them in id tokens but only userinfo and so on.
*/
async function claims(userId, use, scope) {
debug(`claims: userId:${userId} use:${use} scope:${scope}`);
const [error, user] = await safe(users.get(userId));
if (error) return { error: 'user not found' };
@@ -608,8 +654,6 @@ async function claims(userId, use, scope) {
preferred_username: user.username
};
debug(`claims: userId:${userId} result`, claims);
return claims;
}
@@ -623,6 +667,7 @@ async function logoutSource(ctx, form) {
ctx.body = ejs.render(fs.readFileSync(path.join(__dirname, 'oidc_templates/logout.ejs'), 'utf8'), data, {});
}
// this is called if client has not specified a post_logout_redirect_uri
async function postLogoutSuccessSource(ctx) {
// const client = ctx.oidc.client || {}; // client is defined if the user chose to stay logged in with the OP
const data = {
@@ -633,8 +678,6 @@ async function postLogoutSuccessSource(ctx) {
}
async function findAccount(ctx, id) {
debug(`findAccount id:${id}`);
return {
accountId: id,
async claims(use, scope) { return await claims(id, use, scope); },
@@ -658,7 +701,7 @@ async function start() {
gHttpServer = http.createServer(app);
const { Provider } = await import('oidc-provider');
const Provider = (await import('oidc-provider')).default;
// TODO we may want to rotate those in the future
const jwksKeys = [];
@@ -711,6 +754,12 @@ async function start() {
postLogoutSuccessSource
},
},
responseTypes: [
'code',
'id_token', 'id_token token',
'code id_token', 'code token', 'code id_token token',
'none',
],
// if a client only has one redirect uri specified, the client does not have to provide it in the request
allowOmittingSingleRegisteredRedirectUri: true,
clients: [],
@@ -719,10 +768,31 @@ async function start() {
keys: [ 'cookiesecret1', 'cookiesecret2' ]
},
pkce: {
required: function pkceRequired(ctx, client) {
required: function pkceRequired(/*ctx, client*/) {
return false;
}
},
conformIdTokenClaims: false,
// https://github.com/panva/node-oidc-provider/blob/main/recipes/skip_consent.md
loadExistingGrant: async function (ctx) {
const grantId = ctx.oidc.result?.consent?.grantId
|| ctx.oidc.session.grantIdFor(ctx.oidc.client.clientId);
if (grantId) {
return await ctx.oidc.provider.Grant.find(grantId);
} else if (ctx.oidc.client.clientId === DASHBOARD_CLIENT_ID || ctx.oidc.client.clientId === DEV_CLIENT_ID) {
const grant = new ctx.oidc.provider.Grant({
clientId: ctx.oidc.client.clientId,
accountId: ctx.oidc.session.accountId,
});
grant.addOIDCScope('openid email profile');
// grant.addOIDCClaims(['first_name']);
await grant.save();
return grant;
}
},
ttl: {
// in seconds, can also be a function returning the seconds https://github.com/panva/node-oidc-provider/blob/b1c1a9318036c2d3793cc9e668f99937c5c36bc6/docs/README.md#ttl
AccessToken: 3600, // 1 hour
@@ -754,6 +824,7 @@ async function start() {
app.get (`${ROUTE_PREFIX}/interaction/:uid/abort`, setNoCache, interactionAbort(provider));
app.use(ROUTE_PREFIX, provider.callback());
app.use(middleware.lastMile());
await util.promisify(gHttpServer.listen.bind(gHttpServer))(constants.OIDC_PORT, '127.0.0.1');
}
+5 -1
View File
@@ -6,6 +6,10 @@
<title>OpenID Connect Error</title>
<link id="favicon" type="image/png" rel="icon" href="/api/v1/cloudron/avatar">
<link rel="apple-touch-icon" href="/api/v1/cloudron/avatar">
<link rel="icon" href="/api/v1/cloudron/avatar">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
@@ -53,7 +57,7 @@
<div class="col-md-12" style="text-align: center;">
<img width="128" height="128" class="avatar" src="/api/v1/cloudron/avatar"/>
<br/>
<h1>OpenID Connect Error</h1>
<h2>OpenID Connect Error</h2>
</div>
</div>
<br/>
+19 -11
View File
@@ -6,6 +6,10 @@
<title>Authorize <%= name %></title>
<link id="favicon" type="image/png" rel="icon" href="/api/v1/cloudron/avatar">
<link rel="apple-touch-icon" href="/api/v1/cloudron/avatar">
<link rel="icon" href="/api/v1/cloudron/avatar">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
@@ -46,35 +50,39 @@
</head>
<body>
<% if (hasAccess) { -%>
<form id="submitForm" method="post" action="<%= submitUrl %>">
<!-- <button type="submit"></button> -->
</form>
<% } else { -%>
<div class="layout-root">
<div class="layout-content">
<div class="card">
<div class="row">
<div class="col-md-12" style="text-align: center;">
<img width="128" height="128" class="avatar" src="/api/v1/cloudron/avatar"/>
<img width="128" height="128" class="avatar" src="<%= iconUrl %>"/>
<br/>
<% if (hasAccess) { -%>
<h3>Authorize for <b><%= name %></b></h3>
<% } else { -%>
<h3>You do not have access to <b><%= name %></b></h3>
<% } -%>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12 text-center">
<% if (hasAccess) { -%>
<form method="post" action="<%= submitUrl %>">
<button class="btn btn-primary btn-outline" type="submit">Authorize</button>
</form>
<% } else { -%>
<a class="btn btn-primary btn-outline" href="<%= submitUrl %>">Continue</a>
<% } -%>
</div>
</div>
</div>
</div>
</div>
<% } -%>
<script>
<% if (hasAccess) { -%>
document.getElementById('submitForm').submit();
<% } -%>
</script>
</body>
</html>
+42 -11
View File
@@ -4,7 +4,11 @@
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<title>Login to <%= name %></title>
<title>{{ login.loginTo }} <%= name %></title>
<link id="favicon" type="image/png" rel="icon" href="/api/v1/cloudron/avatar">
<link rel="apple-touch-icon" href="/api/v1/cloudron/avatar">
<link rel="icon" href="/api/v1/cloudron/avatar">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
@@ -16,6 +20,8 @@
<script type="text/javascript" src="/3rdparty/js/jquery.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/password-reveal.js"></script>
<style>
.card {
@@ -51,9 +57,9 @@
<div class="card">
<div class="row">
<div class="col-md-12" style="text-align: center;">
<img width="128" height="128" class="avatar" src="/api/v1/cloudron/avatar"/>
<img width="128" height="128" class="avatar" src="<%= iconUrl %>"/>
<br/>
<h1>Login to <%= name %></h1>
<h1><small>{{ login.loginTo }}</small> <%= name %></h1>
</div>
</div>
<br/>
@@ -61,18 +67,23 @@
<div class="col-md-12">
<form id="loginForm">
<div class="form-group">
<label class="control-label" for="inputUsername">Username</label>
<label class="control-label" for="inputUsername">{{ login.username }}</label>
<input type="text" class="form-control" id="inputUsername" name="username" autofocus required>
</div>
<div class="form-group">
<label class="control-label" for="inputPassword">Password</label>
<label class="control-label" for="inputPassword">{{ login.password }}</label>
<input type="password" class="form-control" name="password" id="inputPassword" required password-reveal>
<p class="has-error" id="passwordError"></p>
</div>
<div class="form-group">
<label class="control-label" for="inputTotpToken">2FA Token</label>
<label class="control-label" for="inputTotpToken">{{ login.2faToken }}</label>
<input type="text" class="form-control" name="totpToken" id="inputTotpToken" value="">
<p class="has-error" id="totpError"></p>
</div>
<div class="card-form-bottom-bar">
<a href="/passwordreset.html">{{ login.resetPasswordAction }}</a>
<button class="btn btn-primary btn-outline" type="submit">{{ login.signInAction }}</button>
</div>
<button class="btn btn-primary btn-outline pull-right" type="submit">Log in</button>
</form>
</div>
</div>
@@ -85,6 +96,9 @@
document.getElementById('loginForm').addEventListener('submit', function (event) {
event.preventDefault();
document.getElementById('passwordError').innerText = '';
document.getElementById('totpError').innerText = '';
var apiUrl = '<%= submitUrl %>';
console.log('submit', apiUrl);
@@ -94,19 +108,36 @@ document.getElementById('loginForm').addEventListener('submit', function (event)
totpToken: document.getElementById('inputTotpToken').value
};
let res;
fetch(apiUrl, {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-type': 'application/json; charset=UTF-8' }
}).then(function (response) {
if (response.ok) return response.json();
return Promise.reject(response);
res = response;
return res.json(); // we always return objects
}).then(function (data) {
if (res.status === 401) {
if (data.message === 'Username and password does not match') {
document.getElementById('inputPassword').value = '';
document.getElementById('inputPassword').focus();
document.getElementById('passwordError').innerText = '{{ login.errorIncorrectCredentials }}';
} else if (data.message.indexOf('totpToken') !== -1) {
document.getElementById('inputTotpToken').value = '';
document.getElementById('inputTotpToken').focus();
document.getElementById('totpError').innerText = '{{ login.errorIncorrect2FAToken }}';
} else {
throw new Error('Something went wrong');
}
} else if (res.status !== 200) {
throw new Error('Something went wrong');
}
if (data.redirectTo) window.location.href = data.redirectTo;
else console.log('login success but missing redirectTo in data:', data);
}).catch(function (error) {
if (error.status === 401) document.getElementById('inputPassword').value = ''
console.warn('Something went wrong.', error);
document.getElementById('passwordError').innerText = '{{ login.errorInternal }}';
console.warn(error, res);
});
});
+4
View File
@@ -6,6 +6,10 @@
<title>OpenID Logout</title>
<link id="favicon" type="image/png" rel="icon" href="/api/v1/cloudron/avatar">
<link rel="apple-touch-icon" href="/api/v1/cloudron/avatar">
<link rel="icon" href="/api/v1/cloudron/avatar">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
+5 -7
View File
@@ -6,6 +6,10 @@
<title>Cloudron Logout</title>
<link id="favicon" type="image/png" rel="icon" href="/api/v1/cloudron/avatar">
<link rel="apple-touch-icon" href="/api/v1/cloudron/avatar">
<link rel="icon" href="/api/v1/cloudron/avatar">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
@@ -53,13 +57,7 @@
<div class="col-md-12" style="text-align: center;">
<img width="128" height="128" class="avatar" src="/api/v1/cloudron/avatar"/>
<br/>
<h1>Succesfully logged out</h1>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12 text-center">
<a href="<%- dashboardOrigin %>" class="btn btn-primary btn-outline">Open dashboard</a>
<h2>Succesfully logged out</h2>
</div>
</div>
</div>
+1 -1
View File
@@ -50,13 +50,13 @@ exports = module.exports = {
FIREWALL_BLOCKLIST_FILE: path.join(baseDir(), 'platformdata/firewall/blocklist.txt'),
LDAP_ALLOWLIST_FILE: path.join(baseDir(), 'platformdata/firewall/ldap_allowlist.txt'),
REVERSE_PROXY_REBUILD_FILE: path.join(baseDir(), 'platformdata/nginx/rebuild-needed'),
NGINX_TRUSTED_IPS_FILE: path.join(baseDir(), 'platformdata/nginx/trusted.ips'),
BOX_DATA_DIR: path.join(baseDir(), 'boxdata/box'),
MAIL_DATA_DIR: path.join(baseDir(), 'boxdata/mail'),
LOG_DIR: path.join(baseDir(), 'platformdata/logs'),
TASKS_LOG_DIR: path.join(baseDir(), 'platformdata/logs/tasks'),
CRASH_LOG_DIR: path.join(baseDir(), 'platformdata/logs/crash'),
BOX_LOG_FILE: path.join(baseDir(), 'platformdata/logs/box.log'),
GHOST_USER_FILE: path.join(baseDir(), 'platformdata/cloudron_ghost.json'),
+3 -3
View File
@@ -13,7 +13,6 @@ const apps = require('./apps.js'),
BoxError = require('./boxerror.js'),
constants = require('./constants.js'),
debug = require('debug')('box:platform'),
delay = require('./delay.js'),
fs = require('fs'),
infra = require('./infra_version.js'),
locker = require('./locker.js'),
@@ -23,6 +22,7 @@ const apps = require('./apps.js'),
services = require('./services.js'),
shell = require('./shell.js'),
tasks = require('./tasks.js'),
timers = require('timers/promises'),
volumes = require('./volumes.js'),
_ = require('underscore');
@@ -74,7 +74,7 @@ async function start(options) {
const retry = error.reason === BoxError.DATABASE_ERROR && (error.code === 'PROTOCOL_CONNECTION_LOST' || error.code === 'ECONNREFUSED');
debug(`Failed to start services. retry=${retry} (attempt ${attempt}): ${error.message}`);
if (!retry) throw error; // refuse to start
await delay(10000);
await timers.setTimeout(10000);
}
}
@@ -113,7 +113,7 @@ async function pruneInfraImages() {
for (const line of lines) {
if (!line) continue;
const parts = line.split(' '); // [ ID, Repo:Tag@Digest ]
const normalizedTag = parts[1].replace('registry.ipv6.docker.com/', '').replace('registry-1.docker.io/', '');
const normalizedTag = parts[1].replace('registry.ipv6.docker.com/', '').replace('registry-1.docker.io/', '').replace('registry.docker.com', '');
if (image.tag === normalizedTag) continue; // keep
debug(`pruneInfraImages: removing unused image of ${image.repo}: tag: ${parts[1]} id: ${parts[0]}`);
+2 -2
View File
@@ -3,7 +3,7 @@
exports = module.exports = promiseRetry;
const assert = require('assert'),
delay = require('./delay.js'),
timers = require('timers/promises'),
util = require('util');
async function promiseRetry(options, asyncFunction) {
@@ -19,7 +19,7 @@ async function promiseRetry(options, asyncFunction) {
if (i === times - 1) throw error;
if (options.retry && !options.retry(error)) throw error; // no more retry
if (options.debug) options.debug(`Attempt ${i+1} failed. Will retry: ${error.message}`);
await delay(interval);
await timers.setTimeout(interval);
}
}
}
+2 -3
View File
@@ -28,8 +28,7 @@ const assert = require('assert'),
paths = require('./paths.js'),
users = require('./users.js'),
tld = require('tldjs'),
tokens = require('./tokens.js'),
_ = require('underscore');
tokens = require('./tokens.js');
// we cannot use tasks since the tasks table gets overwritten when db is imported
const gProvisionStatus = {
@@ -245,7 +244,7 @@ async function getStatus() {
const allSettings = await settings.list();
return _.extend({
return Object.assign({
version: constants.VERSION,
apiServerOrigin: settings.apiServerOrigin(), // used by CaaS tool
webServerOrigin: settings.webServerOrigin(), // used by CaaS tool
+28 -2
View File
@@ -27,7 +27,10 @@ exports = module.exports = {
removeAppConfigs,
restoreFallbackCertificates,
handleCertificateProviderChanged
handleCertificateProviderChanged,
getTrustedIps,
setTrustedIps
};
const acme2 = require('./acme2.js'),
@@ -52,7 +55,8 @@ const acme2 = require('./acme2.js'),
settings = require('./settings.js'),
shell = require('./shell.js'),
sysinfo = require('./sysinfo.js'),
util = require('util');
util = require('util'),
validator = require('validator');
const NGINX_APPCONFIG_EJS = fs.readFileSync(__dirname + '/nginxconfig.ejs', { encoding: 'utf8' });
const RESTART_SERVICE_CMD = path.join(__dirname, 'scripts/restartservice.sh');
@@ -728,3 +732,25 @@ async function handleCertificateProviderChanged(domain) {
safe.fs.appendFileSync(paths.REVERSE_PROXY_REBUILD_FILE, `${domain}\n`, 'utf8');
}
async function getTrustedIps() {
return await settings.getTrustedIps();
}
async function setTrustedIps(trustedIps) {
assert.strictEqual(typeof trustedIps, 'string');
let trustedIpsConfig = 'real_ip_header X-Forwarded-For;\nreal_ip_recursive on;\n';
for (const line of trustedIps.split('\n')) {
if (!line || line.startsWith('#')) continue;
const rangeOrIP = line.trim();
// this checks for IPv4 and IPv6
if (!validator.isIP(rangeOrIP) && !validator.isIPRange(rangeOrIP)) throw new BoxError(BoxError.BAD_FIELD, `${rangeOrIP} is not a valid IP or range`);
trustedIpsConfig += `set_real_ip_from ${rangeOrIP};\n`;
}
await settings.setTrustedIps(trustedIps);
if (!safe.fs.writeFileSync(paths.NGINX_TRUSTED_IPS_FILE, trustedIpsConfig, 'utf8')) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
await reload();
}
+7
View File
@@ -6,6 +6,7 @@ exports = module.exports = {
create,
cleanup,
remount,
getMountStatus
};
const assert = require('assert'),
@@ -63,3 +64,9 @@ async function remount(req, res, next) {
next(new HttpSuccess(202, {}));
}
async function getMountStatus(req, res, next) {
const [error, mountStatus] = await safe(backups.getMountStatus());
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, mountStatus));
}

Some files were not shown because too many files have changed in this diff Show More