Compare commits

..

43 Commits

Author SHA1 Message Date
Johannes Zellner e2f4e9f30a filemanager: overwrite on upload by default for now 2024-08-20 18:31:31 +02:00
Girish Ramakrishnan 44011afd14 apps: remove port min/max tooltip
min should also be 1, otherwise you cannot go back to say port 53
2024-08-20 18:18:24 +02:00
Girish Ramakrishnan cebaa71ce1 cloudron-support: improved dns check 2024-08-20 16:52:48 +02:00
Johannes Zellner 0ed9105a05 frontend: just use vue essential linter ruleset 2024-08-19 19:27:15 +02:00
Johannes Zellner 69ecbe5ad7 filemanager: fix upload cancellation 2024-08-19 17:09:04 +02:00
Johannes Zellner a218761e99 frontend: fix various linter issues 2024-08-19 16:53:10 +02:00
Johannes Zellner 71d167d5fb Use local eslint in frontend 2024-08-19 16:12:43 +02:00
Johannes Zellner aabdea8627 New sftp addon version to not overwrite files 2024-08-19 14:38:53 +02:00
Johannes Zellner f220a1384c frontend: do not set content-length header on upload 2024-08-19 14:19:47 +02:00
Johannes Zellner e438ade08e frontend: update pankow 2024-08-19 13:30:59 +02:00
Johannes Zellner ed1d537f60 Use sftp addong 3.8.9 to fix file upload on drop 2024-08-19 12:31:10 +02:00
Johannes Zellner d59bc05f12 filemanager: support multi folder/files drops 2024-08-19 12:23:35 +02:00
Johannes Zellner 4608301f1c frontend: update dependencies 2024-08-19 11:47:43 +02:00
Girish Ramakrishnan a865320e3a 8.0.4 changes 2024-08-18 10:40:40 +02:00
Girish Ramakrishnan bc8c01900b HOST_PORT_MIN is incorrect 2024-08-17 16:32:56 +02:00
Girish Ramakrishnan 9704eefc21 backupcleaner: do not remove the backup in progress
the backup cleaner erroneously removes any "creating" state backups.
backups that are stuck are cleaned up elsewhere already (in the
backup retention logic with discardReason of "creating-too-long").
the missing backup logic is intended for any upstream lifecycle policies.
2024-08-15 15:53:31 +02:00
Girish Ramakrishnan 52cd52d83c lint 2024-08-15 15:46:19 +02:00
Girish Ramakrishnan 4a29371907 s3: sometimes message is null and only code is valid 2024-08-13 07:08:33 +02:00
Girish Ramakrishnan 1e5e4e3189 ionos: add contract-owned eu-central-3 2024-08-12 15:56:18 +02:00
Girish Ramakrishnan 041f7da59b backups: make noop upload work again 2024-08-12 10:05:14 +02:00
Girish Ramakrishnan 4dae3447d6 backups: noop provider has no location 2024-08-12 09:58:44 +02:00
Girish Ramakrishnan 7391af6f08 tail does not support doubledash it seems 2024-08-10 11:13:07 +02:00
Girish Ramakrishnan 8a640c8219 better app autoupdate logs 2024-08-10 11:04:17 +02:00
Girish Ramakrishnan 2857582f46 add note on UI timestamps 2024-08-09 14:57:50 +02:00
Johannes Zellner 1d80f03c38 dashboard: remove mailbox import/export feature 2024-08-08 15:48:47 +02:00
Johannes Zellner d7c20048fe dashboard: remove random console.log 2024-08-08 15:39:09 +02:00
Johannes Zellner cbbdb77a6e dashboard: remove hidden user import/export feature 2024-08-08 15:39:09 +02:00
Girish Ramakrishnan 2ff995aa95 filemanager: do not respond again 2024-08-08 15:20:50 +02:00
Girish Ramakrishnan 21705a0e96 volumes: /mnt/volumes is reserved 2024-08-08 14:45:50 +02:00
Girish Ramakrishnan c03da3be54 volumes: check provider instead of hostPath 2024-08-08 14:41:43 +02:00
Girish Ramakrishnan 69f48ed11a apps: do not log app logs to output 2024-08-07 15:51:04 +02:00
Johannes Zellner caa0c342a4 sftp: restore mode and owner 2024-08-01 21:44:34 +02:00
Johannes Zellner 01b4388b3c Update dependencies 2024-08-01 18:28:29 +02:00
Girish Ramakrishnan b870f98ec2 proxy-middleware: no more a middleware 2024-07-30 13:34:41 +02:00
Girish Ramakrishnan a5249102f2 proxy-middleware: just pass a string 2024-07-30 12:04:35 +02:00
Girish Ramakrishnan 5aa0c57a74 proxy-middleware: remove https and custom headers 2024-07-30 11:46:54 +02:00
Girish Ramakrishnan 053b076af0 proxy-middleware: remove via header and cookie support 2024-07-30 11:35:46 +02:00
Girish Ramakrishnan 247309e11b use constant 2024-07-30 11:00:50 +02:00
Johannes Zellner c9fe08e7b7 dashboard: also render checklist items in apps.html 2024-07-30 09:47:06 +02:00
Girish Ramakrishnan 468d4dd9b0 ami: imdsv2 support
https://aws.amazon.com/blogs/security/defense-in-depth-open-firewalls-reverse-proxies-ssrf-vulnerabilities-ec2-instance-metadata-service/

One has to get a token now via PUT. This is because there is a bunch of
open proxies out there which blindly forwarded everything to internal network
including metadata requests. They have found that PUT requests don't cleanly
proxy and also AWS rejects token requests with X-Forwarded-For.
2024-07-27 14:48:42 +02:00
Johannes Zellner 6056ba6475 Another missing check for manifest.addons 2024-07-27 11:56:36 +02:00
Johannes Zellner 4f03a6fb58 dashboard: mailbox edit dialog is not really a form with submit action
As a form with a submit button the browser tries to be smart which will
trigger the next button tag as enter action on a textinput
2024-07-26 18:57:45 +02:00
Girish Ramakrishnan d8aa4bc5e4 filemanager: fix sending of double header
we should not proceed to notFoundHandler if proxy handled it just fine
2024-07-26 11:58:41 +02:00
40 changed files with 1733 additions and 1012 deletions
-25
View File
@@ -1,25 +0,0 @@
{
"env": {
"node": true,
"es6": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 13
},
"rules": {
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"always"
],
"no-console": "off"
}
}
-5
View File
@@ -1,5 +0,0 @@
{
"node": true,
"unused": true,
"esversion": 11
}
+12
View File
@@ -2818,3 +2818,15 @@
* logs: fix recursion when displaying box logs
* frontend: fix clear view in logs viewer
* dashboard: support links/markdown in checklist items
[8.0.4]
* ami: IMDv2 support
* ionos: add contract-owned eu-central-3
* dashboard: remove mailbox import/export feature
* backupcleaner: do not remove the backup in progress
* backups: make noop upload work again
* volumes: `/mnt/volumes` is reserved
* apps: do not log app logs to output
* sftp: restore mode and owner
* dashboard: also render checklist items in apps.html
+4 -5
View File
@@ -240,9 +240,10 @@ const ENDPOINTS_OVH = [
// https://docs.ionos.com/cloud/managed-services/s3-object-storage/endpoints
const REGIONS_IONOS = [
{ 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
{ name: 'Berlin (eu-central-3)', value: 'https://s3.eu-central-3.ionoscloud.com', region: 'de' }, // default. contract-owned
{ name: 'Frankfurt (DE)', value: 'https://s3.eu-central-1.ionoscloud.com', region: 'de' },
{ name: 'Berlin (eu-central-2)', value: 'https://s3-eu-central-2.ionoscloud.com', region: 'eu-central-2' },
{ name: 'Logrono (eu-south-2)', value: 'https://s3-eu-south-2.ionoscloud.com', region: 'eu-south-2' },
];
// this is not used anywhere because upcloud needs endpoint URL. we detect region from the URL (https://upcloud.com/data-centres)
@@ -468,8 +469,6 @@ function redirectIfNeeded(status, currentView) {
return false;
}
console.log(status, currentView);
if (status.activated) {
console.log('Already activated');
if (currentView === 'dashboard') {
+1 -1
View File
@@ -870,7 +870,7 @@
},
"timezone": {
"title": "Time Zone",
"description": "The current timezone setting is <b>{{ timeZone }}</b>.\nThis setting is used for scheduling backup and update tasks."
"description": "The current timezone setting is <b>{{ timeZone }}</b>. This setting is used for scheduling backup and update tasks. Timestamps in the UI are always displayed using the browser's timezone."
},
"updates": {
"title": "Updates",
+6 -5
View File
@@ -1038,7 +1038,7 @@
"packageVersion": "Pakketversie",
"lastUpdated": "Laatst geüpdatet",
"checkForUpdatesAction": "Controleer op updates",
"customAppUpdateInfo": "Er zijn geen updates beschikbaar voor deze maatwerk app",
"customAppUpdateInfo": "Auto-update is niet beschikbaar voor maatwerk apps.",
"updateAvailableAction": "Update beschikbaar",
"repository": "Pakket Opslagplaats",
"installedAt": "Geïnstalleerd op"
@@ -1047,9 +1047,9 @@
"title": "Automatische updates",
"enabled": "Automatische updates zijn momenteel ingeschakeld.",
"disabled": "Automatische updates zijn momenteel uitgeschakeld.",
"disableAction": "Uitschakelen",
"enableAction": "Inschakelen",
"description": "Cloudron controleert de App Store periodiek op updates. Als je dit uitschakelt zorg er dan voor dat je updates handmatig installeert."
"disableAction": "Auto-update uitschakelen",
"enableAction": "Auto-update inschakelen",
"description": "Cloudron controleert periodiek de <a href=\"{{ appStoreLink }}\" target=\"_blank\">App Store</a> op updates."
},
"noUpdates": "Geen nieuwe updates beschikbaar"
},
@@ -1492,7 +1492,8 @@
"upload": {
"title": "Uploaden bestand naar {{ name }}"
},
"uploadToTmp": "Upload naar /tmp"
"uploadToTmp": "Upload naar /tmp",
"uploadTo": "Upload naar {{ path }}"
},
"filemanager": {
"title": "Bestandsbeheer",
+2 -2
View File
@@ -632,7 +632,7 @@
<label class="control-label" for="inputPortInfo{{env}}"><input type="checkbox" ng-model="clone.portsEnabled[env]">
{{ info.title }}
<sup>
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}} ({{ HOST_PORT_MIN }} - {{ HOST_PORT_MAX }})"><i class="fa fa-question-circle"></i></a>
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}}"><i class="fa fa-question-circle"></i></a>
</sup>
<small style="padding-left: 5px;" ng-show="info.readOnly">{{ 'appstore.installDialog.portReadOnly' | tr }}</small>
</label>
@@ -938,7 +938,7 @@
<label class="control-label" style="width: 100%" for="locationPortInput{{env}}"><input type="checkbox" ng-model="location.portsEnabled[env]">
{{ info.title }}
<sup>
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}}. {{info.portCount >=1 ? (info.portCount + ' ports. ') : ''}} ({{ HOST_PORT_MIN }} - {{ HOST_PORT_MAX }})"><i class="fa fa-question-circle"></i></a>
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}}. {{info.portCount >=1 ? (info.portCount + ' ports. ') : ''}}"><i class="fa fa-question-circle"></i></a>
</sup>
<small style="padding-left: 5px;" ng-show="info.readOnly">{{ 'appstore.installDialog.portReadOnly' | tr }}</small>
<span ng-show="info.portCount" style="display: block; float: right">{{ location.ports[env] }} to {{ location.ports[env] + info.portCount - 1 }} ({{ info.portCount }} ports)</span>
+1 -1
View File
@@ -21,7 +21,7 @@
<div ng-show="appPostInstallConfirm.app.manifest.documentationUrl" ng-bind-html="'app.appInfo.appDocsUrl' | tr:{ docsUrl: appPostInstallConfirm.app.manifest.documentationUrl, title: appPostInstallConfirm.app.manifest.title, forumUrl: (appPostInstallConfirm.app.manifest.forumUrl || 'https://forum.cloudron.io') }"></div>
<div ng-repeat="item in appPostInstallConfirm.app.checklist">
<div class="checklist-item" ng-hide="item.acknowledged">
{{ item.message }}
<span ng-bind-html="item.message | markdown2html"></span>
</div>
</div>
</div>
+2 -2
View File
@@ -75,7 +75,7 @@
<label class="control-label" for="inputPortInfo{{env}}"><input type="checkbox" ng-model="appInstall.portsEnabled[env]">
{{ info.title }}
<sup>
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}}. {{info.portCount >=1 ? (info.portCount + ' ports. ') : ''}} ({{ HOST_PORT_MIN }} - {{ HOST_PORT_MAX }})"><i class="fa fa-question-circle"></i></a>
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}}. {{info.portCount >=1 ? (info.portCount + ' ports. ') : ''}}"><i class="fa fa-question-circle"></i></a>
</sup>
<small style="padding-left: 5px;" ng-show="info.readOnly">{{ 'appstore.installDialog.portReadOnly' | tr }}</small>
</label>
@@ -407,7 +407,7 @@
<center>
<a href="" ng-click="appstoreLogin.setupType = 'signup'" ng-show="appstoreLogin.setupType === 'login'">{{ 'appstore.accountDialog.switchToSignUpAction' | tr }}</a>
<a href="" ng-click="appstoreLogin.setupType = 'login'" ng-show="appstoreLogin.setupType === 'signup' || appstoreLogin.setupType === 'setupToken'">{{ 'appstore.accountDialog.switchToLoginAction' | tr }}</a>
<span ng-show="appstoreLogin.setupType !== 'setupToken'"> or <a href="" ng-click="appstoreLogin.setupType = 'setupToken'">use a setup token</a></span>
<span ng-show="appstoreLogin.setupType !== 'setupToken'"> or <a href="" ng-click="appstoreLogin.setupType = 'setupToken'">Use a setup token</a></span>
</center>
</div>
</div>
+1 -1
View File
@@ -10,7 +10,7 @@
angular.module('Application').controller('AppStoreController', ['$scope', '$translate', '$location', '$timeout', '$routeParams', 'Client', function ($scope, $translate, $location, $timeout, $routeParams, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
$scope.HOST_PORT_MIN = 1024;
$scope.HOST_PORT_MIN = 1;
$scope.HOST_PORT_MAX = 65535;
$scope.ready = false;
+1 -1
View File
@@ -476,7 +476,7 @@
<span>{{ prettyProviderName(backupConfig.provider) }}</span>
</div>
</div>
<div class="row">
<div class="row" ng-show="backupConfig.provider !== 'noop'">
<div class="col-xs-6">
<span class="text-muted">{{ 'backups.location.location' | tr }}</span>
</div>
+52 -107
View File
@@ -108,79 +108,74 @@
<h4 class="modal-title">{{ 'email.editMailboxDialog.title' | tr:{ name: mailboxes.edit.name, domain: domain.domain } }}</h4>
</div>
<div class="modal-body">
<form name="mailboxedit_form" role="form" ng-submit="mailboxes.edit.submit()" autocomplete="off">
<input type="password" style="display: none;">
<div class="form-group">
<label class="control-label">{{ 'email.editMailboxDialog.owner' | tr }}</label>
<div class="control-label">
<multiselect ng-model="mailboxes.edit.owner" options="o.display for o in owners" data-compare-by="name" data-header-key="header" data-divider-key="divider" data-multiple="false" filter-after-rows="5" scroll-after-rows="10"></multiselect>
</div>
<div class="form-group">
<label class="control-label">{{ 'email.editMailboxDialog.owner' | tr }}</label>
<div class="control-label">
<multiselect ng-model="mailboxes.edit.owner" options="o.display for o in owners" data-compare-by="name" data-header-key="header" data-divider-key="divider" data-multiple="false" filter-after-rows="5" scroll-after-rows="10"></multiselect>
</div>
</div>
<div class="form-group aliases">
<label class="control-label">{{ 'email.editMailboxDialog.aliases' | tr }}</label>
<div class="has-error" ng-show="mailboxes.edit.error">{{ mailboxes.edit.error.message }}</div>
<div class="form-group aliases">
<label class="control-label">{{ 'email.editMailboxDialog.aliases' | tr }}</label>
<div class="has-error" ng-show="mailboxes.edit.error">{{ mailboxes.edit.error.message }}</div>
<div class="row" ng-repeat="alias in mailboxes.edit.aliases | orderBy:'reversedSortingNotation'">
<div class="col col-lg-11">
<div class="input-group">
<input type="text" class="form-control input-sm" ng-model="alias.name" autofocus>
<div class="row" ng-repeat="alias in mailboxes.edit.aliases | orderBy:'reversedSortingNotation'">
<div class="col col-lg-11">
<div class="input-group">
<input type="text" class="form-control input-sm" ng-model="alias.name">
<div class="input-group-btn">
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
<span>@{{ alias.domain }}</span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right" role="menu">
<li ng-repeat="incomingDomain in incomingDomains">
<a href="" ng-click="alias.domain = incomingDomain.domain">{{ incomingDomain.domain }}</a>
</li>
</ul>
</div>
<div class="input-group-btn">
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
<span>@{{ alias.domain }}</span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right" role="menu">
<li ng-repeat="incomingDomain in incomingDomains">
<a href="" ng-click="alias.domain = incomingDomain.domain">{{ incomingDomain.domain }}</a>
</li>
</ul>
</div>
</div>
<div class="col col-lg-1">
<button class="btn btn-danger btn-sm" ng-click="mailboxes.edit.delAlias($event, alias)"><i class="far fa-trash-alt"></i></button>
</div>
</div>
<div ng-show="mailboxes.edit.aliases.length === 0">
{{ 'email.editMailboxDialog.noAliases' | tr }} <a href="" ng-click="mailboxes.edit.addAlias($event)">{{ 'email.editMailboxDialog.addAliasAction' | tr }}</a>
</div>
<div ng-show="mailboxes.edit.aliases.length > 0" style="margin-top: 5px;">
<a href="" ng-click="mailboxes.edit.addAlias($event)">{{ 'email.editMailboxDialog.addAnotherAliasAction' | tr }}</a>
<div class="col col-lg-1">
<button class="btn btn-danger btn-sm" ng-click="mailboxes.edit.delAlias($event, alias)"><i class="far fa-trash-alt"></i></button>
</div>
</div>
<div class="form-group">
<label for="storageQuota">
<input id="storageQuota" type="checkbox" ng-model="mailboxes.edit.storageQuotaEnabled">
{{ 'email.editMailboxDialog.enableStorageQuota' | tr }} <b ng-hide="!mailboxes.edit.storageQuotaEnabled">: {{ mailboxes.edit.storageQuota | prettyDecimalSize }}</b>
</input>
</label>
<input type="range" id="storageQuota" ng-disabled="!mailboxes.edit.storageQuotaEnabled" ng-model="mailboxes.edit.storageQuota" step="500000000" min="{{ storageQuotaTicks[0] }}" max="{{ storageQuotaTicks[storageQuotaTicks.length-1] }}" list="storageQuotaTicks" />
<datalist id="storageQuotaTicks">
<option ng-repeat="quota in storageQuotaTicks" value="{{ quota }}"></option>
</datalist>
<div ng-show="mailboxes.edit.aliases.length === 0">
{{ 'email.editMailboxDialog.noAliases' | tr }} <a href="" ng-click="mailboxes.edit.addAlias($event)">{{ 'email.editMailboxDialog.addAliasAction' | tr }}</a>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="mailboxes.edit.enablePop3"> {{ 'email.updateMailboxDialog.enablePop3' | tr }}</input>
</label>
<div ng-show="mailboxes.edit.aliases.length > 0" style="margin-top: 5px;">
<a href="" ng-click="mailboxes.edit.addAlias($event)">{{ 'email.editMailboxDialog.addAnotherAliasAction' | tr }}</a>
</div>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="mailboxes.edit.active"> {{ 'email.updateMailboxDialog.activeCheckbox' | tr }}</input>
</label>
</div>
<div class="form-group">
<label for="storageQuota">
<input id="storageQuota" type="checkbox" ng-model="mailboxes.edit.storageQuotaEnabled">
{{ 'email.editMailboxDialog.enableStorageQuota' | tr }} <b ng-hide="!mailboxes.edit.storageQuotaEnabled">: {{ mailboxes.edit.storageQuota | prettyDecimalSize }}</b>
</input>
</label>
<input type="range" id="storageQuota" ng-disabled="!mailboxes.edit.storageQuotaEnabled" ng-model="mailboxes.edit.storageQuota" step="500000000" min="{{ storageQuotaTicks[0] }}" max="{{ storageQuotaTicks[storageQuotaTicks.length-1] }}" list="storageQuotaTicks" />
<datalist id="storageQuotaTicks">
<option ng-repeat="quota in storageQuotaTicks" value="{{ quota }}"></option>
</datalist>
</div>
<input class="hide" type="submit" ng-disabled="mailboxedit_form.$invalid || mailboxes.edit.busy || !mailboxes.edit.owner"/>
</form>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="mailboxes.edit.enablePop3"> {{ 'email.updateMailboxDialog.enablePop3' | tr }}</input>
</label>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="mailboxes.edit.active"> {{ 'email.updateMailboxDialog.activeCheckbox' | tr }}</input>
</label>
</div>
</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="mailboxes.edit.submit()" ng-disabled="mailboxedit_form.$invalid || mailboxes.edit.busy || !mailboxes.edit.owner"><i class="fa fa-circle-notch fa-spin" ng-show="mailboxes.edit.busy"></i> {{ 'main.dialog.save' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="mailboxes.edit.submit()" ng-disabled="mailboxes.edit.busy || !mailboxes.edit.owner"><i class="fa fa-circle-notch fa-spin" ng-show="mailboxes.edit.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
@@ -210,44 +205,6 @@
</div>
</div>
<!-- Modal import mailboxes -->
<div class="modal fade" id="mailboxImportModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'email.mailboxImportDialog.title' | tr }}</h4>
</div>
<div class="modal-body">
<div ng-show="!mailboxImport.done">
<div ng-show="!mailboxImport.busy">
<p ng-bind-html=" 'email.mailboxImportDialog.description' | tr:{ docsLink: 'https://cloudron.io/documentation/email/#import-mailboxes' } "></p>
<input type="file" style="display: none;" id="mailboxImportFileInput" accept="application/json,text/csv"/>
<button class="btn btn-primary" ng-click="mailboxImport.openFileInput()">{{ 'email.mailboxImportDialog.fileInput' | tr }}</button>
<br/>
<br/>
<p class="text-danger" ng-show="mailboxImport.error.file">{{ mailboxImport.error.file }}</p>
<p class="text-info" ng-show="mailboxImport.mailboxes.length">{{ 'email.mailboxImportDialog.mailboxesFound' | tr:{ count: mailboxImport.mailboxes.length } }}</p>
</div>
<div ng-show="mailboxImport.busy" class="progress progress-striped active">
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ mailboxImport.percent }}%"></div>
</div>
</div>
<div ng-show="mailboxImport.done">
<p>{{ 'email.mailboxImportDialog.success' | tr:{ count: mailboxImport.success } }}</p>
<div ng-show="mailboxImport.error.import.length">
<p class="text-danger">{{ 'email.mailboxImportDialog.failed' | tr }}</p>
<div ng-repeat="tmp in mailboxImport.error.import"><b>{{ tmp.mailbox.name }}@{{ tmp.mailbox.domain }}:</b> {{ tmp.error.message }}</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<button type="button" class="btn btn-primary" ng-click="mailboxImport.import()" ng-show="!mailboxImport.done" ng-disabled="mailboxImport.busy || !mailboxImport.mailboxes.length"><i class="fa fa-circle-notch fa-spin" ng-show="mailboxImport.busy"></i> {{ 'email.mailboxImportDialog.importAction' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal add mailinglist -->
<div class="modal fade" id="mailinglistAddModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
@@ -417,18 +374,6 @@
<div class="text-left">
<h3 style="margin-bottom: 15px;">{{ 'email.incoming.mailboxes.title' | tr }}
<button class="btn btn-primary btn-outline pull-right" ng-click="mailboxes.add.show()" ng-disabled="!domain.mailConfig.enabled" tooltip-enable="!domain.mailConfig.enabled" uib-tooltip="{{ 'email.incoming.mailboxes.disabledTooltip' | tr }}"><i class="fa fa-inbox"></i> {{ 'email.incoming.mailboxes.addAction' | tr }}</button>
<div class="btn-group pull-right" style="margin-left: 5px;">
<button class="btn btn-default" ng-click="mailboxImport.show()" uib-tooltip="{{ 'email.incoming.mailboxes.importTooltip' | tr }}" tooltip-append-to-body="true"><i class="fas fa-download"></i></button>
<div class="btn-group" role="group">
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown" uib-tooltip="{{ 'email.incoming.mailboxes.exportTooltip' | tr }}" tooltip-append-to-body="true">
<i class="fas fa-upload"></i>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="" ng-click="mailboxExport('csv')">{{ 'email.incoming.mailboxes.mailboxExport.csv' | tr }}</a></li>
<li><a href="" ng-click="mailboxExport('json')">{{ 'email.incoming.mailboxes.mailboxExport.json' | tr }}</a></li>
</ul>
</div>
</div>
<input class="form-control pull-right" style="width: 200px;" placeholder="{{ 'main.searchPlaceholder' | tr }}" type="text" ng-model="mailboxes.search" ng-model-options="{ debounce: 1000 }" ng-change="mailboxes.updateFilter()" />
</h3>
</div>
-166
View File
@@ -377,172 +377,6 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
}
};
$scope.mailboxImport = {
busy: false,
done: false,
error: null,
percent: 0,
success: 0,
mailboxes: [],
reset: function () {
$scope.mailboxImport.busy = false;
$scope.mailboxImport.error = null;
$scope.mailboxImport.mailboxes = [];
$scope.mailboxImport.percent = 0;
$scope.mailboxImport.success = 0;
$scope.mailboxImport.done = false;
},
handleFileChanged: function () {
$scope.mailboxImport.reset();
var fileInput = document.getElementById('mailboxImportFileInput');
if (!fileInput.files || !fileInput.files[0]) return;
var file = fileInput.files[0];
if (file.type !== 'application/json' && file.type !== 'text/csv') return console.log('Unsupported file type.');
const reader = new FileReader();
reader.addEventListener('load', function () {
$scope.$apply(function () {
$scope.mailboxImport.mailboxes = [];
var mailboxes = [];
if (file.type === 'text/csv') {
var lines = reader.result.split('\n');
if (lines.length === 0) return $scope.mailboxImport.error = { file: 'Imported file has no lines' };
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line) continue;
var items = line.split(',');
if (items.length !== 4) {
$scope.mailboxImport.error = { file: 'Line ' + (i+1) + ' has wrong column count. Expecting 4' };
return;
}
mailboxes.push({
name: items[0].trim(),
domain: items[1].trim(),
owner: items[2].trim(),
ownerType: items[3].trim(),
});
}
} else {
try {
mailboxes = JSON.parse(reader.result).map(function (mailbox) {
return {
name: mailbox.name,
domain: mailbox.domain,
owner: mailbox.owner,
ownerType: mailbox.ownerType
};
});
} catch (e) {
console.error('Failed to parse mailboxes.', e);
$scope.mailboxImport.error = { file: 'Imported file is not valid JSON' };
}
}
$scope.mailboxImport.mailboxes = mailboxes;
});
}, false);
reader.readAsText(file);
},
show: function () {
$scope.mailboxImport.reset();
// named so no duplactes
document.getElementById('mailboxImportFileInput').addEventListener('change', $scope.mailboxImport.handleFileChanged);
$('#mailboxImportModal').modal('show');
},
openFileInput: function () {
$('#mailboxImportFileInput').click();
},
import: function () {
$scope.mailboxImport.percent = 0;
$scope.mailboxImport.success = 0;
$scope.mailboxImport.done = false;
$scope.mailboxImport.error = { import: [] };
$scope.mailboxImport.busy = true;
var processed = 0;
async.eachSeries($scope.mailboxImport.mailboxes, function (mailbox, callback) {
var owner = $scope.owners.find(function (o) { return o.display === mailbox.owner && o.type === mailbox.ownerType; }); // owner may not exist
if (!owner) {
$scope.mailboxImport.error.import.push({ error: new Error('Could not detect owner'), mailbox: mailbox });
++processed;
$scope.mailboxImport.percent = 100 * processed / $scope.mailboxImport.mailboxes.length;
return callback();
}
Client.addMailbox(mailbox.domain, mailbox.name, owner.id, mailbox.ownerType, function (error) {
if (error) $scope.mailboxImport.error.import.push({ error: error, mailbox: mailbox });
else ++$scope.mailboxImport.success;
++processed;
$scope.mailboxImport.percent = 100 * processed / $scope.mailboxImport.mailboxes.length;
callback();
});
}, function (error) {
if (error) return console.error(error);
$scope.mailboxImport.busy = false;
$scope.mailboxImport.done = true;
if ($scope.mailboxImport.success) $scope.mailboxes.refresh();
});
}
};
$scope.mailboxExport = function (type) {
// FIXME only does first 10k mailboxes
Client.listMailboxes($scope.domain.domain, '', 1, 10000, function (error, result) {
if (error) {
Client.error('Failed to list mailboxes. Full error in the webinspector.');
return console.error('Failed to list mailboxes.', error);
}
var content = '';
if (type === 'json') {
content = JSON.stringify(result.map(function (mailbox) {
var owner = $scope.owners.find(function (o) { return o.id === mailbox.ownerId; }); // owner may not exist
return {
name: mailbox.name,
domain: mailbox.domain,
owner: owner ? owner.display : '', // this meta property is set when we get the user list
ownerType: owner ? owner.type : '',
active: mailbox.active,
aliases: mailbox.aliases
};
}), null, 2);
} else if (type === 'csv') {
content = result.map(function (mailbox) {
var owner = $scope.owners.find(function (o) { return o.id === mailbox.ownerId; }); // owner may not exist
var aliases = mailbox.aliases.map(function (a) { return a.name + '@' + a.domain; }).join(' ');
return [ mailbox.name, mailbox.domain, owner ? owner.display : '', owner ? owner.type : '', aliases, mailbox.active ].join(',');
}).join('\n');
} else {
return;
}
var file = new Blob([ content ], { type: type === 'json' ? 'application/json' : 'text/csv' });
var a = document.createElement('a');
a.href = URL.createObjectURL(file);
a.download = $scope.domain.domain.replaceAll('.','_') + '-mailboxes.' + type;
document.body.appendChild(a);
a.click();
});
};
$scope.mailboxes = {
mailboxes: [],
search: '',
-55
View File
@@ -292,48 +292,6 @@
</div>
</div>
<!-- Modal user import -->
<div class="modal fade" id="userImportModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'users.userImportDialog.title' | tr }}</h4>
</div>
<div class="modal-body">
<div ng-show="!userImport.done">
<div ng-show="!userImport.busy">
<p ng-bind-html=" 'users.userImportDialog.description' | tr:{ docsLink: 'https://docs.cloudron.io/user-management/#import-users' } "></p>
<input type="file" style="display: none;" id="userImportFileInput" accept="application/json,text/csv"/>
<button class="btn btn-primary" ng-click="userImport.openFileInput()">{{ 'users.userImportDialog.fileInput' | tr }}</button>
<br/>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="userImport.sendInvite" id="inputUserImportSendInvite"> {{ 'users.userImportDialog.sendInviteCheckbox' | tr }}
</label>
</div>
<p class="text-danger" ng-show="userImport.error.file">{{ userImport.error.file }}</p>
<p class="text-info" ng-show="userImport.users.length">{{ 'users.userImportDialog.usersFound' | tr:{ count: userImport.users.length } }}</p>
</div>
<div ng-show="userImport.busy" class="progress progress-striped active">
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ userImport.percent }}%"></div>
</div>
</div>
<div ng-show="userImport.done">
<p>{{ 'users.userImportDialog.success' | tr:{ count: userImport.success } }}</p>
<div ng-show="userImport.error.import.length">
<p class="text-danger">{{ 'users.userImportDialog.failed' | tr }}</p>
<div ng-repeat="tmp in userImport.error.import"><b>{{ tmp.user.email }}:</b> {{ tmp.error.message }}</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<button type="button" class="btn btn-primary" ng-click="userImport.import()" ng-show="!userImport.done" ng-disabled="userImport.busy || !userImport.users.length"><i class="fa fa-circle-notch fa-spin" ng-show="userImport.busy"></i> {{ 'users.userImportDialog.importAction' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal password reset -->
<div class="modal fade" id="passwordResetModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
@@ -454,19 +412,6 @@
<input type="text" id="userSearchInput" class="form-control" style="max-width: 350px;" ng-model="userSearchString" ng-model-options="{ debounce: 1000 }" ng-change="updateFilter()" placeholder="{{ 'main.searchPlaceholder' | tr }}"/>
<multiselect ng-model="userStateFilter" ms-header="{{ 'apps.stateFilterHeader' | tr }}" ms-selected="{{ userStateFilter }}" options="state.label for state in userStates" data-multiple="false"></multiselect>
<div style="flex-grow: 1;"></div>
<!-- import/export buttons are hidden until we figure what the exact use case is -->
<div class="btn-group" ng-hide="true">
<button class="btn btn-default" ng-click="userImport.show()" uib-tooltip="{{ 'users.userImport.tooltip' | tr }}" tooltip-append-to-body="true"><i class="fas fa-download"></i></button>
<div class="btn-group" role="group">
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown" uib-tooltip="{{ 'users.userExport.tooltip' | tr }}" tooltip-append-to-body="true">
<i class="fas fa-upload"></i>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="" ng-click="userExport('csv')">{{ 'users.userExport.csv' | tr }}</a></li>
<li><a href="" ng-click="userExport('json')">{{ 'users.userExport.json' | tr }}</a></li>
</ul>
</div>
</div>
<button class="btn btn-primary btn-outline" ng-click="userAdd.show()">
<i class="fa fa-user-plus"></i> {{ 'users.newUserAction' | tr }}
</button>
-165
View File
@@ -67,171 +67,6 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
return true;
};
$scope.userImport = {
busy: false,
done: false,
error: null,
percent: 0,
success: 0,
users: [],
sendInvite: false,
reset: function () {
$scope.userImport.busy = false;
$scope.userImport.error = null;
$scope.userImport.users = [];
$scope.userImport.percent = 0;
$scope.userImport.success = 0;
$scope.userImport.done = false;
$scope.userImport.sendInvite = false;
},
handleFileChanged: function () {
$scope.userImport.reset();
var fileInput = document.getElementById('userImportFileInput');
if (!fileInput.files || !fileInput.files[0]) return;
var file = fileInput.files[0];
if (file.type !== 'application/json' && file.type !== 'text/csv') return console.log('Unsupported file type.');
const reader = new FileReader();
reader.addEventListener('load', function () {
$scope.$apply(function () {
$scope.userImport.users = [];
var users = [];
if (file.type === 'text/csv') {
var lines = reader.result.split('\n');
if (lines.length === 0) return $scope.userImport.error = { file: 'Imported file has no lines' };
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line) continue;
var items = line.split(',');
if (items.length !== 5) {
$scope.userImport.error = { file: 'Line ' + (i+1) + ' has wrong column count. Expecting 5' };
return;
}
users.push({
username: items[0].trim(),
email: items[1].trim(),
fallbackEmail: items[2].trim(),
displayName: items[3].trim(),
role: items[4].trim()
});
}
} else {
try {
users = JSON.parse(reader.result).map(function (user) {
return {
username: user.username,
email: user.email,
fallbackEmail: user.fallbackEmail,
displayName: user.displayName,
role: user.role
};
});
} catch (e) {
console.error('Failed to parse users.', e);
$scope.userImport.error = { file: 'Imported file is not valid JSON:' + e.message };
}
}
$scope.userImport.users = users;
});
}, false);
reader.readAsText(file);
},
show: function () {
$scope.userImport.reset();
// named so no duplactes
document.getElementById('userImportFileInput').addEventListener('change', $scope.userImport.handleFileChanged);
$('#userImportModal').modal('show');
},
openFileInput: function () {
$('#userImportFileInput').click();
},
import: function () {
$scope.userImport.percent = 0;
$scope.userImport.success = 0;
$scope.userImport.done = false;
$scope.userImport.error = { import: [] };
$scope.userImport.busy = true;
var processed = 0;
async.eachSeries($scope.userImport.users, function (user, callback) {
Client.addUser(user, function (error, userId) {
if (error) $scope.userImport.error.import.push({ error: error, user: user });
else ++$scope.userImport.success;
++processed;
$scope.userImport.percent = 100 * processed / $scope.userImport.users.length;
if (!error && $scope.userImport.sendInvite) {
console.log('sending', userId, user.email);
Client.sendInviteEmail(userId, user.email, function (error) {
if (error) console.error('Failed to send invite.', error);
});
}
callback();
});
}, function (error) {
if (error) return console.error(error);
$scope.userImport.busy = false;
$scope.userImport.done = true;
if ($scope.userImport.success) {
refreshCurrentPage();
refreshAllUsers();
}
});
}
};
// supported types are 'json' and 'csv'
$scope.userExport = function (type) {
Client.getAllUsers(function (error, result) {
if (error) {
Client.error('Failed to list users. Full error in the webinspector.');
return console.error('Failed to list users.', error);
}
var content = '';
if (type === 'json') {
content = JSON.stringify(result.map(function (user) {
return {
id: user.id,
username: user.username,
email: user.email,
fallbackEmail: user.fallbackEmail,
displayName: user.displayName,
role: user.role,
active: user.active
};
}), null, 2);
} else if (type === 'csv') {
content = result.map(function (user) {
return [ user.id, user.username, user.email, user.fallbackEmail, user.displayName, user.role, user.active ].join(',');
}).join('\n');
} else {
return;
}
var file = new Blob([ content ], { type: type === 'json' ? 'application/json' : 'text/csv' });
var a = document.createElement('a');
a.href = URL.createObjectURL(file);
a.download = type === 'json' ? 'users.json' : 'users.csv';
document.body.appendChild(a);
a.click();
});
};
$scope.userRemove = {
busy: false,
error: null,
+22
View File
@@ -0,0 +1,22 @@
import globals from 'globals';
import js from '@eslint/js';
import pluginVue from 'eslint-plugin-vue';
export default [
js.configs.recommended,
...pluginVue.configs['flat/essential'],
{
files: ["**/*.js"],
languageOptions: {
globals: {
...globals.browser,
},
ecmaVersion: 13,
sourceType: 'module'
},
rules: {
semi: "error",
"prefer-const": "error"
}
}
];
+1360 -112
View File
File diff suppressed because it is too large Load Diff
+11 -8
View File
@@ -1,5 +1,5 @@
{
"name": "my-vue-app",
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
@@ -16,17 +16,20 @@
"anser": "^2.1.1",
"combokeys": "^3.0.1",
"filesize": "^10.1.4",
"marked": "^13.0.2",
"marked": "^13.0.3",
"moment": "^2.30.1",
"pankow": "^1.6.8",
"pankow": "^1.7.3",
"pankow-viewers": "^1.0.4",
"superagent": "^9.0.2",
"vue": "^3.4.33",
"vue-i18n": "^9.13.1",
"vue-router": "^4.4.0"
"vue": "^3.4.38",
"vue-i18n": "^9.14.0",
"vue-router": "^4.4.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.5",
"vite": "^5.3.4"
"@eslint/js": "^9.9.0",
"@vitejs/plugin-vue": "^5.1.2",
"eslint": "^9.9.0",
"eslint-plugin-vue": "^9.27.0",
"vite": "^5.4.1"
}
}
+8 -7
View File
@@ -67,13 +67,12 @@ export function createDirectoryModel(origin, accessToken, api) {
return result.body.entries;
},
async upload(targetDir, file, progressHandler) {
upload(targetDir, file, progressHandler) {
// file may contain a file name or a file path + file name
const relativefilePath = (file.webkitRelativePath ? file.webkitRelativePath : file.name);
const xhr = new XMLHttpRequest();
const req = new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response);
@@ -94,18 +93,20 @@ export function createDirectoryModel(origin, accessToken, api) {
if (event.loaded) progressHandler({ direction: 'upload', loaded: event.loaded});
});
xhr.open('POST', `${origin}/api/v1/${api}/files/${encodeURIComponent(sanitize(targetDir + '/' + relativefilePath))}?access_token=${accessToken}`);
xhr.open('POST', `${origin}/api/v1/${api}/files/${encodeURIComponent(sanitize(targetDir + '/' + relativefilePath))}?access_token=${accessToken}&overwrite=true`);
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
xhr.setRequestHeader('Content-Length', file.size);
xhr.send(file);
});
const res = await req;
// attach for upstream xhr.abort()
req.xhr = xhr;
return req;
},
async newFile(filePath) {
await this.save(filePath, '')
await this.save(filePath, '');
},
async newFolder(folderPath) {
await superagent.post(`${origin}/api/v1/${api}/files/${folderPath}?access_token=${accessToken}&directory=true`);
+110 -104
View File
@@ -213,6 +213,76 @@ export default {
this.loadCwd();
}
},
async mounted() {
this.busy = true;
const type = this.$route.params.type || 'app';
const resourceId = this.$route.params.resourceId;
const cwd = this.$route.params.cwd;
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);
return this.onFatalError(`Invalid resource ${type} ${resourceId}`);
}
this.appLink = `https://${result.body.fqdn}`;
this.title = `${result.body.label || result.body.fqdn} (${result.body.manifest.title})`;
} 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);
return this.onFatalError(`Invalid resource ${type} ${resourceId}`);
}
this.title = result.body.name;
} else {
return this.onFatalError(`Unsupported type ${type}`);
}
try {
const result = await superagent.get(`${this.apiOrigin}/api/v1/dashboard/config`).query({ access_token: this.accessToken });
this.footerContent = marked.parse(result.body.footer);
} catch (e) {
console.error('Failed to fetch Cloudron config.', e);
}
window.document.title = `File Manager - ${this.title}`;
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.ownersModel = this.directoryModel.ownersModel;
this.loadCwd();
this.$watch(() => this.$route.params, (toParams, previousParams) => {
if (toParams.type !== 'app' && toParams.type !== 'volume') return this.onFatalError(`Unknown type ${toParams.type}`);
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('/')}` : '/';
});
},
methods: {
onFatalError(errorMessage) {
this.fatalError = errorMessage;
@@ -225,8 +295,8 @@ export default {
this.$refs.uploadMenu.open(event, elem);
},
onCancelUpload() {
if (!this.uploadRequest) return;
this.uploadRequest.abort();
if (!this.uploadRequest || !this.uploadRequest.xhr) return;
this.uploadRequest.xhr.abort();
},
// generic dialog focus handler
onDialogShow(focusElementId) {
@@ -292,36 +362,50 @@ export default {
async onDrop(targetFolder, dataTransfer, files) {
const fullTargetFolder = sanitize(this.cwd + '/' + targetFolder);
// if dataTransfer is set, we have a file/folder drop from outside
if (dataTransfer) {
// figure if a folder was dropped on a modern browser, in this case the first would have to be a directory
let folderItem;
try {
folderItem = dataTransfer.items[0].webkitGetAsEntry();
if (folderItem.isFile) return this.$refs.fileUploader.addFiles(dataTransfer.files, fullTargetFolder, false);
} catch (e) {
return this.$refs.fileUploader.addFiles(dataTransfer.files, fullTargetFolder, false);
async function getFile(entry) {
return new Promise((resolve, reject) => {
entry.file(resolve, reject);
});
}
// if we got here we have a folder drop and a modern browser
// now traverse the folder tree and create a file list
var that = this;
function traverseFileTree(item, path) {
async function readEntries(dirReader) {
return new Promise((resolve, reject) => {
dirReader.readEntries(resolve, reject);
});
}
const fileList = [];
async function traverseFileTree(item) {
if (item.isFile) {
item.file(function (file) {
that.$refs.fileUploader.addFiles([file], sanitize(`${that.cwd}/${targetFolder}`), false);
});
fileList.push(await getFile(item));
} else if (item.isDirectory) {
// Get folder contents
var dirReader = item.createReader();
dirReader.readEntries(function (entries) {
for (let i in entries) {
traverseFileTree(entries[i], item.name);
}
});
const dirReader = item.createReader();
const entries = await readEntries(dirReader);
for (let i in entries) {
await traverseFileTree(entries[i], item.name);
}
} else {
console.log('Skipping uknown file type', item);
}
}
traverseFileTree(folderItem, '');
// collect all files to upload
for (const item of dataTransfer.items) {
const entry = item.webkitGetAsEntry();
if (entry.isFile) {
fileList.push(await getFile(entry));
} else if (entry.isDirectory) {
await traverseFileTree(entry, sanitize(`${this.cwd}/${targetFolder}`));
}
}
this.$refs.fileUploader.addFiles(fileList, sanitize(`${this.cwd}/${targetFolder}`));
} else {
if (!files.length) return;
@@ -346,16 +430,8 @@ export default {
async deleteHandler(files) {
if (!files) return;
function start_and_end(str) {
if (str.length > 100) {
return str.substr(0, 45) + ' ... ' + str.substr(str.length-45, str.length);
}
return str;
}
const confirmed = await this.$refs.inputDialog.confirm({
message: this.$t('filemanager.removeDialog.reallyDelete'),
// message: start_and_end(files.map((f) => f.name).join(', ')),
confirmStyle: 'danger',
confirmLabel: this.$t('main.dialog.yes'),
rejectLabel: this.$t('main.dialog.no'),
@@ -458,7 +534,7 @@ export default {
try {
await this.uploadRequest;
} catch (e) {
console.log('Upload cancelled.');
console.log('Upload cancelled.', e);
}
this.uploadRequest = null;
@@ -500,11 +576,11 @@ export default {
}
while(true) {
let error, result;
let result;
try {
result = await superagent.get(`${this.apiOrigin}/api/v1/apps/${this.resourceId}`).query({ access_token: this.accessToken });
} catch (e) {
error = e;
console.error('Failed to fetch app status.', e);
}
if (result && result.statusCode === 200 && result.body.installationState === ISTATES.INSTALLED) break;
@@ -514,76 +590,6 @@ export default {
this.busyRestart = false;
}
},
async mounted() {
this.busy = true;
const type = this.$route.params.type || 'app';
const resourceId = this.$route.params.resourceId;
const cwd = this.$route.params.cwd;
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);
return this.onFatalError(`Invalid resource ${type} ${resourceId}`);
}
this.appLink = `https://${result.body.fqdn}`;
this.title = `${result.body.label || result.body.fqdn} (${result.body.manifest.title})`;
} 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);
return this.onFatalError(`Invalid resource ${type} ${resourceId}`);
}
this.title = result.body.name;
} else {
return this.onFatalError(`Unsupported type ${type}`);
}
try {
const result = await superagent.get(`${this.apiOrigin}/api/v1/dashboard/config`).query({ access_token: this.accessToken });
this.footerContent = marked.parse(result.body.footer);
} catch (e) {
console.error('Failed to fetch Cloudron config.', e);
}
window.document.title = `File Manager - ${this.title}`;
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.ownersModel = this.directoryModel.ownersModel;
this.loadCwd();
this.$watch(() => this.$route.params, (toParams, previousParams) => {
if (toParams.type !== 'app' && toParams.type !== 'volume') return this.onFatalError(`Unknown type ${toParams.type}`);
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('/')}` : '/';
});
}
};
+42 -23
View File
@@ -19,16 +19,18 @@ readonly HELP_MESSAGE="
Cloudron Support and Diagnostics Tool
Options:
--disable-dnssec Disable DNSSEC
--enable-remote-access Enable SSH Remote Access for the Cloudron support team
--patch Apply a patch from git. WARNING: Do not use unless you know what you are doing!
--recreate-containers Deletes all existing containers and recreates them without loss of data
--recreate-docker Deletes docker storage (containers and images) and recreates it without loss of data
--send-diagnostics Collects server diagnostics and uploads it to ${PASTEBIN}
--troubleshoot Dashboard down? Run tests to identify the potential problem
--owner-login Login as owner
--use-external-dns Forwards all DNS requests to Google (8.8.8.8) and Cloudflare (1.1.1.1) DNS servers
--help Show this message
--disable-dnssec Disable DNSSEC
--enable-remote-access Enable SSH Remote Access for the Cloudron support team
--patch Apply a patch from git. WARNING: Do not use unless you know what you are doing!
--recreate-containers Deletes all existing containers and recreates them without loss of data
--recreate-docker Deletes docker storage (containers and images) and recreates it without loss of data
--send-diagnostics Collects server diagnostics and uploads it to ${PASTEBIN}
--troubleshoot Dashboard down? Run tests to identify the potential problem
--owner-login Login as owner
--unbound-use-external-dns Forwards all Unbound requests to Google (8.8.8.8) and Cloudflare (1.1.1.1) DNS servers.
Unbound is the internal DNS server used for recursive DNS queries. This is only needed
if your network does not allow outbound DNS requests.
--help Show this message
"
function success() {
@@ -133,11 +135,10 @@ function check_netplan() {
fi
if [[ -z "${output}" ]]; then
fail "netplan configuration is empty"
exit 1
warn "netplan configuration is empty. this might be OK depending on your networking setup"
else
success "netplan is good"
fi
success "netplan is good"
}
function owner_login() {
@@ -217,13 +218,31 @@ function send_diagnostics() {
}
function check_dns() {
if ! host cloudron.io &>/dev/null; then
fail "DNS is not resolving"
host cloudron.io
exit 1
if host cloudron.io &>/dev/null; then
success "DNS is resolving via systemd-resolved"
return
fi
success "DNS is resolving via systemd-resolved"
if ! systemctl is-active -q systemd-resolved; then
warn "systemd-resolved is not in use. see 'systemctl status systemd-resolved'"
fi
if [[ -L /etc/resolv.conf ]]; then
target=$(readlink /etc/resolv.conf)
if [[ "$target" != *"/run/systemd/resolve/stub-resolv.conf" ]]; then
warn "/etc/resolv.conf is symlinked to $target instead of '../run/systemd/resolve/stub-resolv.conf'"
fi
else
warn "/etc/resolv.conf is not symlinked to '../run/systemd/resolve/stub-resolv.conf'"
fi
if ! grep -q "^nameserver 127.0.0.53" /etc/resolv.conf; then
warn "/etc/resolv.conf is not using systemd-resolved. it is missing the line 'nameserver 127.0.0.53'"
fi
fail "DNS is not resolving"
host cloudron.io || true
exit 1
}
function check_unbound() {
@@ -244,7 +263,7 @@ function check_unbound() {
fi
if ! host cloudron.io 127.0.0.150 &>/dev/null; then
fail "Unbound is not resolving, maybe try forwarding all DNS requests. You can do this by running 'cloudron-support --use-external-dns' option"
fail "Unbound is not resolving, maybe try forwarding all DNS requests. You can do this by running 'cloudron-support --unbound-use-external-dns' option"
host cloudron.io 127.0.0.150
exit 1
fi
@@ -430,7 +449,7 @@ function check_expired_domain() {
success "Domain ${dashboard_domain} is valid and has not expired"
}
function use_external_dns() {
function unbount_use_external_dns() {
local -r conf_file="/etc/unbound/unbound.conf.d/forward-everything.conf"
info "To remove the forwarding, please delete $conf_file and 'systemctl restart unbound'"
@@ -658,7 +677,7 @@ function apply_patch() {
check_disk_space
args=$(getopt -o "" -l "admin-login,disable-dnssec,enable-ssh,enable-remote-access,help,owner-login,patch:,recreate-containers,recreate-docker,send-diagnostics,use-external-dns,troubleshoot" -n "$0" -- "$@")
args=$(getopt -o "" -l "admin-login,disable-dnssec,enable-ssh,enable-remote-access,help,owner-login,patch:,recreate-containers,recreate-docker,send-diagnostics,unbound-use-external-dns,troubleshoot" -n "$0" -- "$@")
eval set -- "${args}"
while true; do
@@ -674,7 +693,7 @@ while true; do
--send-diagnostics) send_diagnostics; exit 0;;
--troubleshoot) troubleshoot; exit 0;;
--disable-dnssec) disable_dnssec; exit 0;;
--use-external-dns) use_external_dns; exit 0;;
--unbound-use-external-dns) unbound_use_external_dns; exit 0;;
--recreate-containers) recreate_containers; exit 0;;
--recreate-docker) recreate_docker; exit 0;;
--patch) apply_patch "$2"; exit 0;;
+2 -1
View File
@@ -2628,8 +2628,9 @@ async function autoupdateApps(updateInfo, auditSource) { // updateInfo is { appI
force: false
};
debug(`app ${app.fqdn} will be automatically updated`);
const [updateError] = await safe(updateApp(app, data, auditSource));
if (updateError) debug(`Error initiating autoupdate of ${appId}. ${updateError.message}`);
if (updateError) debug(`Error autoupdating ${appId}. ${updateError.message}`);
}
}
+7 -4
View File
@@ -69,7 +69,7 @@ function applyBackupRetention(allBackups, retention, referencedBackupIds) {
}
if (retention.keepLatest) {
let latestNormalBackup = allBackups.find(b => b.state === backups.BACKUP_STATE_NORMAL);
const latestNormalBackup = allBackups.find(b => b.state === backups.BACKUP_STATE_NORMAL);
if (latestNormalBackup && !latestNormalBackup.keepReason) latestNormalBackup.keepReason = 'latest';
}
@@ -122,7 +122,7 @@ async function cleanupAppBackups(backupConfig, retention, referencedBackupIds, p
const appBackups = await backups.getByTypePaged(backups.BACKUP_TYPE_APP, 1, 1000);
// collate the backups by app id. note that the app could already have been uninstalled
let appBackupsById = {};
const appBackupsById = {};
for (const appBackup of appBackups) {
if (!appBackupsById[appBackup.identifier]) appBackupsById[appBackup.identifier] = [];
appBackupsById[appBackup.identifier].push(appBackup);
@@ -177,7 +177,8 @@ async function cleanupBoxBackups(backupConfig, retention, progressCallback) {
assert.strictEqual(typeof retention, 'object');
assert.strictEqual(typeof progressCallback, 'function');
let referencedBackupIds = [], removedBoxBackupPaths = [];
let referencedBackupIds = [];
const removedBoxBackupPaths = [];
const boxBackups = await backups.getByTypePaged(backups.BACKUP_TYPE_BOX, 1, 1000);
@@ -200,7 +201,7 @@ async function cleanupBoxBackups(backupConfig, retention, progressCallback) {
return { removedBoxBackupPaths, referencedBackupIds };
}
// cleans up the database by checking if backup exists in the remote
// cleans up the database by checking if backup exists in the remote. this can happen if user had set some bucket policy
async function cleanupMissingBackups(backupConfig, progressCallback) {
assert.strictEqual(typeof backupConfig, 'object');
assert.strictEqual(typeof progressCallback, 'function');
@@ -215,6 +216,8 @@ async function cleanupMissingBackups(backupConfig, progressCallback) {
result = await backups.list(page, perPage);
for (const backup of result) {
if (backup.state !== backups.BACKUP_STATE_NORMAL) continue; // note: errored and incomplete backups are cleaned up by the backup retention logic
let backupFilePath = backupFormat.api(backup.format).getBackupFilePath(backupConfig, backup.remotePath);
if (backup.format === 'rsync') backupFilePath = backupFilePath + '/'; // add trailing slash to indicate directory
+2 -2
View File
@@ -223,7 +223,7 @@ async function handleAutoupdatePatternChanged(pattern) {
const updateInfo = updateChecker.getUpdateInfo();
// do box before app updates. for the off chance that the box logic fixes some app update logic issue
if (updateInfo.box && !updateInfo.box.unstable) {
debug('Starting box autoupdate to %j', updateInfo.box);
debug('Starting box autoupdate to %j', updateInfo.box.version);
const [error] = await safe(updater.updateToLatest({ skipBackup: false }, AuditSource.CRON));
if (!error) return; // do not start app updates when a box update got scheduled
debug(`Failed to start box autoupdate task: ${error.message}`);
@@ -232,7 +232,7 @@ async function handleAutoupdatePatternChanged(pattern) {
const appUpdateInfo = _.omit(updateInfo, 'box');
if (Object.keys(appUpdateInfo).length > 0) {
debug('Starting app update to %j', appUpdateInfo);
debug('Starting app autoupdate: %j', Object.keys(appUpdateInfo));
const [error] = await safe(apps.autoupdateApps(appUpdateInfo, AuditSource.CRON));
if (error) debug(`Failed to app autoupdate: ${error.message}`);
} else {
+1 -1
View File
@@ -18,7 +18,7 @@ exports = module.exports = {
'mysql': 'registry.docker.com/cloudron/mysql:3.4.3@sha256:8934c5ddcd69f24740d9a38f0de2937e47240238f3b8f5c482862eeccc5a21d2',
'postgresql': 'registry.docker.com/cloudron/postgresql:5.2.3@sha256:9b7d5147e9c8008e4766cc80ebf4b833f3dfcf19ef0d81b013dfab76995d8d16',
'redis': 'registry.docker.com/cloudron/redis:3.5.3@sha256:1e1200900c6fb196950531ecec43f400b4fe5e559fac1c75f21e6f0c11885b5f',
'sftp': 'registry.docker.com/cloudron/sftp:3.8.7@sha256:9d13007f665d72875e7e4830fc4d5ee352c7c1a44b4f0f526746e2c2d2c296e0',
'sftp': 'registry.docker.com/cloudron/sftp:3.8.9@sha256:f2a126839df99ca420a3ad8177594f58b113f6292e98719f2cf2e0ddc3597696',
'turn': 'registry.docker.com/cloudron/turn:1.7.2@sha256:9ed8da613c1edc5cb8700657cf6e49f0f285b446222a8f459f80919945352f6d',
}
};
+1 -1
View File
@@ -62,7 +62,7 @@ function tail(filePaths, options) {
if (options.follow) args.push('--follow');
if (options.sudo) {
return shell.sudo('tail', args.concat(filePaths), { streamStdout: true }, () => {});
return shell.sudo('tail', args.concat(filePaths), { quiet: true }, () => {});
} else {
const cp = spawn('/usr/bin/tail', args.concat(filePaths));
cp.terminate = () => cp.kill('SIGKILL');
-1
View File
@@ -3,7 +3,6 @@
exports = module.exports = {
cookieParser: require('cookie-parser'),
cors: require('./cors.js'),
proxy: require('./proxy-middleware.js'),
lastMile: require('connect-lastmile'),
multipart: require('./multipart.js'),
timeout: require('connect-timeout')
-149
View File
@@ -1,149 +0,0 @@
// https://github.com/cloudron-io/node-proxy-middleware
// MIT license
// contains https://github.com/gonzalocasas/node-proxy-middleware/pull/59
var os = require('os');
var http = require('http');
var https = require('https');
var owns = {}.hasOwnProperty;
module.exports = function proxyMiddleware(options) {
//enable ability to quickly pass a url for shorthand setup
if(typeof options === 'string'){
options = require('url').parse(options);
}
var httpLib = options.protocol === 'https:' ? https : http;
var request = httpLib.request;
options = options || {};
options.hostname = options.hostname;
options.port = options.port;
options.pathname = options.pathname || '/';
return function (req, resp, next) {
var url = req.url;
// You can pass the route within the options, as well
if (typeof options.route === 'string') {
if (url === options.route) {
url = '';
} else if (url.slice(0, options.route.length) === options.route) {
url = url.slice(options.route.length);
} else {
return next();
}
}
//options for this request
var opts = extend({}, options);
if (url && url.charAt(0) === '?') { // prevent /api/resource/?offset=0
if (options.pathname.length > 1 && options.pathname.charAt(options.pathname.length - 1) === '/') {
opts.path = options.pathname.substring(0, options.pathname.length - 1) + url;
} else {
opts.path = options.pathname + url;
}
} else if (url) {
opts.path = slashJoin(options.pathname, url);
} else {
opts.path = options.pathname;
}
opts.method = req.method;
opts.headers = options.headers ? merge(req.headers, options.headers) : req.headers;
applyViaHeader(req.headers, opts, opts.headers);
if (!options.preserveHost) {
// Forwarding the host breaks dotcloud
delete opts.headers.host;
}
var myReq = request(opts, function (myRes) {
var statusCode = myRes.statusCode
, headers = myRes.headers
, location = headers.location;
// Fix the location
if (((statusCode > 300 && statusCode < 304) || statusCode === 201) && location && location.indexOf(options.href) > -1) {
// absoulte path
headers.location = location.replace(options.href, slashJoin('/', slashJoin((options.route || ''), '')));
}
applyViaHeader(myRes.headers, opts, myRes.headers);
rewriteCookieHosts(myRes.headers, opts, myRes.headers, req);
resp.writeHead(myRes.statusCode, myRes.headers);
myRes.on('error', function (err) {
next(err);
});
myRes.on('end', function (err) {
next();
});
myRes.pipe(resp);
});
myReq.on('error', function (err) {
next(err);
});
if (!req.readable) {
myReq.end();
} else {
req.pipe(myReq);
}
};
};
function applyViaHeader(existingHeaders, opts, applyTo) {
if (!opts.via) return;
var viaName = (true === opts.via) ? os.hostname() : opts.via;
var viaHeader = '1.1 ' + viaName;
if(existingHeaders.via) {
viaHeader = existingHeaders.via + ', ' + viaHeader;
}
applyTo.via = viaHeader;
}
function rewriteCookieHosts(existingHeaders, opts, applyTo, req) {
if (!opts.cookieRewrite || !owns.call(existingHeaders, 'set-cookie')) {
return;
}
var existingCookies = existingHeaders['set-cookie'],
rewrittenCookies = [],
rewriteHostname = (true === opts.cookieRewrite) ? os.hostname() : opts.cookieRewrite;
if (!Array.isArray(existingCookies)) {
existingCookies = [ existingCookies ];
}
for (var i = 0; i < existingCookies.length; i++) {
var rewrittenCookie = existingCookies[i].replace(/(Domain)=[a-z\.-_]*?(;|$)/gi, '$1=' + rewriteHostname + '$2');
if (!req.connection.encrypted) {
rewrittenCookie = rewrittenCookie.replace(/;\s*?(Secure)/i, '');
}
rewrittenCookies.push(rewrittenCookie);
}
applyTo['set-cookie'] = rewrittenCookies;
}
function slashJoin(p1, p2) {
var trailing_slash = false;
if (p1.length && p1[p1.length - 1] === '/') { trailing_slash = true; }
if (trailing_slash && p2.length && p2[0] === '/') {p2 = p2.substring(1); }
return p1 + p2;
}
function extend(obj, src) {
for (var key in src) if (owns.call(src, key)) obj[key] = src[key];
return obj;
}
//merges data without changing state in either argument
function merge(src1, src2) {
var merged = {};
extend(merged, src1);
extend(merged, src2);
return merged;
}
+1 -1
View File
@@ -63,7 +63,7 @@ function validateMountOptions(type, options) {
}
}
// managed providers are those for which we setup systemd mount file
// managed providers are those for which we setup systemd mount file under /mnt/volumes
function isManagedProvider(provider) {
return provider === 'sshfs' || provider === 'cifs' || provider === 'nfs' || provider === 'ext4' || provider === 'xfs' || provider === 'disk';
}
+27 -20
View File
@@ -6,11 +6,10 @@ exports = module.exports = {
const assert = require('assert'),
BoxError = require('../boxerror.js'),
middleware = require('../middleware/index.js'),
http = require('http'),
HttpError = require('connect-lastmile').HttpError,
safe = require('safetydance'),
services = require('../services.js'),
url = require('url');
services = require('../services.js');
function proxy(kind) {
assert(kind === 'mail' || kind === 'volume' || kind === 'app');
@@ -25,25 +24,33 @@ function proxy(kind) {
case 'mail': id = 'mail'; break;
}
const [error, result] = await safe(services.getContainerDetails('sftp', 'CLOUDRON_SFTP_TOKEN'));
const [error, addonDetails] = await safe(services.getContainerDetails('sftp', 'CLOUDRON_SFTP_TOKEN'));
if (error) return next(BoxError.toHttpError(error));
const parsedUrl = url.parse(req.url, true /* parseQueryString */);
parsedUrl.query['access_token'] = result.token;
const searchParams = new URLSearchParams(req.url.slice(req.url.indexOf('?')+1));
searchParams.delete('access_token');
searchParams.append('access_token', addonDetails.token);
req.url = url.format({ pathname: `/files/${id}/${encodeURIComponent(req.params[0])}`, query: parsedUrl.query }); // params[0] already contains leading '/'
const opts = {
hostname: addonDetails.ip,
port: 3000,
path: `/files/${id}/${encodeURIComponent(req.params[0])}?${searchParams.toString()}`, // params[0] already contains leading '/'
method: req.method,
headers: req.headers
};
const proxyOptions = url.parse(`http://${result.ip}:3000`);
proxyOptions.rejectUnauthorized = false;
const fileManagerProxy = middleware.proxy(proxyOptions);
fileManagerProxy(req, res, function (error) {
if (!error) return next();
if (error.code === 'ECONNREFUSED') return next(new HttpError(424, 'Unable to connect to filemanager server'));
if (error.code === 'ECONNRESET') return next(new HttpError(424, 'Unable to query filemanager server'));
next(new HttpError(500, error));
});
};
const sftpReq = http.request(opts, function (sftpRes) {
res.writeHead(sftpRes.statusCode, sftpRes.headers);
// note: these are intentionally not handled. response has already been written. do not forward to connect-lastmile
// sftpRes.on('error', (error) => next(new HttpError(500, `filemanager error: ${error.message} ${error.code}`)));
// sftpRes.on('end', () => next());
sftpRes.pipe(res);
});
sftpReq.on('error', (error) => next(new HttpError(424, `Unable to connect to filemanager: ${error.message} ${error.code}`)));
if (!req.readable) {
sftpReq.end();
} else {
req.pipe(sftpReq);
}
};
}
+24 -21
View File
@@ -13,40 +13,43 @@ const assert = require('assert'),
AuditSource = require('../auditsource.js'),
BoxError = require('../boxerror.js'),
debug = require('debug')('box:routes/mailserver'),
http = require('http'),
HttpError = require('connect-lastmile').HttpError,
HttpSuccess = require('connect-lastmile').HttpSuccess,
mailServer = require('../mailserver.js'),
middleware = require('../middleware/index.js'),
safe = require('safetydance'),
services = require('../services.js'),
url = require('url');
services = require('../services.js');
async function proxyToMailContainer(port, pathname, req, res, next) {
const parsedUrl = url.parse(req.url, true /* parseQueryString */);
// do not proxy protected values
delete parsedUrl.query['access_token'];
delete req.headers['authorization'];
delete req.headers['cookies'];
req.clearTimeout();
const [error, addonDetails] = await safe(services.getContainerDetails('mail', 'CLOUDRON_MAIL_TOKEN'));
if (error) return next(BoxError.toHttpError(error));
parsedUrl.query['access_token'] = addonDetails.token;
req.url = url.format({ pathname, query: parsedUrl.query });
const searchParams = new URLSearchParams(req.url.slice(req.url.indexOf('?')+1));
searchParams.delete('access_token');
searchParams.append('access_token', addonDetails.token);
const proxyOptions = url.parse(`http://${addonDetails.ip}:${port}`);
const mailserverProxy = middleware.proxy(proxyOptions);
const opts = {
hostname: addonDetails.ip,
port: 3000,
path: `/${pathname}?${searchParams.toString()}`,
method: req.method,
headers: req.headers
};
req.clearTimeout(); // TODO: add timeout to mail server proxy logic instead of this
mailserverProxy(req, res, function (error) {
if (!error) return next(); // note: response was already sent by proxy by this point
if (error.code === 'ECONNREFUSED') return next(new HttpError(424, 'Unable to connect to mail server'));
if (error.code === 'ECONNRESET') return next(new HttpError(424, 'Unable to query mail server'));
next(new HttpError(500, error));
const sftpReq = http.request(opts, function (sftpRes) {
res.writeHead(sftpRes.statusCode, sftpRes.headers);
sftpRes.on('error', (error) => next(new HttpError(500, `mailserver error: ${error.message} ${error.code}`)));
sftpRes.on('end', () => next());
sftpRes.pipe(res);
});
sftpReq.on('error', (error) => next(new HttpError(424, `Unable to connect to mailserver: ${error.message} ${error.code}`)));
if (!req.readable) {
sftpReq.end();
} else {
req.pipe(sftpReq);
}
}
async function proxy(req, res, next) {
+10 -1
View File
@@ -50,7 +50,16 @@ async function providerTokenAuth(req, res, next) {
if (system.getProvider() === 'ami') {
if (typeof req.body.providerToken !== 'string' || !req.body.providerToken) return next(new HttpError(400, 'providerToken must be a non empty string'));
const [error, response] = await safe(superagent.get('http://169.254.169.254/latest/meta-data/instance-id').timeout(30 * 1000).ok(() => true));
// IMDSv2 https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-options.html
// https://aws.amazon.com/blogs/security/defense-in-depth-open-firewalls-reverse-proxies-ssrf-vulnerabilities-ec2-instance-metadata-service/
const imdsIp = req.body.ipv4Config?.provider === 'noop' ? '[fd00:ec2::254]' : '169.254.169.254'; // use ipv4config carefully, it's not validated yet at this point
const [tokenError, tokenResponse] = await safe(superagent.put(`http://${imdsIp}/latest/api/token`).set('x-aws-ec2-metadata-token-ttl-seconds', 600).timeout(30 * 1000).ok(() => true));
if (tokenError) return next(new HttpError(422, `Network error getting EC2 metadata session token: ${tokenError.message}`));
if (tokenResponse.status !== 200) return next(new HttpError(422, `Unable to get EC2 meta data session token. statusCode: ${tokenResponse.status}`));
const imdsToken = tokenResponse.text;
const [error, response] = await safe(superagent.get(`http://${imdsIp}/latest/meta-data/instance-id`).set('x-aws-ec2-metadata-token', imdsToken).timeout(30 * 1000).ok(() => true));
if (error) return next(new HttpError(422, `Network error getting EC2 metadata: ${error.message}`));
if (response.status !== 200) return next(new HttpError(422, `Unable to get EC2 meta data. statusCode: ${response.status}`));
if (response.text !== req.body.providerToken) return next(new HttpError(422, 'Instance ID does not match'));
+1 -1
View File
@@ -29,6 +29,6 @@ while true; do
done
# first sort the existing log lines
tail --quiet --lines=${lines} -- "$@" | sort -k1 || true # ignore error if files are missing
tail --quiet --lines=${lines} "$@" | sort -k1 || true # ignore error if files are missing
exec tail ${follow} --lines=0 "$@"
+1 -1
View File
@@ -1792,7 +1792,7 @@ async function startRedis(existingInfra) {
const allApps = await apps.list();
for (const app of allApps) {
if (!('redis' in app.manifest.addons)) continue; // app doesn't use the addon
if (!app.manifest.addons || !('redis' in app.manifest.addons)) continue; // app doesn't use the addon
const redisName = `redis-${app.id}`;
+4 -3
View File
@@ -15,6 +15,7 @@ const apps = require('./apps.js'),
docker = require('./docker.js'),
hat = require('./hat.js'),
infra = require('./infra_version.js'),
mounts = require('./mounts.js'),
path = require('path'),
paths = require('./paths.js'),
safe = require('safetydance'),
@@ -82,7 +83,7 @@ async function start(existingInfra) {
dataDirs.push({ hostDir: '/mnt/volumes', mountDir: '/mnt/volumes' }); // managed volumes
const allVolumes = await volumes.list();
for (const volume of allVolumes) {
if (volume.hostPath.startsWith('/mnt/volumes/')) continue; // skip managed volume
if (mounts.isManagedProvider(volume.mountType)) continue; // skip managed volume. these are acessed via /mnt/volumes mount above
if (!safe.fs.existsSync(volume.hostPath)) {
debug(`Ignoring volume host path ${volume.hostPath} since it does not exist`);
@@ -100,7 +101,7 @@ async function start(existingInfra) {
const readOnly = !serviceConfig.recoveryMode ? '--read-only' : '';
const cmd = serviceConfig.recoveryMode ? '/bin/bash -c \'echo "Debug mode. Sleeping" && sleep infinity\'' : '';
const mounts = dataDirs.map(v => `-v "${v.hostDir}:${v.mountDir}"`).join(' ');
const volumeMounts = dataDirs.map(v => `-v "${v.hostDir}:${v.mountDir}"`).join(' ');
const runCmd = `docker run --restart=always -d --name=sftp \
--hostname sftp \
--net cloudron \
@@ -112,7 +113,7 @@ async function start(existingInfra) {
-m ${memoryLimit} \
--memory-swap -1 \
-p 222:22 \
${mounts} \
${volumeMounts} \
-e CLOUDRON_SFTP_TOKEN=${cloudronToken} \
-v ${paths.SFTP_KEYS_DIR}:/etc/ssh:ro \
--label isCloudronManaged=true \
+1 -1
View File
@@ -111,7 +111,7 @@ function sudo(tag, args, options, callback) {
cp.stdout.on('data', (data) => {
if (options.captureStdout) stdoutResult += data.toString('utf8');
process.stdout.write(data); // do not use debug to avoid double timestamps when calling backupupload.js
if (!options.quiet) process.stdout.write(data); // do not use debug to avoid double timestamps when calling backupupload.js
});
cp.stderr.on('data', (data) => {
process.stderr.write(data); // do not use debug to avoid double timestamps when calling backupupload.js
+1 -1
View File
@@ -171,7 +171,7 @@ async function copy(apiConfig, oldFilePath, newFilePath, progressCallback) {
cpOptions += apiConfig.noHardlinks ? '' : 'l'; // this will hardlink backups saving space
if (apiConfig.provider === PROVIDER_SSHFS) {
const identityFilePath = `/home/yellowtent/platformdata/sshfs/id_rsa_${apiConfig.mountOptions.host}`;
const identityFilePath = path.join(paths.SSHFS_KEYS_DIR, `id_rsa_${apiConfig.mountOptions.host}`);
const sshOptions = [ '-o', '"StrictHostKeyChecking no"', '-i', identityFilePath, '-p', apiConfig.mountOptions.port, `${apiConfig.mountOptions.user}@${apiConfig.mountOptions.host}` ];
const sshArgs = sshOptions.concat([ 'cp', cpOptions, oldFilePath.replace('/mnt/cloudronbackup/', ''), newFilePath.replace('/mnt/cloudronbackup/', '') ]);
+10 -6
View File
@@ -21,7 +21,8 @@ exports = module.exports = {
const assert = require('assert'),
BoxError = require('../boxerror.js'),
debug = require('debug')('box:storage/noop');
debug = require('debug')('box:storage/noop'),
fs = require('fs');
async function getProviderStatus(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
@@ -35,15 +36,18 @@ async function getAvailableSize(apiConfig) {
return Number.POSITIVE_INFINITY;
}
function upload(apiConfig, backupFilePath, sourceStream, callback) {
async function upload(apiConfig, backupFilePath) {
assert.strictEqual(typeof apiConfig, 'object');
assert.strictEqual(typeof backupFilePath, 'string');
assert.strictEqual(typeof sourceStream, 'object');
assert.strictEqual(typeof callback, 'function');
debug('upload: %s', backupFilePath);
debug(`upload: ${backupFilePath}`);
callback(null);
const uploadStream = fs.createWriteStream('/dev/null');
return {
stream: uploadStream,
async finish() {}
};
}
async function exists(apiConfig, backupFilePath) {
+1 -1
View File
@@ -133,7 +133,7 @@ async function upload(apiConfig, backupFilePath) {
stream: passThrough,
async finish() {
const [error, data] = await safe(uploadPromise);
if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, `Upload error: ${error.message}`);
if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, `Upload error: code: ${error.code} message: ${error.message}`); // sometimes message is null
debug(`Upload finished. ${JSON.stringify(data)}`);
}
};
+4 -1
View File
@@ -55,10 +55,13 @@ function validateHostPath(hostPath, mountType) {
if (hostPath === '/') return new BoxError(BoxError.BAD_FIELD, 'hostPath cannot be /');
if (!hostPath.endsWith('/')) hostPath = hostPath + '/'; // ensure trailing slash for the prefix matching to work
const allowedPaths = [ '/mnt/', '/media/', '/srv/', '/opt/' ];
const allowedPaths = [ '/mnt/', '/media/', '/srv/', '/opt/' ];
if (!allowedPaths.some(p => hostPath.startsWith(p))) return new BoxError(BoxError.BAD_FIELD, 'hostPath must be under /mnt, /media, /opt or /srv');
const reservedPaths = [ `${paths.VOLUMES_MOUNT_DIR}/` ];
if (reservedPaths.some(p => hostPath.startsWith(p))) return new BoxError(BoxError.BAD_FIELD, 'hostPath is reserved');
if (!constants.TEST) { // we expect user to have already mounted this
const stat = safe.fs.lstatSync(hostPath);
if (!stat) return new BoxError(BoxError.BAD_FIELD, 'hostPath does not exist. Please create it on the server first');