Compare commits

...

107 Commits

Author SHA1 Message Date
Johannes Zellner
1b810ec74f Only add unchecked checklist items on fresh installs for the moment 2024-09-16 13:46:19 +02:00
Johannes Zellner
f59b9e1b5f frontend: adjust filemanager to new pankow api 2024-09-16 13:28:30 +02:00
Johannes Zellner
398dbe802e frontend: remove another unused css rule 2024-09-16 12:21:14 +02:00
Johannes Zellner
8b5fa0fe76 frontend: purge unwanted css styles 2024-09-16 12:08:10 +02:00
Johannes Zellner
99042a47f3 frontend: Fix all toolbuttons 2024-09-16 12:05:41 +02:00
Johannes Zellner
46e600abe9 frontend: fixup LogsViewer 2024-09-16 11:50:20 +02:00
Johannes Zellner
051dd8b58f frontend: update dependencies 2024-09-16 11:50:20 +02:00
Girish Ramakrishnan
067b02dba1 dashboard: reconfigure all apps on location change
continuation of 1b5fee233e

all containers have ExtraHosts , so we have to reconfigure everything
2024-09-16 11:23:06 +02:00
Girish Ramakrishnan
22a0874188 grammar 2024-09-16 10:37:01 +02:00
Girish Ramakrishnan
0e25809158 settings: do not overflow the schedule 2024-09-16 10:29:35 +02:00
Girish Ramakrishnan
305d877896 operator: fix resource view
app resources view requires the cpu and memory information
2024-09-13 16:47:13 +02:00
Girish Ramakrishnan
a932a5251a update: all operators to update an app
previously, the update info was restricted to admins. this can now be queried
by any authenticated user. update information can be gathered from listing apps and
then checking against appstore anyway.
2024-09-13 16:46:58 +02:00
Girish Ramakrishnan
7b58fccb9f app info: fix overflow of manifest id 2024-09-13 11:34:30 +02:00
Johannes Zellner
859fef62d4 Revert "Make unbound prefer ipv4 to avoid using ipv6 for spam checking"
This reverts commit aedf55dba0.
2024-09-12 17:41:12 +02:00
Girish Ramakrishnan
0647a3a233 unbound: prefer ip4 on ubuntu 24 and above
ip6 queries seems to be blocked by spamhaus
2024-09-12 17:13:50 +02:00
Johannes Zellner
aedf55dba0 Make unbound prefer ipv4 to avoid using ipv6 for spam checking 2024-09-12 16:43:34 +02:00
Girish Ramakrishnan
e9a422b657 logs: handle logs not found (logrotated)
we show an error message in the UI now
2024-09-12 10:32:00 +02:00
Girish Ramakrishnan
23df6bdfbf add to changes 2024-09-11 17:55:35 +02:00
Girish Ramakrishnan
1b5fee233e docker: use the system dns for app containers
take 2 after failed attempt with 92bce26e22

this makes the dashboard domain resolve internally to nginx

can test with `getent ahosts my.domain.com` inside the container.
2024-09-11 17:52:25 +02:00
Girish Ramakrishnan
63457d2de4 Revert "docker: use the system dns for app containers"
This reverts commit 92bce26e22.
2024-09-10 19:37:39 +02:00
Girish Ramakrishnan
732c944e98 changelog: update release version 2024-09-10 17:43:18 +02:00
Girish Ramakrishnan
86c4db8f22 bugs in syslog parsing 2024-09-10 13:46:13 +02:00
Girish Ramakrishnan
8c0c9981de remove usage of nsyslog-parser-2
this module is somehow parsing the syslog incorrectly causing
incorrect directories being created in the logs directory
(since appName got parsed incorrectly)
2024-09-10 13:09:43 +02:00
Girish Ramakrishnan
e5dcf78ceb unbound: setup anchor on service restart 2024-09-10 09:48:10 +02:00
Girish Ramakrishnan
92bce26e22 docker: use the system dns for app containers 2024-09-10 09:42:31 +02:00
Girish Ramakrishnan
a72c038435 cloudron-support: also need to be remove any corrupt containerd 2024-09-09 18:42:08 +02:00
Girish Ramakrishnan
6742cdf373 backups: remount remote if not mounted before a backup 2024-09-09 18:15:49 +02:00
Girish Ramakrishnan
ea72cef7f9 storage: remove getProviderStatus 2024-09-09 17:36:51 +02:00
Girish Ramakrishnan
565ad83399 add to changes 2024-09-09 09:29:54 +02:00
Girish Ramakrishnan
43f795c9e4 remove use of "Cloudron" in various descriptions 2024-09-08 19:17:35 +02:00
Girish Ramakrishnan
1589cfb639 tz: add note in backup and update UI 2024-09-08 18:20:15 +02:00
Girish Ramakrishnan
a9b9931aa8 backups: do not overflow the schedule timings 2024-09-08 15:51:07 +02:00
Girish Ramakrishnan
1cd577cc65 filesystem: remove debug warning 2024-09-08 15:25:49 +02:00
Johannes Zellner
13d8db3daa For the moment new checklist items on update are acknowledged 2024-09-07 09:37:39 +02:00
Girish Ramakrishnan
40c4a01bc0 cloudron-support: ipv6 check 2024-09-06 17:20:52 +02:00
Girish Ramakrishnan
4301c70ba7 exoscale: add sos AT-VIE-2 region 2024-09-02 22:01:29 +02:00
Girish Ramakrishnan
d5e9e556ab digitalocean: add LON1 region 2024-09-02 20:58:14 +02:00
Girish Ramakrishnan
bdf9e04963 memory: ensure slider is always usable 2024-08-30 12:07:55 +02:00
Girish Ramakrishnan
b95285365d 8.1.0 changes 2024-08-28 11:51:01 +02:00
Girish Ramakrishnan
abf445e969 docker: fix rounding
toFixed() returns a string!
2024-08-28 11:45:53 +02:00
Girish Ramakrishnan
e988e3a303 storage: fix noop test 2024-08-27 15:16:18 +02:00
Girish Ramakrishnan
dca548b8a0 apptask: better progress message 2024-08-26 17:26:23 +02:00
Girish Ramakrishnan
56ecfdb4eb Fix crash on missing translation 2024-08-26 17:26:12 +02:00
Johannes Zellner
7640851aa9 dashboard: notification items need more padding on mobile 2024-08-23 19:48:04 +02:00
Johannes Zellner
d9301160e1 dashboard: give notification header more horizontal space 2024-08-23 19:45:27 +02:00
Johannes Zellner
3656d7f631 frontend: fix translation resolver to actually fallback to english 2024-08-23 19:41:58 +02:00
Johannes Zellner
9f89b07777 frontend: ensure API_ORIGIN is always set 2024-08-23 19:28:26 +02:00
Johannes Zellner
199dbff7b1 frontend: rework i18n and replace all superagent calls with pankow fetcher 2024-08-23 19:17:23 +02:00
Johannes Zellner
88b8cb48fc Deliver translation files as content type json 2024-08-23 18:34:53 +02:00
Johannes Zellner
e8b3232966 frontend: replace more superagent with pankow fetcher 2024-08-23 18:34:53 +02:00
Johannes Zellner
5de7537c71 frontend: replace superagent with pankow fetcher in DirectoryModel 2024-08-23 12:19:47 +02:00
Johannes Zellner
4706313239 frontend: update dependencies 2024-08-23 12:19:47 +02:00
Girish Ramakrishnan
d32819da4e i18n: fix crash if language file is missing 2024-08-23 10:20:35 +02:00
Girish Ramakrishnan
b6becae396 make TRANSLATIONS_DIR a constant 2024-08-23 10:09:21 +02:00
Johannes Zellner
d310c5746e dashboard: improve admin checklist display in postinstall dialog 2024-08-20 19:00:19 +02:00
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
Girish Ramakrishnan
06e46e0f1e 8.0.3 changes 2024-07-26 09:09:35 +02:00
Girish Ramakrishnan
731295f708 system: simplify logic 2024-07-25 17:50:50 +02:00
Girish Ramakrishnan
9399040cd3 Fix log recursion
shell.sudo logs output to stdout/stderr intentionally. It is not meant
for scripts that generate much output (basically scripts/* files).

core of the issue is that none of the log commands require to use sudo.
they can just use normal tail. only app logs requires sudo because of the
logPaths directive in the manifest.
2024-07-25 17:48:58 +02:00
Johannes Zellner
9f9fde5811 frontend: fix clear view in logs viewer 2024-07-25 17:44:20 +02:00
Johannes Zellner
cbc46a8229 dashboard: support links/markdown in checklist items 2024-07-25 17:40:15 +02:00
Girish Ramakrishnan
fb11997430 Add note on automatic upgrades 2024-07-25 17:09:46 +02:00
Girish Ramakrishnan
b6fbc46b58 Revert "Add option to not log shell subprocess stdout+stderr"
This reverts commit 51bb2d2bc2.
2024-07-25 11:53:56 +02:00
Johannes Zellner
21de2513e7 frontend: fix all usage of file upload without multipart 2024-07-25 11:18:14 +02:00
Johannes Zellner
51bb2d2bc2 Add option to not log shell subprocess stdout+stderr
When tailing the box log file this leads to logline recursion
2024-07-25 10:22:02 +02:00
86 changed files with 2421 additions and 1984 deletions

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"
}
}

View File

@@ -1,5 +0,0 @@
{
"node": true,
"unused": true,
"esversion": 11
}

34
CHANGES
View File

@@ -2814,3 +2814,37 @@
* sshfs: if remote copy fails, fallback to sshfs based copy
* frontend: reduce DOM node creation on very fast logstreams and cap to 1k loglines
[8.0.3]
* 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
[8.0.5]
* cpu quota: fix rounding error
* frontend: fix translation resolver to actually fallback to english
* i18n: fix crash if language file is missing
* memory: fix slider UI where max was incorrectly set
* digitalocean: add LON1 Spaces region
* exoscale: add sos AT-VIE-2 region
* i18n: remove use of "Cloudron"
* tz: add note in backup and update UI
* backups: do not overflow the schedule timings
* checklist: new checklist items on update are acknowledged
* backups: automatically trigger a remount if mount is not active
* logs: rework the syslog parser
* docker: use system dns for app containers
* logs: show error message in UI when log rotated
* unbound: prefer ip4 for dns queries (only on ubuntu 24 and above)
* apps: allow operators to update apps

View File

@@ -171,6 +171,7 @@ const REGIONS_WASABI = [
const REGIONS_DIGITALOCEAN = [
{ name: 'AMS3', value: 'https://ams3.digitaloceanspaces.com' },
{ name: 'FRA1', value: 'https://fra1.digitaloceanspaces.com' },
{ name: 'LON1', value: 'https://lon1.digitaloceanspaces.com' },
{ name: 'NYC3', value: 'https://nyc3.digitaloceanspaces.com' },
{ name: 'SFO2', value: 'https://sfo2.digitaloceanspaces.com' },
{ name: 'SFO3', value: 'https://sfo3.digitaloceanspaces.com' },
@@ -181,6 +182,7 @@ const REGIONS_DIGITALOCEAN = [
// https://www.exoscale.com/datacenters/
const REGIONS_EXOSCALE = [
{ name: 'Vienna (AT-VIE-1)', value: 'https://sos-at-vie-1.exo.io' },
{ name: 'Vienna (AT-VIE-2)', value: 'https://sos-at-vie-2.exo.io' },
{ name: 'Sofia (BG-SOF-1)', value: 'https://sos-bg-sof-1.exo.io' },
{ name: 'Zurich (CH-DK-2)', value: 'https://sos-ch-dk-2.exo.io' },
{ name: 'Geneva (CH-GVA-2)', value: 'https://sos-ch-gva-2.exo.io' },
@@ -240,9 +242,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 +471,6 @@ function redirectIfNeeded(status, currentView) {
return false;
}
console.log(status, currentView);
if (status.activated) {
console.log('Already activated');
if (currentView === 'dashboard') {
@@ -1399,8 +1400,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
};
Client.prototype.getUpdateInfo = function (callback) {
if (!this._userInfo.isAtLeastAdmin) return callback(new Error('Not allowed'));
get('/api/v1/updater/updates', null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
@@ -2643,9 +2642,10 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
this.config(function (error, result) {
if (error) return callback(error);
that.getUpdateInfo(function (error, info) { // note: non-admin users may get access denied for this
if (!error) result.update = info.update; // attach update information to config object
that.getUpdateInfo(function (error, info) {
if (error) return callback(error);
result.update = info.update;
that.setConfig(result);
callback(null);
});

View File

@@ -713,6 +713,10 @@ multiselect {
background-color: transparent;
}
.checklist-item > span > p {
margin: 0;
}
// ----------------------------
// Mail view
// ----------------------------
@@ -1755,6 +1759,7 @@ div:hover > .picture-edit-indicator {
.notification-item {
cursor: pointer;
padding: 10px 15px;
&:hover {
box-shadow: 0 2px 27px rgba(0,0,0,.1);

View File

@@ -22,13 +22,17 @@
"auth": {
"sso": "Log ind med Cloudron-oplysninger",
"nosso": "Log ind med en dedikeret konto",
"email": "Log ind med din e-mailadresse"
"email": "Log ind med din e-mailadresse",
"openid": "Log ind med Cloudron OpenID"
},
"addAppAction": "Tilføj app",
"addAppproxyAction": "Tilføj app-proxy",
"addApplinkAction": "Tilføj app-link",
"filter": {
"clearAll": "Ryd alt"
},
"apps": {
"count": "Antal apps: {{ count }}"
}
},
"main": {
@@ -80,7 +84,8 @@
"justNow": "lige nu",
"yeserday": "I går",
"minutesAgo": "{{ m }} minutter siden",
"hoursAgo": "{{ h }} timer siden"
"hoursAgo": "{{ h }} timer siden",
"never": "Aldrig"
},
"navbar": {
"users": "Brugere"
@@ -165,7 +170,10 @@
"loginAction": "Login",
"createAccountAction": "Opret konto",
"switchToSignUpAction": "Har du ikke en konto endnu? Tilmeld dig",
"switchToLoginAction": "Har du allerede en konto? Log ind"
"switchToLoginAction": "Har du allerede en konto? Log ind",
"setupWithTokenAction": "Opsætning",
"setupToken": "Opsætningstoken",
"titleToken": "Tilmeld dig med installationstoken"
},
"title": "App Store",
"searchPlaceholder": "Søg efter alternativer som Github, Dropbox, Slack, Trello, …",
@@ -180,7 +188,7 @@
"users": {
"externalLdap": {
"title": "Tilslut en ekstern mappe",
"description": "Cloudron synkroniserer brugere og grupper fra en ekstern LDAP- eller ActiveDirectory-server. Adgangskodebekræftelse til autentificering af disse brugere foretages mod den eksterne server. Synkroniseringen køres ikke automatisk, men skal udløses manuelt.",
"description": "Denne indstilling synkroniserer og godkender brugere og grupper fra en ekstern LDAP- eller Active Directory-server. Synkroniseringen køres med jævne mellemrum, men kan også udløses manuelt.",
"bindUsername": "Bind DN/Benyttelsesnavn (valgfrit)",
"subscriptionRequiredAction": "Oprettelse af abonnement nu",
"noopInfo": "LDAP-godkendelse er ikke konfigureret.",
@@ -195,14 +203,15 @@
"groupFilter": "Gruppefilter",
"groupnameField": "Groupname Felt",
"auth": "Auth",
"autocreateUsersOnLogin": "Opret automatisk brugere, når de logger ind på Cloudron",
"autocreateUsersOnLogin": "Opret automatisk brugere ved login",
"showLogsAction": "Vis logs",
"syncAction": "Synkroniser",
"configureAction": "Konfigurer",
"bindPassword": "Bind adgangskode (valgfrit)",
"errorSelfSignedCert": "Serveren bruger et ugyldigt eller selvsigneret certifikat.",
"providerOther": "Andre",
"providerDisabled": "Deaktiveret"
"providerDisabled": "Deaktiveret",
"disableWarning": "Godkendelseskilden for alle eksisterende brugere bliver nulstillet til at godkende mod den lokale adgangskodedatabase."
},
"addUserDialog": {
"sendInviteCheckbox": "Send en e-mail med en invitation nu",
@@ -227,7 +236,9 @@
"primaryEmail": "Primær e-mail",
"errorDisplayNameRequired": "Navn er påkrævet",
"activeCheckbox": "Brugeren er aktiv",
"displayNamePlaceholder": "Valgfrit. Hvis den ikke er angivet, kan brugeren angive den under tilmeldingen"
"displayNamePlaceholder": "Valgfrit. Hvis den ikke er angivet, kan brugeren angive den under tilmeldingen",
"external2FA": "2FA-opsætning styres af ekstern godkendelseskilde",
"ldapGroups": "LDAP-grupper"
},
"invitationDialog": {
"descriptionLink": "Kopier link til invitation",
@@ -255,10 +266,11 @@
"description": "Cloudron kan fungere som en central brugerkatalogserver for eksterne programmer.",
"enabled": "Aktiveret",
"ipRestriction": {
"description": "Mappeserveren kan begrænses til bestemte IP'er eller områder.",
"description": "Begræns adgang til Directory Server til specifikke IP'er eller områder. Linjer, der starter med <code>#</code>, behandles som kommentarer.",
"placeholder": "Linjeadskilt IP-adresse eller undernet",
"label": "Begræns adgang"
}
},
"cloudflarePortWarning": "Cloudflare-proxying skal deaktiveres på dashboard-domænet for at få adgang til LDAP-serveren"
},
"userImportDialog": {
"description": "Upload en JSON- eller CSV-fil med det skema, der er beskrevet i vores <a href=\"{{ docsLink }}\" target=\"_blank\">dokumentation</a>",
@@ -482,7 +494,10 @@
"changeEmail": {
"title": "Ændre primær e-mailadresse",
"errorEmailInvalid": "E-mail-adressen er ikke gyldig",
"errorEmailRequired": "En gyldig e-mailadresse er påkrævet"
"errorEmailRequired": "En gyldig e-mailadresse er påkrævet",
"email": "Ny e-mailadresse",
"password": "Adgangskode til bekræftelse",
"errorWrongPassword": "Forkert adgangskode"
},
"changeDisplayName": {
"title": "Ændre dit visningsnavn",
@@ -609,7 +624,7 @@
},
"check": {
"noop": "Cloudron-backups er deaktiveret. Sørg for, at der tages backup af denne server ved hjælp af alternative midler. Se https://docs.cloudron.io/backups/#storage-providers for flere oplysninger.",
"sameDisk": "Cloudron-backups er i øjeblikket på den samme disk som Cloudron-serverinstansen. Dette er farligt og kan føre til fuldstændigt tab af data, hvis disken fejler. Se https://docs.cloudron.io/backups/#storage-providers for lagring af sikkerhedskopier på en ekstern placering."
"sameDisk": "Sikkerhedskopierne ligger i øjeblikket på den samme disk som Cloudron selv. Hvis disken fyldes op med disse sikkerhedskopier, vil Cloudron ikke fungere. En diskfejl kan også føre til fuldstændigt datatab. Se https://docs.cloudron.io/backups/#storage-providers for at gemme sikkerhedskopier på et eksternt sted."
},
"title": "Sikkerhedskopiering",
"logs": {
@@ -642,7 +657,9 @@
"logo": "Logo",
"changeLogo": {
"title": "Vælg Cloudron Avatar"
}
},
"backgroundImage": "Baggrundsbillede af login-side",
"clearBackgroundImage": "Klar"
},
"emails": {
"domains": {
@@ -771,7 +788,7 @@
"ip": {
"interfaceDescription": "Liste over tilgængelige enheder på serveren med:",
"title": "IP-adresse",
"description": "Cloudron bruger denne IP-adresse, når der oprettes DNS-poster.",
"description": "Cloudron bruger denne IPv4-adresse til at oprette DNS A-poster.",
"provider": "Udbyder",
"interface": "Navn på netværksgrænseflade",
"configure": "Konfigurer",
@@ -795,7 +812,7 @@
},
"title": "Netværk",
"configureIp": {
"title": "Konfigurer IP-provider",
"title": "Konfigurer IPv4-provider",
"providerGenericDescription": "Serverens offentlige IP-adresse registreres automatisk."
},
"ipv4": {
@@ -838,14 +855,12 @@
},
"settings": {
"timezone": {
"description": "Den aktuelle tidszoneindstilling er <b>{{{{ timeZone }}}</b>.\nDenne indstilling bruges til planlægning af backup- og opdateringsopgaver.",
"description": "Den aktuelle tidszoneindstilling er <b>{{ timeZone }}</b>. Denne indstilling bruges til at planlægge backup- og opdateringsopgaver. Tidsstempler i brugergrænsefladen vises altid i browserens tidszone.",
"title": "Tidszone"
},
"updates": {
"updateAvailableAction": "Opdatering tilgængelig",
"title": "Opdateringer",
"autoUpdateDisabled": "Automatisk opdatering af platformen og apps er<b>deaktiveret</b>.",
"currentSchedule": "Den nuværende tidsplan for automatisk opdatering af platform og apps er",
"version": "Platform version",
"showLogsAction": "Vis logs",
"changeScheduleAction": "Ændre tidsplan",
@@ -940,7 +955,11 @@
"disableAction": "Deaktivere SSH-støtteadgang",
"enableAction": "Aktiver SSH-støtteadgang"
},
"title": "Støtte"
"title": "Støtte",
"help": {
"title": "Hjælp",
"description": "Brug venligst følgende ressourcer til hjælp og support:\n* [Cloudron Forum]({{ forumLink }}) - Brug venligst de support- og app-specifikke kategorier til spørgsmål.\n* [Cloudron Docs & Knowledge Base]({{ docsLink }})\n* [Custom App Packaging & API]({{ packagingLink }})\n"
}
},
"system": {
"diskUsage": {
@@ -964,7 +983,19 @@
"title": "System Memory",
"graphSubtext": "Kun apps, der bruger mere end {{ threshold }} af memory, vises"
},
"selectPeriodLabel": "Vælg periode"
"selectPeriodLabel": "Vælg periode",
"info": {
"platformVersion": "Platformsversion",
"title": "Info",
"vendor": "Leverandør",
"product": "Produtk",
"memory": "Memory",
"uptime": "Driftstid",
"activationTime": "Cloudrons skabelsestidspunkt"
},
"graphs": {
"title": "Diagrammer"
}
},
"domains": {
"renewCerts": {
@@ -1025,7 +1056,13 @@
"porkbunSecretapikey": "Hemmelig API-nøgle",
"cloudflareDefaultProxyStatus": "Aktiver proxying for nye DNS-poster",
"porkbunApikey": "API-nøgle",
"bunnyAccessKey": "Bunny Access Key"
"bunnyAccessKey": "Bunny Access Key",
"deSecToken": "deSEC Token",
"dnsimpleAccessToken": "Adgangstoken",
"ovhEndpoint": "Endepunkt",
"ovhConsumerKey": "Consumer Key",
"ovhAppKey": "Application Key",
"ovhAppSecret": "Application Secret"
},
"title": "Domæner og certs",
"addDomain": "Tilføj domæne",
@@ -1089,7 +1126,8 @@
"copy": "Kopier",
"clear": "Klar",
"pasteInfo": "Brug Ctrl+v til at indsætte for at indsætte"
}
},
"uploadTo": "Upload til {{ path }}"
},
"filemanager": {
"newFileDialog": {
@@ -1121,7 +1159,8 @@
"renameDialog": {
"title": "Omdøb {{ fileName }}",
"newName": "Nyt navn",
"rename": "Omdøb"
"rename": "Omdøb",
"reallyOverwrite": "Der findes allerede en fil med det navn. Overskrive eksisterende fil?"
},
"extractDialog": {
"title": "Udpakning af {{ fileName }}",
@@ -1278,7 +1317,7 @@
},
"enableEmailDialog": {
"description": "Dette vil konfigurere Cloudron til at modtage e-mails for<b>{{ domain }}</b>Se dokumentationen for åbning af de <a href=\"{{{ requiredPortsDocsLink }}\" target=\"_blank\">forpligtede porte</a> for Cloudron Email.",
"cloudflareInfo": "Domænet <code>{{{ adminDomain }}</code> administreres af Cloudflare. Kontroller venligst, at Cloudflare-proxying er deaktiveret for <code>{{{ mailFqdn }}</code> og indstillet til <code>Kun DNS</code>. Dette er påkrævet, fordi Cloudflare ikke giver proxy for e-mail.",
"cloudflareInfo": "Mailserverens domæne <code>{{ adminDomain }}</code> administreres af Cloudflare. Kontrollér, at Cloudflare-proxy er deaktiveret for <code>{{ mailFqdn }}</code> og indstillet til <code>kun DNS</code>. Dette er nødvendigt, fordi Cloudflare ikke proxy'er e-mail.",
"title": "Aktiver e-mail for {{ domain }}?",
"noProviderInfo": "Der er ikke oprettet nogen DNS-udbyder. De DNS-poster, der er anført i fanen Status, skal oprettes manuelt.",
"setupDnsCheckbox": "Opsæt Mail DNS-poster nu",
@@ -1431,7 +1470,7 @@
},
"memory": {
"title": "Memory graense",
"description": "Cloudron tildeler 50 % af denne værdi som RAM og 50 % som swap.",
"description": "Maksimal arbejdshastighed, som appen kan bruge",
"error": "Kan ikke indstille memory limit, prøv mindre.",
"resizeAction": "Ændre størrelse"
}
@@ -1492,11 +1531,12 @@
"packageVersion": "Pakkeversion",
"lastUpdated": "Sidst opdateret",
"checkForUpdatesAction": "Tjek for opdateringer",
"customAppUpdateInfo": "Opdateringer er ikke tilgængelige for brugerdefinerede apps",
"updateAvailableAction": "Opdatering tilgængelig"
"customAppUpdateInfo": "Automatisk opdatering er ikke tilgængelig for brugerdefinerede apps.",
"updateAvailableAction": "Opdatering tilgængelig",
"installedAt": "Installeret på"
},
"auto": {
"description": "Cloudron kontrollerer jævnligt App Store for opdateringer. Hvis du deaktiverer automatiske opdateringer, skal du sørge for at anvende opdateringerne manuelt.",
"description": "Cloudron tjekker med jævne mellemrum <a href=»{{ appStoreLink }}« target=»_blank«>App Store</a> for opdateringer.",
"title": "Automatiske opdateringer",
"enabled": "Automatiske opdateringer er i øjeblikket aktiveret.",
"disabled": "Automatiske opdateringer er i øjeblikket deaktiveret.",
@@ -1569,7 +1609,8 @@
"openAction": "Åbn {{ app }}",
"firstTimeTitle": "Første gang du bruger det",
"firstTimeCollapseHeader": "Første gangs opsætningsvejledning",
"customAppUpdateWarning": "Dette er en brugerdefineret app, som ikke er installeret fra App Store og ikke modtager opdateringer. Se <a target=\"_blank\" href=\"{{ docsLink }}\">Dokumentation</a> om, hvordan du opdaterer en brugerdefineret app."
"customAppUpdateWarning": "Dette er en brugerdefineret app, som ikke er installeret fra App Store og ikke modtager opdateringer. Se <a target=\"_blank\" href=\"{{ docsLink }}\">Dokumentation</a> om, hvordan du opdaterer en brugerdefineret app.",
"checklist": "Administrativ tjekliste"
},
"restoreDialog": {
"warning": "Alle data, der er genereret mellem nu og den sidst kendte sikkerhedskopi, vil uigenkaldeligt gå tabt. Det anbefales at oprette en sikkerhedskopi af de aktuelle data, før du forsøger at gendanne dem.",
@@ -1718,6 +1759,12 @@
"title": "Redis-konfiguration",
"enable": "Konfigurer appen til at bruge Redis",
"disable": "Deaktiver Redis"
},
"infoTabTitle": "Info",
"info": {
"notes": {
"title": "Administrative noter"
}
}
},
"passwordReset": {
@@ -1821,7 +1868,11 @@
"mountStatus": "Status for montering",
"type": "Type",
"localDirectory": "Lokal vejviser",
"remountActionTooltip": "Genmonter"
"remountActionTooltip": "Genmonter",
"editVolumeDialog": {
"title": "Rediger volumen {{ name }}"
},
"editActionTooltip": "Rediger volumen"
},
"newLoginEmail": {
"topic": "Vi har bemærket et nyt login på din Cloudron-konto.",
@@ -1859,7 +1910,8 @@
"signInAction": "Log ind",
"resetPasswordAction": "Nulstil adgangskode",
"errorIncorrect2FAToken": "2FA-token er ugyldig",
"errorInternal": "Intern fejl, prøv igen senere"
"errorInternal": "Intern fejl, prøv igen senere",
"loginWith": "Log ind med Cloudron"
},
"lang": {
"en": "English",
@@ -1874,7 +1926,8 @@
"es": "Spansk",
"ru": "Russisk",
"pt": "Portugisisk",
"da": "Dansk"
"da": "Dansk",
"id": "Indonesisk"
},
"supportConfig": {
"emailNotVerified": "Du bedes først bekræfte e-mailen på cloudron.io-kontoen for at sikre, at vi kan kontakte dig."

View File

@@ -167,13 +167,11 @@
"updates": {
"checkForUpdatesAction": "Auf Aktualisierungen überprüfen",
"title": "Aktualisierungen",
"currentSchedule": "Die Einstellungen für die automatische Aktualisierung für System und Anwendungen lautet:",
"version": "Systemversion",
"changeScheduleAction": "Zeitplan ändern",
"stopUpdateAction": "Aktualisierung abbrechen",
"updateAvailableAction": "Aktualisierung verfügbar",
"showLogsAction": "Logfiles anzeigen",
"autoUpdateDisabled": "Die automatische Aktualisierung des Systems und der Anwendungen ist <b>deaktiviert</b>."
"showLogsAction": "Logfiles anzeigen"
},
"appstoreAccount": {
"title": "Cloudron.io-Konto",

View File

@@ -521,7 +521,7 @@
"title": "Backups",
"location": {
"title": "Location",
"description": "Cloudron makes a complete backup of your system at the configured location.",
"description": "A complete backup of your system is saved to the storage location with the configured format.",
"disabledList": "The following apps have automatic backups disabled:",
"provider": "Provider",
"location": "Location",
@@ -532,7 +532,7 @@
},
"schedule": {
"title": "Schedule and Retention",
"description": "Cloudron makes a complete backup of your system based on this scheduled interval and keeps backups with the specified retention policy.",
"description": "A complete backup of the system is created based on the specified Schedule in the <a href=\"/#/settings\">System Time Zone</a>. Old backups are removed based on the Retention Policy.",
"schedule": "Schedule",
"retentionPolicy": "Retention Policy",
"configure": "Configure"
@@ -788,7 +788,7 @@
"title": "Network",
"ip": {
"title": "IPv4",
"description": "Cloudron uses this IPv4 address to setup DNS A records.",
"description": "This IPv4 address is used to set up DNS A records.",
"provider": "Provider",
"interface": "Network Interface Name",
"configure": "Configure",
@@ -821,7 +821,7 @@
"ipv6": {
"address": "IPv6 Address",
"title": "IPv6",
"description": "Cloudron uses this IPv6 address to setup DNS AAAA records.\n"
"description": "This IPv6 address is used to set up DNS AAAA records."
},
"configureIpv6": {
"title": "Configure IPv6 Provider"
@@ -835,7 +835,7 @@
},
"services": {
"title": "Services",
"description": "Cloudron services implement functionality such as databases, email and authentication.",
"description": "Services implement functionality such as databases, email and authentication.",
"service": "Service",
"memoryUsage": "Memory Usage",
"memoryLimit": "Memory Limit",
@@ -869,19 +869,20 @@
"emailNotVerified": "Email not yet verified"
},
"timezone": {
"title": "Time Zone",
"description": "The current timezone setting is <b>{{ timeZone }}</b>.\nThis setting is used for scheduling backup and update tasks."
"title": "System Time Zone",
"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",
"autoUpdateDisabled": "Automatic update for the platform and apps is <b>disabled</b>.",
"currentSchedule": "The current automatic update schedule for platform and apps is",
"version": "Platform version",
"showLogsAction": "Show Logs",
"changeScheduleAction": "Change Schedule",
"checkForUpdatesAction": "Check for Updates",
"updateAvailableAction": "Update Available",
"stopUpdateAction": "Stop Update"
"stopUpdateAction": "Stop Update",
"disabled": "Disabled",
"schedule": "Schedule",
"description": "Platform and App Updates are automatically applied based on the Schedule in the <a href=\"/#/settings\">System Time Zone</a>."
},
"privateDockerRegistry": {
"title": "Private Docker Registry",
@@ -1016,7 +1017,7 @@
"tooltipRemove": "Remove Domain",
"renewCerts": {
"title": "Renew certificates",
"description": "Cloudron renews Let's Encrypt certificates automatically. Use this option to trigger a renewal immediately.",
"description": "Let's Encrypt certificates are renewed automatically. Use this option to trigger a renewal immediately.",
"renewAllAction": "Renew All Certs",
"showLogsAction": "Show Logs"
},
@@ -1301,7 +1302,7 @@
"outbound": {
"tabTitle": "Outbound",
"title": "Email Relay",
"description": "Cloudron will use this mail server (Smart host) to send the outbound mails of apps installed under this domain.",
"description": "This mail server (Smart host) will be used to send the outbound mails of apps installed under this domain.",
"noopAdminDomainWarning": "Cloudron cannot send user invites, password reset and other notifications when email is disabled on the primary domain",
"noopNonAdminDomainWarning": "Cloudron cannot provide email sending for apps hosted under this domain when email is disabled.",
"mailRelay": {
@@ -1627,7 +1628,7 @@
},
"auto": {
"title": "Automatic Backups",
"description": "Cloudron periodically creates a backup based on the <a href=\"{{ backupLink }}\">backup</a> settings.",
"description": "Backups are periodically created based on the <a href=\"{{ backupLink }}\">Backup Schedule</a>.",
"enabled": "Automatic Backups is currently enabled.",
"disabled": "Automatic Backups is currently disabled.",
"disableAction": "Disable Automatic Backups",
@@ -1672,7 +1673,8 @@
"openAction": "Open {{ app }}",
"firstTimeTitle": "First Time Usage",
"firstTimeCollapseHeader": "First time setup instructions",
"customAppUpdateWarning": "This is a custom app and not installed from the App Store and will not receive updates. See the <a target=\"_blank\" href=\"{{ docsLink }}\">Documentation</a> on how to update a custom app."
"customAppUpdateWarning": "This is a custom app and not installed from the App Store and will not receive updates. See the <a target=\"_blank\" href=\"{{ docsLink }}\">Documentation</a> on how to update a custom app.",
"checklist": "Admin Checklist"
},
"uninstallDialog": {
"title": "Uninstall {{ app }}",

View File

@@ -871,8 +871,6 @@
"changeScheduleAction": "Cambiar Programación",
"showLogsAction": "Mostrar Registros",
"version": "Versión de la Plataforma",
"currentSchedule": "El programa actual de actualización automática para la plataforma y las aplicaciones es",
"autoUpdateDisabled": "La actualización automática de la plataforma y las aplicaciones está <b> desactivada </b>.",
"title": "Actualizaciones"
},
"language": {

View File

@@ -791,8 +791,6 @@
"changeScheduleAction": "Modifier la fréquence",
"showLogsAction": "Afficher les journaux",
"version": "Version de la plateforme",
"currentSchedule": "La mise à jour automatique de la plateforme et des application a lieu",
"autoUpdateDisabled": "La mise à jour automatique de la plateforme et des applications est <b>désactivée</b>.",
"title": "Mises à jour"
},
"timezone": {

View File

@@ -1151,8 +1151,6 @@
"changeScheduleAction": "Cambia Pianificazione",
"showLogsAction": "Visualizza Logs",
"version": "Versione piattaforma",
"currentSchedule": "L'attuale programma di aggiornamento automatico per piattaforma e app è",
"autoUpdateDisabled": "L'aggiornamento automatico per la piattaforma e le app è <b>disabilitato</b>.",
"title": "Aggiornamenti"
},
"timezone": {

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"
},
@@ -1117,7 +1117,8 @@
"customAppUpdateWarning": "Dit is een aangepaste app en niet geïnstalleerd vanuit de App Store, het krijgt hierdoor geen updates. Lees de <a target=\"_blank\" href=\"{{ docsLink }}\">documentatie</a> over hoe je een aangepaste app kunt updaten.",
"appDocsUrl": "Bekijk de <a target=\"_blank\" href=\"{{ docsUrl }}\">{{ title }} documentatie</a> voor informatie en tips over deze app. Indien je meer hulp nodig hebt ga dan naar Cloudron's <a target=\"_blank\" href=\"{{ forumUrl }}\">{{ title }} forum</a>.",
"sso": "Deze app is ingesteld voor authenticatie via het Cloudron gebuikersadresboek. Cloudron gebruikers kunnen inloggen en het direct gebruiken.",
"ssoEmail": "Deze app is zodanig ingesteld dat alle gebruikers met een e-mailbox op deze Cloudron toegang hebben. Log in met je e-mailadres en wachtwoord voor toegang tot die e-mailbox."
"ssoEmail": "Deze app is zodanig ingesteld dat alle gebruikers met een e-mailbox op deze Cloudron toegang hebben. Log in met je e-mailadres en wachtwoord voor toegang tot die e-mailbox.",
"checklist": "Admin Controlelijst"
},
"uninstallDialog": {
"uninstallAction": "De-installeer",
@@ -1315,12 +1316,10 @@
},
"timezone": {
"title": "Tijdzone",
"description": "De huidige tijdzone instelling is <b>{{ timeZone }}</b>.\nDeze instelling wordt gebruikt voor backup planning en update taken."
"description": "De huidige tijdzone instelling is <b>{{ timeZone }}</b>. Deze instelling wordt gebruikt voor backup planning en update taken. Tijdseenheden in de gebruikersschermen zijn op basis van de browers tijdzone."
},
"updates": {
"title": "Updates",
"autoUpdateDisabled": "Automatische update voor het platform en apps is <b>uitgeschakeld</b>.",
"currentSchedule": "De huidige automatische update planning voor het platform en de apps is",
"showLogsAction": "Toon logbestanden",
"changeScheduleAction": "Planning aanpassen",
"checkForUpdatesAction": "Controleer op updates",
@@ -1492,7 +1491,8 @@
"upload": {
"title": "Uploaden bestand naar {{ name }}"
},
"uploadToTmp": "Upload naar /tmp"
"uploadToTmp": "Upload naar /tmp",
"uploadTo": "Upload naar {{ path }}"
},
"filemanager": {
"title": "Bestandsbeheer",

View File

@@ -1228,9 +1228,7 @@
"checkForUpdatesAction": "Проверить обновления",
"updateAvailableAction": "Обновление доступно",
"version": "Версия платформы",
"stopUpdateAction": "Остановить обновление",
"autoUpdateDisabled": "Автоматические обновления для платформы и приложений <b>выключены</b>.",
"currentSchedule": "Текущее расписание автоматических обновлений для платформы и приложений"
"stopUpdateAction": "Остановить обновление"
},
"privateDockerRegistry": {
"title": "Частный реестр Docker",

View File

@@ -22,14 +22,18 @@
"auth": {
"email": "Đăng nhập bằng email",
"sso": "Đăng nhập với tên & mật khẩu trên Cloudron",
"nosso": "Đăng nhập vào tài khoản riêng"
"nosso": "Đăng nhập bằng tài khoản riêng",
"openid": "Đăng nhập bằng Cloudron OpenID"
},
"addAppAction": "Thêm App",
"addApplinkAction": "Thêm đường link App",
"addApplinkAction": "Thêm link App",
"filter": {
"clearAll": "Xoá tất cả"
},
"addAppproxyAction": "Thêm proxy cho app"
"addAppproxyAction": "Thêm proxy cho app",
"apps": {
"count": "Tổng số app: {{ count }}"
}
},
"main": {
"logout": "Thoát",
@@ -80,7 +84,8 @@
"justNow": "mới đây",
"yeserday": "Hôm qua",
"minutesAgo": "{{ m }} phút trước",
"hoursAgo": "{{ h }} tiếng trước"
"hoursAgo": "{{ h }} tiếng trước",
"never": "Chưa lần nào"
},
"statusEnabled": "Đã bật",
"statusDisabled": "Đã tắt",
@@ -107,7 +112,7 @@
"finance": "Tài chính",
"git": "Chạy code",
"email": "Email",
"game": "Game",
"game": "Trò chơi",
"hosting": "Chạy web",
"media": "Hình ảnh",
"learning": "Học tập",
@@ -130,7 +135,7 @@
"manualWarning": "Thêm A record cho <b>{{ nơi cài đặt }}</b> vào địa chỉ IP công cộng của Cloudron này",
"userManagement": "Quản lý người dùng",
"userManagementMailbox": "Tất cả người dùng với hộp thư trên Cloudron này có quyền truy cập app.",
"userManagementLeaveToApp": "Để phần quản lý người dùng cho app",
"userManagementLeaveToApp": "Để app quản lý người dùng",
"userManagementAllUsers": "Cho phép tất cả người dùng trên Cloudron truy cập",
"errorUserManagementSelectAtLeastOne": "Chọn ít nhất một người dùng hay nhóm",
"users": "Người dùng",
@@ -138,10 +143,10 @@
"lowOnResources": "Cloudron này đang chạy gần hết bộ nhớ.",
"pleaseUpgradeServer": "Hãy nâng cấp server có bộ nhớ nhiều hơn. Hoặc, xoá những app không dùng đến để có thêm chỗ trống.",
"setupSubscriptionAction": "Cài đặt gói đăng ký",
"installAnywayAction": "Vẫn tải về luôn",
"installAnywayAction": "Vẫn tải về",
"installAction": "Tải về",
"subscriptionRequired": "Để cài đặt thêm app, hãy đăng ký gói trả phí.",
"userManagementNone": "App này có phần quản lý người dùng riêng. Phần cài đặt này điều chỉnh app có hiển thị hay không trên bảng dashboard của người dùng.",
"userManagementNone": "App này có phần quản lý người dùng riêng. Cài đặt này điều chỉnh app có hiển thị hay không trên bảng dashboard của người dùng.",
"userManagementSelectUsers": "Chỉ cho phép người dùng và nhóm sau",
"configuredForCloudronEmail": "App này đã được cấu hình sẵn để sử dụng với <a href=\"{{ emailDocsLink }}\" target=\"_blank\">Cloudron Email</a>.",
"doInstallAction": "Tải về {{ dnsOverwrite ? 'and overwrite DNS' : '' }}",
@@ -167,7 +172,10 @@
"switchToLoginAction": "Đã có tài khoản rồi? Đăng nhập",
"switchToSignUpAction": "Chưa có tài khoản? Hãy đăng ký nhé",
"description": "Tài khoản này được dùng để truy cập Cửa hàng App và quản lý gói đăng ký của bạn",
"licenseCheckbox": "Tôi đồng ý <a href=\"{{ licenseLink }}\" target=\"_blank\">bản quyền</a> của Cloudron"
"licenseCheckbox": "Tôi đồng ý <a href=\"{{ licenseLink }}\" target=\"_blank\">bản quyền</a> của Cloudron",
"setupWithTokenAction": "Cài đặt",
"titleToken": "Đăng ký với Mã cài đặt",
"setupToken": "Cài đặt Mã"
},
"searchPlaceholder": "Tìm kiếm app thay thế cho Github, Dropbox, Slack, Trello, …",
"appMissing": "Thiếu app nào đó? Hãy nhắn cho chúng tôi.",
@@ -205,7 +213,9 @@
"username": "Tên đăng nhập",
"fullName": "Họ tên",
"fallbackEmailPlaceholder": "Không bắt buộc. Nếu không được xác định, email chính sẽ được sử dụng",
"displayNamePlaceholder": "Không bắt buộc. Nếu để trống, người dùng có thể tự cài đặt trong lúc đăng ký"
"displayNamePlaceholder": "Không bắt buộc. Nếu để trống, người dùng có thể tự cài đặt trong lúc đăng ký",
"external2FA": "Nguồn xác thực ngoài đang quản lý cài đặt Mã xác minh 2 Bước",
"ldapGroups": "Nhóm LDAP"
},
"addUserDialog": {
"addUserAction": "Thêm người dùng",
@@ -223,7 +233,7 @@
"configureAction": "Cấu hình",
"syncAction": "Đồng bộ",
"showLogsAction": "Hiển thị log",
"autocreateUsersOnLogin": "Tự động tạo tài khoản người dùng khi họ đăng nhập vào Cloudron",
"autocreateUsersOnLogin": "Tự động tạo người dùng khi họ đăng ",
"auth": "Xác minh",
"groupnameField": "Vùng tên nhóm",
"groupFilter": "Lọc nhóm",
@@ -237,10 +247,11 @@
"provider": "Nhà cung cấp",
"noopInfo": "Xác thực LDAP chưa được thiết lập.",
"subscriptionRequiredAction": "Cài đặt gói đăng ký ngay",
"description": "Cloudron sẽ đồng bộ người dùng và nhóm từ server LDAP hay ActiveDirectory bên ngoài. Xác minh mật khẩu cho người dùng được dựa trên server ngoài. Việc đồng bộ hoá không được chạy tự động mà cần được khởi động bằng tay.",
"description": "Cài đặt này đồng bộ và xác thực người dùng và nhóm từ một server LDAP hay ActiveDirectory bên ngoài. Sự đồng bộ hóa này được chạy theo chu kỳ nhưng cũng có thể được khởi động bằng tay.",
"title": "Kết nối thư mục ngoài",
"providerOther": "Khác",
"providerDisabled": "Đã tắt"
"providerDisabled": "Đã tắt",
"disableWarning": "Nguồn mã xác minh cho tất cả người dùng hiện hữu sẽ được cài đặt lại dựa trên cơ sở dữ liệu mật khẩu nội bộ trên server."
},
"users": {
"inactiveTooltip": "Người dùng không hoạt động",
@@ -250,12 +261,12 @@
"notActivatedYetTooltip": "Người dùng chưa được kích hoạt",
"externalLdapTooltip": "Từ thư mục LDAP ngoài",
"usermanagerTooltip": "Người dùng này có thể quản lý nhóm và những người dùng khác",
"adminTooltip": "Người dùng này có vai trò admin",
"superadminTooltip": "Người dùng này có vai trò superadmin",
"adminTooltip": "Người dùng này admin",
"superadminTooltip": "Người dùng này superadmin",
"empty": "Không tìm thấy người dùng",
"groups": "Nhóm",
"user": "Người dùng",
"transferOwnershipTooltip": "Chuyển đổi quyền sở hữu",
"transferOwnershipTooltip": "Chuyển nhượng quyền sở hữu",
"invitationTooltip": "Mời Người dùng",
"setGhostTooltip": "Nhập vai",
"count": "Tổng ng dùng: {{ count }}",
@@ -334,7 +345,7 @@
"enabled": "Đã bật",
"title": "Máy chủ chỉ mục",
"ipRestriction": {
"description": "Máy chủ chỉ mục có thể được giới hạn cho những địa chỉ IP hoặc khoảng vùng cụ thể.",
"description": "Giới hạn quyền truy cập máy chủ chỉ mục cho những địa chỉ IP hoặc khoảng vùng cụ thể. Những dòng bắt đầu bằng dấu <code>#</code> được xem như ghi chú thêm.",
"placeholder": "Viết xuống dòng những địa chỉ IP hoặc Subnet",
"label": "Giới hạn quyền truy cập"
},
@@ -342,7 +353,8 @@
"label": "Mật khẩu bind",
"description": "Tất cả những yêu cầu LDAP cần phải được xác minh với mã bí mật này và tên người dùng user DN <i>{{ userDN }}</i>",
"url": "URL máy chủ"
}
},
"cloudflarePortWarning": "Cần tắt proxy Cloudflare cho tên miền dashboard để truy cập LDAP server"
},
"userImportDialog": {
"success": "{{ count }} người dùng đã được nhập vào.",
@@ -478,7 +490,10 @@
"changeEmail": {
"title": "Thay đổi email chính",
"errorEmailInvalid": "Email không hợp lệ",
"errorEmailRequired": "Bạn cần nhập một email hợp lệ"
"errorEmailRequired": "Bạn cần nhập một email hợp lệ",
"email": "Thêm địa chỉ mail mới",
"password": "Mật khẩu để xác nhận",
"errorWrongPassword": "Sai mật khẩu"
},
"disable2FAAction": "Tắt xác minh hai bước",
"changeFallbackEmail": {
@@ -499,7 +514,8 @@
"passwordResetNotification": {
"title": "Đã đặt lại mật khẩu thành công",
"body": "Email đã được gửi đến {{ email }}"
}
},
"enable2FANotAvailable": "Không cài được cho người dùng từ nguồn xác minh ngoài"
},
"backups": {
"location": {
@@ -617,7 +633,7 @@
},
"check": {
"noop": "Tính năng sao lưu Cloudron đã tắt. Hãy chắc rằng server được sao lưu bằng một biện pháp khác. Xem thông tin thêm tại https://docs.cloudron.io/backups/#storage-providers.",
"sameDisk": "Các bản sao lưu Cloudron đang ở trên cùng ổ đĩa với server chạy Cloudron. Việc này sẽ nguy hiểm và có thể dẫn đến mất dữ liệu nếu ổ đĩa bị trục trặc. Xem cách sao lưu tại ổ đĩa ngoài tại https://docs.cloudron.io/backups/#storage-providers."
"sameDisk": "Các bản sao lưu Cloudron hiện đang ở trên cùng ổ đĩa với server chạy Cloudron. Nếu ổ đĩa chứa đầy các bản sao lưu, Cloudron sẽ không hoạt động được. Sự c trục trặc ổ đĩa cũng có thể làm mất dữ liệu hoàn toàn. Xem cách sao lưu tại ổ đĩa ngoài tại https://docs.cloudron.io/backups/#storage-providers."
},
"backupEdit": {
"preserved": {
@@ -625,7 +641,8 @@
"description": "Vẫn giữ bản sao lưu mặc kệ chính sách lưu giữ được định thế nào"
},
"title": "Chỉnh sửa Bản sao lưu",
"label": "Nhãn"
"label": "Nhãn",
"remotePath": "Đường dẫn"
}
},
"login": {
@@ -637,7 +654,8 @@
"errorIncorrectCredentials": "Không đúng tên đăng nhập hoặc mật khẩu",
"loginTo": "Đăng nhập vào",
"errorIncorrect2FAToken": "Mã bảo mật 2 Bước không đúng",
"errorInternal": "Lỗi nội bộ hệ thống, vui lòng thử lại sau"
"errorInternal": "Lỗi nội bộ hệ thống, vui lòng thử lại sau",
"loginWith": "Đăng nhập bằng Cloudron"
},
"setupAccount": {
"username": "Tên đăng nhập",
@@ -670,7 +688,7 @@
"enableAction": "Bật",
"setupDnsInfo": "Sử dụng lựa chọn này để cài đặt những bản ghi có liên quan đến email. Để trống lựa chọn này sẽ hữu ích cho việc tạo ra các hộp thư và <a href=\"{{ importEmailDocsLink }}\">nhập dữ liệu các mail đã có sẵn</a> trước khi đưa vào sử dụng.",
"setupDnsCheckbox": "Cài đặt các bản ghi DNS ngay",
"cloudflareInfo": "Tên miền <code>{{ adminDomain }}</code> được quản lý bởi Cloudflare. Xin chắc rằng proxy qua Cloudflare đã được tắt cho <code>{{ mailFqdn }}</code> và được chỉnh về chế độ<code>DNS only</code>. Việc này là cần thiết vì Cloudflare không proxy được email.",
"cloudflareInfo": "Tên miền cho mail server <code>{{ adminDomain }}</code> được quản lý bởi Cloudflare. Hãy nhớ tắt proxy qua Cloudflare cho <code>{{ mailFqdn }}</code> và chỉnh về chế độ <code>DNS only</code>. Cần làm vậy vì Cloudflare không proxy được email.",
"noProviderInfo": "Chưa cài đặt nhà cung cấp DNS. Những bản ghi DNS trong phần Trạng thái cần được cài đặt thủ công.",
"description": "Lựa chọn này sẽ cấu hình Cloudron để nhận mail cho <b>{{ domain }}</b>. Xem hướng dẫn để mở <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">những cổng cần thiết</a> cho Email Cloudron.",
"title": "Bật chế độ email cho {{ domain }}?"
@@ -856,7 +874,7 @@
"network": {
"configureIp": {
"providerGenericDescription": "Địa chỉ IP công cộng của server này sẽ được tự động dò tìm ra.",
"title": "Cấu hình nhà cung cấp IP"
"title": "Cấu hình nhà cung cấp IPv4"
},
"dyndns": {
"description": "Bật lựa chọn này để đồng bộ các bản ghi DNS với một địa chỉ IP thường xuyên thay đổi. Việc này hữu ích khi Cloudron chạy trên hệ thống mạng với địa chỉ IP hay thay đổi như kết nối mạng ở nhà.",
@@ -879,8 +897,8 @@
"configure": "Cấu hình",
"interface": "Tên giao diện mạng",
"provider": "Nhà cung cấp",
"description": "Cloudron dùng địa chỉ IP này để cài đặt các bản ghi DNS.",
"title": "Địa chỉ IP",
"description": "Cloudron dùng địa chỉ IPv4 này để cài đặt các bản ghi A của DNS.",
"title": "IPv4",
"address": "Địa chỉ IP"
},
"title": "Mạng",
@@ -981,7 +999,8 @@
"info": "Các cài đặt này áp dụng cho tất cả các tên miền.",
"title": "Cài đặt",
"acl": "Danh sách quản lý truy cập mail",
"aclOverview": "{{ dnsblZonesCount }} vùng DNSBL"
"aclOverview": "{{ dnsblZonesCount }} vùng DNSBL",
"virtualAllMail": "Thư mực \"Tất cả Thư\""
},
"domains": {
"testEmailTooltip": "Gửi mail thử",
@@ -1018,6 +1037,10 @@
},
"action": {
"queue": "Cho vào hàng chờ gửi sau"
},
"changeVirtualAllMailDialog": {
"description": "Thư mục \"Tất cả Thư\" là một thư mục chứa tất cả thư trong hộp thư của bạn. Thư mục này hữu dụng cho những mail client mà không hỗ trợ chức năng tìm kiếm thư mục xoay vòng.",
"title": "Thư mực \"Tất cả Thư\""
}
},
"branding": {
@@ -1032,7 +1055,9 @@
},
"logo": "Logo",
"cloudronName": "Tên cho Cloudron",
"title": "Thương hiệu"
"title": "Thương hiệu",
"backgroundImage": "Hình nền trang đăng nhập",
"clearBackgroundImage": "Xoá"
},
"eventlog": {
"time": "Thời gian",
@@ -1064,7 +1089,19 @@
"uninstalledApp": "App đã xoá",
"diskSpeed": "Tốc độ: {{ speed }} MB/s"
},
"title": "Hệ thống"
"title": "Hệ thống",
"info": {
"activationTime": "Ngày tạo Cloudron",
"platformVersion": "Phiên bản hệ thống",
"title": "Thông tin",
"vendor": "Nhà cung cấp",
"product": "Sản phẩm",
"memory": "Bộ nhớ",
"uptime": "Thời gian online"
},
"graphs": {
"title": "Biểu đồ"
}
},
"support": {
"remoteSupport": {
@@ -1093,9 +1130,14 @@
"subscriptionRequired": "Phiếu hỗ trợ chỉ có trong những gói trả phí.",
"title": "Phiếu hỗ trợ",
"emailNotVerified": "Email tài khoản cloudron.io của bạn {{ email }} chưa được xác minh. Xin hãy xác minh mail trước để tạo phiếu hỗ trợ.",
"emailVerifyAction": "Xác minh ngay"
"emailVerifyAction": "Xác minh ngay",
"typeBilling": "Vấn đề Hóa đơn"
},
"title": "Hỗ trợ"
"title": "Hỗ trợ",
"help": {
"description": "Xin dùng những nguồn lực sau để được trợ giúp và hỗ trợ\n* [Diễn dàn Cloudron]({{ forumLink }}) - Vui lòng vào Mục Hỗ trợ & App cụ thể để đặt câu hỏi.\n* [HDSD & Kho kiến thức Cloudron]({{ docsLink }})\n* [Đóng gói App tùy chỉnh & API]({{ packagingLink }})\n",
"title": "Hỗ trợ"
}
},
"settings": {
"registryConfig": {
@@ -1148,17 +1190,15 @@
"changeScheduleAction": "Thay đổi lịch cập nhật",
"showLogsAction": "Hiển thị log",
"version": "Phiên bản hệ thống",
"currentSchedule": "Lịch cập nhật tự động hiện tại cho hệ thống và các app là",
"autoUpdateDisabled": "Cập nhật tự động cho hệ thống và các app <b>đã tắt</b>.",
"title": "Cập nhật"
},
"timezone": {
"description": "Múi giờ hiện tại là ở <b>{{ timeZone }}</b>.\nMúi giờ này được dùng cho việc lên lịch sao lưu và cập nhật hệ thống.",
"description": "Múi giờ hiện tại là ở <b>{{ timeZone }}</b>. Cài đặt này được dùng cho tác vụ sao lưu và cập nhật. Dấu thời gian hiện ở giao diện được hiển thị theo múi giờ của trình duyệt hiện dùng.",
"title": "Múi giờ"
},
"appstoreAccount": {
"subscriptionReactivateAction": "Kích hoạt lại gói đăng ký",
"subscriptionChangeAction": "Thay đổi gói đăng ký",
"subscriptionChangeAction": "Quản lý gói đăng ký",
"subscriptionSetupAction": "Nâng cấp Gói Cao cấp",
"subscriptionEndsAt": "Đã huỷ đăng ký và kết thúc vào",
"cloudronId": "Mã Cloudron ID",
@@ -1264,7 +1304,8 @@
"renameDialog": {
"rename": "Đổi tên",
"newName": "Tên mới",
"title": "Đổi tên {{ fileName }}"
"title": "Đổi tên {{ fileName }}",
"reallyOverwrite": "Trùng tên tập tin hiện có. Ghi đè lên tập tin cũ?"
},
"newFileDialog": {
"create": "Tạo",
@@ -1315,7 +1356,8 @@
"filePath": "Đường chỉ đến tập tin hay thư mục",
"title": "Tải xuống từ {{ name }}"
},
"title": "Màn hình terminal"
"title": "Màn hình terminal",
"uploadTo": "Tải lên {{ path }}"
},
"logs": {
"download": "Tải xuống tất cả log",
@@ -1384,7 +1426,13 @@
"cloudflareDefaultProxyStatus": "Bật tính năng proxy cho những bản ghi DNS mới",
"porkbunSecretapikey": "Mã bí mật API",
"bunnyAccessKey": "Mã truy cập Bunny",
"porkbunApikey": "Key API"
"porkbunApikey": "Key API",
"deSecToken": "Mã deSEC",
"dnsimpleAccessToken": "Mã truy cập",
"ovhAppSecret": "Mã bí mật App",
"ovhEndpoint": "Điểm Endpoint",
"ovhConsumerKey": "Mã Khách hàng",
"ovhAppKey": "Mã App"
},
"subscriptionRequired": {
"description": "Để thêm tên miền, hãy đăng ký gói trả phí.",
@@ -1432,13 +1480,14 @@
"firstTimeCollapseHeader": "Hướng dẫn cho lần cài đặt đầu tiên",
"openAction": "Mở {{ app }}",
"postInstallConfirmCheckbox": "Đã xem hướng dẫn",
"appDocsUrl": "Xin xem phần <a target=\"_blank\" href=\"{{ docsUrl }}\">{{ title }} hướng dẫn</a> để xem những thông tin hữu ích và chủ đề thường gặp của app này. Nếu bạn cần hỗ trợ thêm, hãy ghé xem trong<a target=\"_blank\" href=\"{{ forumUrl }}\"> diễn đàn {{ title }}</a>."
"appDocsUrl": "Xin xem phần <a target=\"_blank\" href=\"{{ docsUrl }}\">{{ title }} hướng dẫn</a> để xem những thông tin hữu ích và chủ đề thường gặp của app này. Nếu bạn cần hỗ trợ thêm, hãy ghé xem trong<a target=\"_blank\" href=\"{{ forumUrl }}\"> diễn đàn {{ title }}</a>.",
"checklist": "Danh sách kiểm tra cho Admin"
},
"uninstall": {
"uninstall": {
"uninstallAction": "Xoá",
"backupWarning": "Các bản sao lưu app sẽ không được xoá ngay mà sẽ dựa vào lịch trình sao lưu được định sẵn. Bạn có thể hồi sinh app từ một bản sao lưu hiện có bằng những <a target=\"_blank\" href=\"{{ importBackupDocsLink }}\">hướng dẫn sau đây</a>.",
"description": "Lựa chọn này sẽ gỡ cài đặt app ngay lập tức và xoá hết tất cả những dữ liệu liên quan. Trang web sẽ không còn truy cập được sau đó.",
"description": "Việc này sẽ xóa app ngay lập tức và tất cả dữ liệu. Trang sẽ không còn truy cập được sau khi xóa.",
"title": "Xoá"
},
"startStop": {
@@ -1491,23 +1540,24 @@
},
"updates": {
"auto": {
"enableAction": "Bật chế độ cập nhật tự động",
"disableAction": "Tắt chế độ cập nhật tự động",
"enableAction": "Bật cập nhật tự động",
"disableAction": "Tắt cập nhật tự động",
"disabled": "Cập nhật tự động hiện đang tắt.",
"enabled": "Cập nhật tự dộng đang được mở.",
"description": "Cloudron định kỳ kiểm tra Cửa hàng app cho các phiên bản cập nhật mới. Nếu bạn tắt chế độ cập nhật tự động, xin chắc rằng bạn cài đặt thủ công các cập nhật phiên bản mới.",
"description": "Cloudron định kỳ kiểm tra <a href=\"{{ appStoreLink }}\" target=\"_blank\">Cửa hàng App </a> cho phiên bản app mới.",
"title": "Cập nhật tự động"
},
"info": {
"updateAvailableAction": "Có phiên bản cập nhật mới",
"customAppUpdateInfo": "Phiên bản mới không có sẵn cho các app tuỳ chỉnh",
"customAppUpdateInfo": "Tự động cập nhật không có sẵn cho các app tùy chỉnh.",
"checkForUpdatesAction": "Kiểm tra cập nhật",
"lastUpdated": "Lần cuối cập nhật",
"packageVersion": "Phiên bản đóng gói",
"appId": "ID của app",
"description": "Tên app và phiên bản",
"title": "Thông tin app",
"repository": "Repo của bản đống gói"
"repository": "Repo của bản đống gói",
"installedAt": "Được cài lúc"
},
"noUpdates": "Không có phiên bản mới"
},
@@ -1586,14 +1636,14 @@
},
"resources": {
"cpu": {
"description": "Phần trăm thời gian CPU dành cho app khi hệ thống đang chịu tải nặng.",
"title": "Chia phần trong CPU",
"setAction": "Cài đặt"
"description": "Phần trăm CPU tối đa app có thể dùng",
"title": "Giới hạn CPU",
"setAction": "Nâng lên"
},
"memory": {
"resizeAction": "Chỉnh lại",
"error": "Hệ thống không chỉnh được giới hạn bộ nhớ này, hãy thử một giá trị thấp hơn.",
"description": "Cloudron dành 50% giá trị này cho RAM và 50% còn lại cho swap.",
"description": "Bộ nhớ tối đa app có thể dùng",
"title": "Giới hạn bộ nhớ"
}
},
@@ -1747,7 +1797,8 @@
},
"redis": {
"title": "Thiết lập Redis",
"enable": "Thiết lập app sử dụng Redis"
"enable": "Thiết lập app sử dụng Redis",
"disable": "Tắt Redis"
},
"addApplinkDialog": {
"title": "Thêm link app bên ngoài"
@@ -1761,6 +1812,12 @@
"upstreamUri": "Đường dẫn bên ngoài",
"label": "Nhãn",
"clearIconAction": "Xoá biểu tượng"
},
"infoTabTitle": "Thông tin",
"info": {
"notes": {
"title": "Ghi chú của Admin"
}
}
},
"volumes": {
@@ -1795,10 +1852,14 @@
},
"mountStatus": "Trạng thái mount",
"type": "Dạng",
"tooltipEdit": "Chỉnh sửa Volume",
"tooltipEdit": "Chỉnh Volume",
"localDirectory": "Thư mục trên máy",
"remountActionTooltip": "Mount Volume lại",
"mountType": "Dạng mount"
"mountType": "Dạng mount",
"editVolumeDialog": {
"title": "Chỉnh volume {{ name }}"
},
"editActionTooltip": "Chỉnh Volume"
},
"welcomeEmail": {
"inviteLinkAction": "Bắt đầu tạo tải khoản",
@@ -1822,7 +1883,8 @@
"es": "Tiếng Tây Ban Nha",
"ru": "Tiếng Nga",
"da": "Tiếng Đan Mạch",
"pt": "Tiếng Bồ Đào Nha"
"pt": "Tiếng Bồ Đào Nha",
"id": "Tiếng Indonesia"
},
"passwordResetEmail": {
"subject": "[<%= cloudron %>] Đặt lại mật khẩu",
@@ -1859,7 +1921,7 @@
"topic": "Chúng tôi nhận thấy có một đăng nhập mới vào tài khoản Cloudron của bạn.",
"salutation": "Xin chào <%= user %>,",
"subject": "[<%= cloudron %>] Có đăng nhập mới vào tài khoản của bạn",
"notice": "Chhungs tôi nhận thấy một đăng nhập trên tài khoản Cloudron của bạn từ một thiết bị mới.",
"notice": "Có một đăng nhập vào tài khoản Cloudron của bạn từ một thiết bị mới.",
"action": "Nếu người đó là bạn, bạn có thể thoải mái bỏ qua email này. Nếu đó không phải là bạn, bạn nên đổi mật khẩu của bạn ngay bây giờ."
},
"supportConfig": {

View File

@@ -798,8 +798,6 @@
},
"updates": {
"title": "更新",
"autoUpdateDisabled": "平台和应用的自动更新已 <b>停用</b>。",
"currentSchedule": "当前平台和应用的自动更新计划是",
"version": "平台版本",
"showLogsAction": "显示日志",
"changeScheduleAction": "修改计划",

View File

@@ -36,7 +36,7 @@
<div class="modal-body">
<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>
@@ -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>
@@ -750,14 +750,14 @@
<div ng-repeat="(key, item) in app.checklist">
<div class="checklist-item" ng-hide="item.acknowledged">
{{ item.message }}
<span ng-bind-html="item.message | markdown2html"></span>
<button class="btn btn-xs btn-default" style="margin-left: 10px;" ng-click="info.checklistAck(item, key)">Done</button>
</div>
</div>
<div ng-repeat="(key, item) in app.checklist" ng-show="info.showDoneChecklist">
<div class="checklist-item checklist-item-acknowledged" ng-show="item.acknowledged">
{{ item.message }}<br/>
<span ng-bind-html="item.message | markdown2html"></span>
<span class="text-muted text-small">{{ item.changedBy }} {{ item.changedAt | prettyDate }}</span>
</div>
</div>
@@ -775,38 +775,38 @@
</div>
<div class="row">
<div class="col-xs-6">
<div class="col-xs-4">
<span class="text-muted">{{ 'app.updates.info.appId' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<div class="col-xs-8 text-right">
<span>{{ app.id }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<div class="col-xs-4">
<span class="text-muted">{{ 'app.updates.info.packageVersion' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<div class="col-xs-8 text-right">
<span ng-show="app.appStoreId"><a ng-href="/#/appstore/{{app.manifest.id}}?version={{app.manifest.version}}">{{ app.manifest.id }}@{{ app.manifest.version }}</a></span>
<span ng-show="!app.appStoreId">{{ app.manifest.version }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<div class="col-xs-4">
<span class="text-muted">{{ 'app.updates.info.installedAt' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<div class="col-xs-8 text-right">
<span>{{ app.creationTime | prettyDate }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<div class="col-xs-4">
<span class="text-muted">{{ 'app.updates.info.lastUpdated' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<div class="col-xs-8 text-right">
<span>{{ app.updateTime | prettyDate }}</span>
</div>
</div>
@@ -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>

View File

@@ -629,9 +629,12 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
var nearest256m = Math.ceil(Math.max(result.memory, $scope.resources.currentMemoryLimit) / (256*1024*1024)) * 256 * 1024 * 1024;
var startTick = app.manifest.memoryLimit || (256 * 1024 * 1024);
for (var i = startTick; i <= nearest256m; i *= 2) {
// code below ensure we atleast have 2 ticks to keep the slider usable
$scope.resources.memoryTicks.push(startTick); // start tick
for (var i = startTick * 2; i < nearest256m; i *= 2) {
$scope.resources.memoryTicks.push(i);
}
$scope.resources.memoryTicks.push(nearest256m); // end tick
});
// for firefox widget update

View File

@@ -19,9 +19,13 @@
-->
<div ng-bind-html="appPostInstallConfirm.message | markdown2html"></div>
<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 style="margin-top: 10px; margin-bottom: 5px;" ng-show="pendingChecklistItems(appPostInstallConfirm.app)">
<label class="control-label">{{ 'app.appInfo.checklist' | tr }}</label>
</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>

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>

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;

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>
@@ -542,20 +542,20 @@
</div>
<div class="card" style="margin-bottom: 15px;">
<p>{{ 'backups.schedule.description' | tr }}</p>
<p ng-bind-html=" 'backups.schedule.description' | tr "></p>
<div class="row">
<div class="col-xs-6">
<div class="col-xs-4">
<span class="text-muted">{{ 'backups.schedule.schedule' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<div class="col-xs-8 text-right" style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">
<span>{{ prettyBackupSchedule(backupPolicy.currentPolicy.schedule) }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<div class="col-xs-4">
<span class="text-muted">{{ 'backups.schedule.retentionPolicy' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<div class="col-xs-8 text-right">
<span>{{ prettyBackupRetention(backupPolicy.currentPolicy.retention) }}</span>
</div>
</div>

View File

@@ -2,6 +2,7 @@
/* global $, angular, TASK_TYPES, SECRET_PLACEHOLDER, STORAGE_PROVIDERS, BACKUP_FORMATS, APP_TYPES */
/* global REGIONS_S3, REGIONS_WASABI, REGIONS_DIGITALOCEAN, REGIONS_EXOSCALE, REGIONS_SCALEWAY, REGIONS_LINODE, REGIONS_OVH, REGIONS_IONOS, REGIONS_UPCLOUD, REGIONS_VULTR , REGIONS_CONTABO */
/* global document, window, FileReader */
angular.module('Application').controller('BackupsController', ['$scope', '$location', '$rootScope', '$timeout', 'Client', function ($scope, $location, $rootScope, $timeout, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
@@ -15,6 +16,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.memory = null; // { memory, swap }
$scope.mountStatus = null; // { state, message }
$scope.manualBackupApps = [];
$scope.currentTimeZone = '';
$scope.backupConfig = {};
$scope.backups = [];
@@ -785,7 +787,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.backups.forEach(function (backup) {
backup.contents = []; // { id, label, fqdn }
backup.dependsOn.forEach(function (appBackupId) {
let match = appBackupId.match(/app_(.*?)_.*/); // *? means non-greedy
const match = appBackupId.match(/app_(.*?)_.*/); // *? means non-greedy
if (!match) return; // for example, 'mail'
const app = appsById[match[1]];
if (app) {

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>

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: '',

View File

@@ -1,4 +1,4 @@
<div class="content">
<div class="content content-large">
<div class="text-left">
<h1>{{ 'notifications.title' | tr }}
@@ -15,7 +15,7 @@
<h2><i class="fa fa-circle-notch fa-spin"></i></h2>
</div>
<div class="card" ng-hide="busy || notifications.length">
<div class="card card-large" ng-hide="busy || notifications.length">
<div class="row">
<div class="col-xs-12">
<h3 class="text-center" style="margin: 20px;">{{ 'notifications.nonePending' | tr }}</h3>
@@ -23,7 +23,7 @@
</div>
</div>
<div class="card notification-item" ng-repeat="notification in notifications" ng-class="{'notification-unread': !notification.acknowledged }" ng-click="notification.isCollapsed = !notification.isCollapsed" ng-style="{ borderLeftColor: (notification | notificationTypeToColor) }">
<div class="card card-large notification-item" ng-repeat="notification in notifications" ng-class="{'notification-unread': !notification.acknowledged }" ng-click="notification.isCollapsed = !notification.isCollapsed" ng-style="{ borderLeftColor: (notification | notificationTypeToColor) }">
<div class="row">
<div class="col-xs-12" ng-class="{ 'notification-details': notification.detailsShown }">
<span class="notification-title">{{ notification.title }}</span> <small class="text-muted" uib-tooltip="{{ notification.creationTime | prettyLongDate }}">{{ notification.creationTime | prettyDate }}</small>

View File

@@ -272,22 +272,31 @@
<div class="card" style="margin-bottom: 15px;">
<div class="row">
<div class="col-md-12">
<span ng-show="updateSchedule.currentPattern !== 'never'">{{ 'settings.updates.currentSchedule' | tr }} <b>{{ prettyAutoUpdateSchedule(updateSchedule.currentPattern) }}</b></span>
<span ng-show="updateSchedule.currentPattern === 'never'" ng-bind-html=" 'settings.updates.autoUpdateDisabled' | tr "></span>
<span ng-bind-html=" 'settings.updates.description' | tr "></span>
</div>
</div>
<br/>
<div class="row">
<div class="col-xs-6">
<div class="col-xs-4">
<span class="text-muted">{{ 'settings.updates.version' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<div class="col-xs-8 text-right">
v{{ config.version }} ({{ config.ubuntuVersion }})
</div>
</div>
<div class="row">
<div class="col-xs-4">
<span class="text-muted">{{ 'settings.updates.schedule' | tr }}</span>
</div>
<div class="col-xs-8 text-right" style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">
<span ng-show="updateSchedule.currentPattern !== 'never'">{{ prettyAutoUpdateSchedule(updateSchedule.currentPattern) }}</span>
<span ng-show="updateSchedule.currentPattern === 'never'">{{ 'settings.updates.disabled' | tr }}</span>
</div>
</div>
<div class="row">
<br/>
<div ng-if="update.busy" class="col-md-12" style="margin-bottom: 10px;">

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>

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
frontend/eslint.config.js Normal file
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"
}
}
];

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{
"name": "my-vue-app",
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
@@ -9,24 +9,26 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource/noto-sans": "^5.0.22",
"@fontsource/noto-sans": "^5.1.0",
"@xterm/addon-attach": "^0.11.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"anser": "^2.1.1",
"combokeys": "^3.0.1",
"filesize": "^10.1.4",
"marked": "^13.0.2",
"filesize": "^10.1.6",
"marked": "^14.1.2",
"moment": "^2.30.1",
"pankow": "^1.6.8",
"pankow-viewers": "^1.0.4",
"superagent": "^9.0.2",
"vue": "^3.4.33",
"vue-i18n": "^9.13.1",
"vue-router": "^4.4.0"
"pankow": "^2.2.1",
"pankow-viewers": "^1.0.7",
"vue": "^3.5.6",
"vue-i18n": "^10.0.1",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.5",
"vite": "^5.3.4"
"@eslint/js": "^9.10.0",
"@vitejs/plugin-vue": "^5.1.3",
"eslint": "^9.10.0",
"eslint-plugin-vue": "^9.28.0",
"vite": "^5.4.5"
}
}

View File

@@ -11,9 +11,9 @@
<Button icon="fa-solid fa-eraser" @click="onClear()" style="margin-right: 5px">{{ $t('logs.clear') }}</Button>
<Button :href="downloadUrl" target="_blank" icon="fa-solid fa-download">{{ $t('logs.download') }}</Button>
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" v-show="showRestart" secondary :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp"/>
<Button :href="'/frontend/terminal.html?id=' + id" target="_blank" v-show="showTerminal" secondary icon="fa-solid fa-terminal" :title="$t('terminal.title')" />
<Button :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank" v-show="showFilemanager" secondary icon="fa-solid fa-folder" :title="$t('filemanager.title')" />
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" v-show="showRestart" secondary tool :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp"/>
<Button :href="'/frontend/terminal.html?id=' + id" target="_blank" v-show="showTerminal" secondary tool icon="fa-solid fa-terminal" :title="$t('terminal.title')" />
<Button :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank" v-show="showFilemanager" secondary tool icon="fa-solid fa-folder" :title="$t('filemanager.title')" />
</template>
</TopBar>
</template>
@@ -31,7 +31,7 @@ import { Button, TopBar, MainLayout } from 'pankow';
import LogsModel from '../models/LogsModel.js';
import AppModel from '../models/AppModel.js';
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.location.origin ;
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.location.origin;
export default {
name: 'LogsViewer',
@@ -43,7 +43,6 @@ export default {
data() {
return {
accessToken: localStorage.token,
apiOrigin: API_ORIGIN || '',
logsModel: null,
appModel: null,
busyRestart: false,
@@ -59,7 +58,7 @@ export default {
},
methods: {
onClear() {
this.logLines = [];
while (this.$refs.linesContainer.firstChild) this.$refs.linesContainer.removeChild(this.$refs.linesContainer.firstChild);
},
onDownload() {
this.logsModel.download();
@@ -113,10 +112,10 @@ export default {
return;
}
this.logsModel = LogsModel.create(this.apiOrigin, this.accessToken, this.type, this.id);
this.logsModel = LogsModel.create(API_ORIGIN, this.accessToken, this.type, this.id);
if (this.type === 'app') {
this.appModel = AppModel.create(this.apiOrigin, this.accessToken, this.id);
this.appModel = AppModel.create(API_ORIGIN, this.accessToken, this.id);
try {
const app = await this.appModel.get();
@@ -145,7 +144,6 @@ export default {
if (lines < maxLines) ++lines;
else this.$refs.linesContainer.removeChild(this.$refs.linesContainer.firstChild);
// this.logLines.push({ time, html});
const logLine = document.createElement('div');
logLine.className = 'log-line';
logLine.innerHTML = `<span class="time">${line.time || '[no timestamp]&nbsp;' }</span> <span>${line.html}</span>`;
@@ -161,7 +159,7 @@ export default {
this.logsModel.stream((time, html) => {
newLogLines.push({ time, html });
}, function (error) {
console.error('Failed to start log stream:', error);
newLogLines.push({ time: error.time, html: error.html });
})
}
};

View File

@@ -27,9 +27,9 @@
<Button style="margin-left: 20px;" :disabled="!connected" @click="onUpload" icon="fa-solid fa-upload">{{ $t('terminal.uploadTo', { path: '/app/data/' }) }}</Button>
<Button :disabled="!connected" @click="onDownload" icon="fa-solid fa-download">{{ $t('terminal.downloadAction') }}</Button>
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" secondary :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp"/>
<Button v-show="showFilemanager" :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank" secondary icon="fa-solid fa-folder" :title="$t('filemanager.title')" />
<Button :href="'/frontend/logs.html?appId=' + id" target="_blank" secondary icon="fa-solid fa-align-left" :title="$t('logs.title')" />
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" secondary tool :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp"/>
<Button v-show="showFilemanager" :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank" secondary tool icon="fa-solid fa-folder" :title="$t('filemanager.title')" />
<Button :href="'/frontend/logs.html?appId=' + id" target="_blank" secondary tool icon="fa-solid fa-align-left" :title="$t('logs.title')" />
</template>
</TopBar>
</template>
@@ -48,9 +48,7 @@
<script>
import superagent from 'superagent';
import { Button, Dialog, FileUploader, InputDialog, MainLayout, TopBar } from 'pankow';
import { fetcher, Button, Dialog, FileUploader, InputDialog, MainLayout, TopBar } from 'pankow';
import '@xterm/xterm/css/xterm.css';
import { Terminal } from '@xterm/xterm';
@@ -75,7 +73,6 @@ export default {
data() {
return {
accessToken: localStorage.token,
apiOrigin: API_ORIGIN || '',
appModel: null,
directoryModel: null,
fatalError: false,
@@ -117,7 +114,7 @@ export default {
if (!downloadFileName) return;
try {
const result = await superagent.head(`${this.apiOrigin}/api/v1/apps/${this.id}/download`).query({
await fetcher.head(`${API_ORIGIN}/api/v1/apps/${this.id}/download`, {
file: downloadFileName,
access_token: this.accessToken
});
@@ -128,7 +125,7 @@ export default {
return;
}
this.downloadFileDownloadUrl = `${this.apiOrigin}/api/v1/apps/${this.id}/download?file=${encodeURIComponent(downloadFileName)}&access_token=${this.accessToken}`;
this.downloadFileDownloadUrl = `${API_ORIGIN}/api/v1/apps/${this.id}/download?file=${encodeURIComponent(downloadFileName)}&access_token=${this.accessToken}`;
// we have to click the link to make the browser do the download
// don't know how to prevent the browsers
@@ -199,7 +196,7 @@ export default {
let execId;
try {
const result = await superagent.post(`${this.apiOrigin}/api/v1/apps/${this.id}/exec`).query({ access_token: this.accessToken }).send({ cmd: [ '/bin/bash' ], tty: true, lang: 'C.UTF-8' });
const result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${this.id}/exec`, { cmd: [ '/bin/bash' ], tty: true, lang: 'C.UTF-8' }, { access_token: this.accessToken });
execId = result.body.id;
} catch (error) {
console.error('Cannot create socket.', error);
@@ -222,7 +219,7 @@ export default {
});
// websocket cannot use relative urls
const url = `${this.apiOrigin.replace('https', 'wss')}/api/v1/apps/${this.id}/exec/${execId}/startws?tty=true&rows=${this.terminal.rows}&columns=${this.terminal.cols}&access_token=${this.accessToken}`;
const url = `${API_ORIGIN.replace('https', 'wss')}/api/v1/apps/${this.id}/exec/${execId}/startws?tty=true&rows=${this.terminal.rows}&columns=${this.terminal.cols}&access_token=${this.accessToken}`;
this.socket = new WebSocket(url);
this.terminal.loadAddon(new AttachAddon(this.socket));
@@ -263,8 +260,8 @@ export default {
this.id = id;
this.name = id;
this.appModel = create(this.apiOrigin, this.accessToken, this.id);
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, `apps/${id}`);
this.appModel = create(API_ORIGIN, this.accessToken, this.id);
this.directoryModel = createDirectoryModel(API_ORIGIN, this.accessToken, `apps/${id}`);
try {
const app = await this.appModel.get();

View File

@@ -1,14 +1,12 @@
import { createApp } from 'vue';
import { createI18n } from 'vue-i18n';
import './style.css';
import '@fontsource/noto-sans';
import superagent from 'superagent';
import { createRouter, createWebHashHistory } from 'vue-router';
import i18n from './i18n.js';
import FileManager from './FileManager.vue';
import Home from './views/Home.vue';
import Viewer from './views/Viewer.vue';
@@ -25,53 +23,10 @@ const router = createRouter({
routes,
});
const translations = {};
const i18n = createI18n({
locale: 'en', // set locale
fallbackLocale: 'en', // set fallback locale
messages: translations,
// will replace our double {{}} to vue-i18n single brackets
messageResolver: (keys, key) => {
let message = key.split('.').reduce((o, k) => o && o[k] || null, keys);
// fallback tr key
if (message === null) message = key;
return message.replaceAll('{{', '{').replaceAll('}}', '}');
}
});
// 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;
}
}
(async function init() {
const app = createApp(FileManager);
app.use(i18n);
app.use(await i18n());
app.use(router);
app.mount('#app');

51
frontend/src/i18n.js Normal file
View File

@@ -0,0 +1,51 @@
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.origin;
import { createI18n } from 'vue-i18n';
import { fetcher } from 'pankow';
const translations = {};
const i18n = createI18n({
locale: 'en', // set locale
fallbackLocale: 'en', // set fallback locale
messages: translations,
// will replace our double {{}} to vue-i18n single brackets
messageResolver: function (keys, key) {
const message = key.split('.').reduce((o, k) => o && o[k] || null, keys);
// if not found return null to fallback to resolving for english
if (message === null) return null;
return message.replaceAll('{{', '{').replaceAll('}}', '}');
}
});
// https://vue-i18n.intlify.dev/guide/advanced/lazy.html
async function loadLanguage(lang) {
try {
const result = await fetcher.get(`${API_ORIGIN}/translation/${lang}.json`);
translations[lang] = result.body;
} catch (e) {
console.error(`Failed to load language file for ${lang}`, e);
}
}
async function main() {
// load at least fallback english
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;
}
}
return i18n;
}
export default main;

View File

@@ -1,61 +1,16 @@
import { createApp } from 'vue';
import { createI18n } from 'vue-i18n';
import './style.css';
import '@fontsource/noto-sans';
import superagent from 'superagent';
import i18n from './i18n.js';
import LogsViewer from './components/LogsViewer.vue';
const translations = {};
const i18n = createI18n({
locale: 'en', // set locale
fallbackLocale: 'en', // set fallback locale
messages: translations,
// will replace our double {{}} to vue-i18n single brackets
messageResolver: (keys, key) => {
let message = key.split('.').reduce((o, k) => o && o[k] || null, keys);
// fallback tr key
if (message === null) message = key;
return message.replaceAll('{{', '{').replaceAll('}}', '}');
}
});
// 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;
}
}
(async function init() {
const app = createApp(LogsViewer);
app.use(i18n);
app.use(await i18n());
app.mount('#app');
})();

View File

@@ -1,6 +1,6 @@
import superagent from 'superagent';
import { ISTATES } from '../constants.js';
import { fetcher } from 'pankow';
import { sleep } from 'pankow/utils';
export function create(origin, accessToken, id) {
@@ -9,13 +9,13 @@ export function create(origin, accessToken, id) {
async get() {
let error, result;
try {
result = await superagent.get(`${origin}/api/v1/apps/${id}`).query({ access_token: accessToken });
result = await fetcher.get(`${origin}/api/v1/apps/${id}`, { access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 200) {
console.error(`Invalid app ${id}`, error || result.statusCode);
if (error || result.status !== 200) {
console.error(`Invalid app ${id}`, error || result.status);
this.fatalError = `Invalid app ${id}`;
return;
}
@@ -25,25 +25,25 @@ export function create(origin, accessToken, id) {
async restart() {
let error, result;
try {
result = await superagent.post(`${origin}/api/v1/apps/${id}/restart`).query({ access_token: accessToken });
result = await fetcher.post(`${origin}/api/v1/apps/${id}/restart`, null, { access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 202) {
console.error(`Failed to restart app ${this.id}`, error || result.statusCode);
if (error || result.status !== 202) {
console.error(`Failed to restart app ${this.id}`, error || result.status);
return;
}
while(true) {
let result;
try {
result = await superagent.get(`${origin}/api/v1/apps/${id}`).query({ access_token: accessToken });
result = await fetcher.get(`${origin}/api/v1/apps/${id}`, { access_token: accessToken });
} catch (e) {
console.error(e);
}
if (result && result.statusCode === 200 && result.body.installationState === ISTATES.INSTALLED) break;
if (result && result.status === 200 && result.body.installationState === ISTATES.INSTALLED) break;
await sleep(2000);
}

View File

@@ -1,5 +1,5 @@
import superagent from 'superagent';
import { fetcher } from 'pankow';
import { sanitize } from 'pankow/utils';
const BASE_URL = import.meta.env.BASE_URL || '/';
@@ -35,15 +35,15 @@ export function createDirectoryModel(origin, accessToken, api) {
async listFiles(path) {
let error, result;
try {
result = await superagent.get(`${origin}/api/v1/${api}/files/${path}`).query({ access_token: accessToken });
result = await fetcher.get(`${origin}/api/v1/${api}/files/${path}`, { access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 200) {
if (error || result.status !== 200) {
if (error.status === 404) return [];
console.error('Failed to list files', error || result.statusCode);
console.error('Failed to list files', error || result.status);
return [];
}
@@ -59,6 +59,8 @@ export function createDirectoryModel(origin, accessToken, api) {
// if we have an image, attach previewUrl
if (item.mimeType.indexOf('image/') === 0) {
item.previewUrl = `${origin}/api/v1/${api}/files/${encodeURIComponent(path + '/' + item.fileName)}?access_token=${accessToken}`;
} else {
item.previewUrl = '';
}
item.owner = item.uid;
@@ -67,13 +69,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,7 +95,66 @@ 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.send(file);
});
// attach for upstream xhr.abort()
req.xhr = xhr;
return req;
},
async newFile(filePath) {
await this.save(filePath, '');
},
async newFolder(folderPath) {
await fetcher.post(`${origin}/api/v1/${api}/files/${folderPath}`, { access_token: accessToken, directory: true });
},
async remove(filePath) {
await fetcher.del(`${origin}/api/v1/${api}/files/${filePath}`, { access_token: accessToken });
},
async rename(fromFilePath, toFilePath, overwrite = false) {
await fetcher.put(`${origin}/api/v1/${api}/files/${fromFilePath}`, { action: 'rename', newFilePath: sanitize(toFilePath), overwrite }, { access_token: accessToken });
},
async copy(fromFilePath, toFilePath) {
await fetcher.put(`${origin}/api/v1/${api}/files/${fromFilePath}`, { action: 'copy', newFilePath: sanitize(toFilePath) }, { access_token: accessToken });
},
async chown(filePath, uid) {
await fetcher.put(`${origin}/api/v1/${api}/files/${filePath}`, { action: 'chown', uid: uid, recursive: true }, { access_token: accessToken });
},
async extract(path) {
await fetcher.put(`${origin}/api/v1/${api}/files/${path}`, { action: 'extract' }, { 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');
const req = new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response);
} else {
reject({
status: xhr.status,
statusText: xhr.statusText
});
}
});
xhr.addEventListener('error', () => {
reject({
status: xhr.status,
statusText: xhr.statusText
});
});
xhr.open('POST', `${origin}/api/v1/${api}/files/${filePath}?access_token=${accessToken}&overwrite=true`);
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
xhr.setRequestHeader('Content-Length', file.size);
@@ -102,49 +162,7 @@ export function createDirectoryModel(origin, accessToken, api) {
xhr.send(file);
});
const res = await req;
},
async newFile(folderPath, fileName) {
await superagent.post(`${origin}/api/v1/${api}/files/${folderPath}`)
.query({ access_token: accessToken })
.attach('file', new File([], fileName));
},
async newFolder(folderPath) {
await superagent.post(`${origin}/api/v1/${api}/files/${folderPath}?access_token=${accessToken}&directory=true`);
},
async remove(filePath) {
await superagent.del(`${origin}/api/v1/${api}/files/${filePath}`)
.query({ access_token: accessToken });
},
async rename(fromFilePath, toFilePath, overwrite = false) {
await superagent.put(`${origin}/api/v1/${api}/files/${fromFilePath}`)
.send({ action: 'rename', newFilePath: sanitize(toFilePath), overwrite })
.query({ access_token: accessToken });
},
async copy(fromFilePath, toFilePath) {
await superagent.put(`${origin}/api/v1/${api}/files/${fromFilePath}`)
.send({ action: 'copy', newFilePath: sanitize(toFilePath) })
.query({ access_token: accessToken });
},
async chown(filePath, uid) {
await superagent.put(`${origin}/api/v1/${api}/files/${filePath}`)
.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}`)
.query({ access_token: accessToken })
.attach('file', file)
.field('overwrite', 'true');
await req;
},
async getFile(path) {
let result;
@@ -160,7 +178,7 @@ export function createDirectoryModel(origin, accessToken, api) {
},
async paste(targetDir, action, files) {
// this will not overwrite but tries to find a new unique name to past to
for (let f in files) {
for (const f in files) {
let done = false;
let targetPath = targetDir + '/' + files[f].name;
while (!done) {

View File

@@ -1,9 +1,6 @@
import moment from 'moment';
import superagent from 'superagent';
import { ansiToHtml } from 'anser';
import { ISTATES } from '../constants.js';
import { sleep } from 'pankow/utils';
// https://github.com/janl/mustache.js/blob/master/mustache.js#L60
const entityMap = {
@@ -58,7 +55,17 @@ export function create(origin, accessToken, type, id) {
name: 'LogsModel',
stream(lineHandler, errorHandler) {
eventSource = new EventSource(`${origin}${streamApi}?lines=${INITIAL_STREAM_LINES}&access_token=${accessToken}`);
eventSource.onerror = errorHandler;
eventSource._lastMessage = null;
eventSource.onerror = function ( /* uselessError */) {
if (eventSource.readyState === EventSource.CLOSED) {
// eventSource does not give us the HTTP error code. We have to resort to message count check and guess the reason
const msg = eventSource._lastMessage === null ? `Logs unavailable. Maybe the logs were logrotated.` : `Connection closed.`;
const e = new Error(msg);
e.time = moment().format('MMM DD HH:mm:ss');
e.html = ansiToHtml(e.message);
errorHandler(e);
}
};
// eventSource.onopen = function () { console.log('stream is open'); };
eventSource.onmessage = function (message) {
var data;
@@ -72,56 +79,12 @@ export function create(origin, accessToken, type, id) {
const time = data.realtimeTimestamp ? moment(data.realtimeTimestamp/1000).format('MMM DD HH:mm:ss') : '';
const html = ansiToHtml(escapeHtml(typeof data.message === 'string' ? data.message : ab2str(data.message)));
eventSource._lastMessage = { time, html };
lineHandler(time, html);
};
},
getDownloadUrl() {
return `${origin}${downloadApi}?access_token=${accessToken}&format=short&lines=-1`;
},
// TODO maybe move this into AppsModel.js
async getApp() {
let error, result;
try {
result = await superagent.get(`${origin}/api/v1/apps/${id}`).query({ access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 200) {
console.error(`Invalid app ${id}`, error || result.statusCode);
this.fatalError = `Invalid app ${id}`;
return;
}
return result.body;
},
async restartApp() {
if (type !== 'app') return;
let error, result;
try {
result = await superagent.post(`${origin}/api/v1/apps/${id}/restart`).query({ access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 202) {
console.error(`Failed to restart app ${this.id}`, error || result.statusCode);
return;
}
while(true) {
let result;
try {
result = await superagent.get(`${origin}/api/v1/apps/${id}`).query({ access_token: accessToken });
} catch (e) {
console.error(e);
}
if (result && result.statusCode === 200 && result.body.installationState === ISTATES.INSTALLED) break;
await sleep(2000);
}
}
};
}

View File

@@ -9,19 +9,6 @@ html, body {
color: var(--pankow-text-color);
}
h1 {
font-weight: 300 !important;
}
a {
color: #2196f3;
text-decoration: none;
}
a:hover, a:focus {
color: #0a6ebd;
}
.shadow {
box-shadow: 0 2px 5px rgba(0,0,0,.1);
}
@@ -29,9 +16,3 @@ a:hover, a:focus {
#app {
height: 100%;
}
@media (prefers-color-scheme: dark) {
body {
background-color: black;
}
}

View File

@@ -1,61 +1,16 @@
import { createApp } from 'vue';
import { createI18n } from 'vue-i18n';
import './style.css';
import '@fontsource/noto-sans';
import superagent from 'superagent';
import i18n from './i18n.js';
import Terminal from './components/Terminal.vue';
const translations = {};
const i18n = createI18n({
locale: 'en', // set locale
fallbackLocale: 'en', // set fallback locale
messages: translations,
// will replace our double {{}} to vue-i18n single brackets
messageResolver: (keys, key) => {
let message = key.split('.').reduce((o, k) => o && o[k] || null, keys);
// fallback tr key
if (message === null) message = key;
return message.replaceAll('{{', '{').replaceAll('}}', '}');
}
});
// 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;
}
}
(async function init() {
const app = createApp(Terminal);
app.use(i18n);
app.use(await i18n());
app.mount('#app');
})();

View File

@@ -28,7 +28,7 @@
<template #header>
<TopBar class="navbar">
<template #left>
<Button icon="fa-solid fa-arrow-rotate-right" :loading="busyRefresh" @click="onRefresh()" secondary plain/>
<Button icon="fa-solid fa-arrow-rotate-right" :loading="busyRefresh" @click="onRefresh()" secondary plain tool/>
<Breadcrumb :home="breadcrumbHomeItem" :items="breadcrumbItems" :activate-handler="onActivateBreadcrumb"/>
</template>
<template #right>
@@ -37,9 +37,9 @@
<Button icon="fa-solid fa-upload" @click="onUploadMenu">{{ $t('filemanager.toolbar.upload') }}</Button>
<Menu ref="uploadMenu" :model="uploadMenuModel"/>
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" secondary :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp" v-show="resourceType === 'app'"/>
<Button :href="'/frontend/terminal.html?id=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary icon="fa-solid fa-terminal" :title="$t('terminal.title')" />
<Button :href="'/frontend/logs.html?appId=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary icon="fa-solid fa-align-left" :title="$t('logs.title')" />
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" secondary tool :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp" v-show="resourceType === 'app'"/>
<Button :href="'/frontend/terminal.html?id=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary tool icon="fa-solid fa-terminal" :title="$t('terminal.title')" />
<Button :href="'/frontend/logs.html?appId=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary tool icon="fa-solid fa-align-left" :title="$t('logs.title')" />
</template>
</TopBar>
</template>
@@ -57,8 +57,6 @@
:delete-handler="deleteHandler"
:rename-handler="renameHandler"
:change-owner-handler="changeOwnerHandler"
:copy-handler="copyHandler"
:cut-handler="cutHandler"
:paste-handler="pasteHandler"
:download-handler="downloadHandler"
:extract-handler="extractHandler"
@@ -68,7 +66,6 @@
:upload-folder-handler="onUploadFolder"
:drop-handler="onDrop"
:items="items"
:clipboard="clipboard"
:owners-model="ownersModel"
:fallback-icon="fallbackIcon"
:tr="$t"
@@ -100,10 +97,9 @@
<script>
import superagent from 'superagent';
import { marked } from 'marked';
import { Dialog, DirectoryView, TopBar, Breadcrumb, BottomBar, Button, InputDialog, MainLayout, Menu, FileUploader, Spinner } from 'pankow';
import { fetcher, Dialog, DirectoryView, TopBar, Breadcrumb, BottomBar, Button, InputDialog, MainLayout, Menu, FileUploader, Spinner } from 'pankow';
import Icon from 'pankow/components/Icon.vue';
import { sanitize, sleep } from 'pankow/utils';
@@ -112,7 +108,7 @@ import { ISTATES } from '../constants.js';
import PreviewPanel from '../components/PreviewPanel.vue';
import { createDirectoryModel } from '../models/DirectoryModel.js';
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.origin;
const BASE_URL = import.meta.env.BASE_URL || '/';
const beforeUnloadListener = (event) => {
@@ -150,12 +146,7 @@ export default {
activeDirectoryItem: {},
items: [],
selectedItems: [],
clipboard: {
action: '', // copy or cut
files: []
},
accessToken: localStorage.token,
apiOrigin: API_ORIGIN || '',
title: 'Cloudron',
appLink: '',
resourceType: '',
@@ -213,6 +204,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 fetcher.get(`${API_ORIGIN}/api/v1/apps/${resourceId}`, { access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.status !== 200) {
console.error(`Invalid resource ${type} ${resourceId}`, error || result.status);
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 fetcher.get(`${API_ORIGIN}/api/v1/volumes/${resourceId}`, { access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.status !== 200) {
console.error(`Invalid resource ${type} ${resourceId}`, error || result.status);
return this.onFatalError(`Invalid resource ${type} ${resourceId}`);
}
this.title = result.body.name;
} else {
return this.onFatalError(`Unsupported type ${type}`);
}
try {
const result = await fetcher.get(`${API_ORIGIN}/api/v1/dashboard/config`, { 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(API_ORIGIN, 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(API_ORIGIN, 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 +286,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) {
@@ -244,7 +305,7 @@ export default {
if (!newFileName) return;
await this.directoryModel.newFile(this.directoryModel.buildFilePath(this.cwd, newFileName), newFileName);
await this.directoryModel.newFile(this.directoryModel.buildFilePath(this.cwd, newFileName));
await this.loadCwd();
},
async onNewFolder() {
@@ -292,36 +353,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 +421,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'),
@@ -412,31 +479,15 @@ export default {
await this.loadCwd();
},
async copyHandler(files) {
if (!files) return;
this.clipboard = {
action: 'copy',
files
};
},
async cutHandler(files) {
if (!files) return;
this.clipboard = {
action: 'cut',
files
};
},
async pasteHandler(target) {
if (!this.clipboard.files || !this.clipboard.files.length) return;
async pasteHandler(action, files, target) {
if (!files || !files.length) return;
const targetPath = (target && target.isDirectory) ? sanitize(this.cwd + '/' + target.fileName) : this.cwd;
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.$refs.pasteInProgressDialog.open();
await this.directoryModel.paste(targetPath, this.clipboard.action, this.clipboard.files);
await this.directoryModel.paste(targetPath, action, files);
this.clipboard = {};
await this.loadCwd();
@@ -458,7 +509,7 @@ export default {
try {
await this.uploadRequest;
} catch (e) {
console.log('Upload cancelled.');
console.log('Upload cancelled.', e);
}
this.uploadRequest = null;
@@ -489,101 +540,31 @@ export default {
let error, result;
try {
result = await superagent.post(`${this.apiOrigin}/api/v1/apps/${this.resourceId}/restart`).query({ access_token: this.accessToken });
result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${this.resourceId}/restart`, null, { access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 202) {
console.error(`Failed to restart app ${this.resourceId}`, error || result.statusCode);
if (error || result.status !== 202) {
console.error(`Failed to restart app ${this.resourceId}`, error || result.status);
return;
}
while(true) {
let error, result;
let result;
try {
result = await superagent.get(`${this.apiOrigin}/api/v1/apps/${this.resourceId}`).query({ access_token: this.accessToken });
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${this.resourceId}`, { 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;
if (result && result.status === 200 && result.body.installationState === ISTATES.INSTALLED) break;
await sleep(2000);
}
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('/')}` : '/';
});
}
};

View File

@@ -16,7 +16,7 @@ import { TextViewer, ImageViewer } from 'pankow-viewers';
import { createDirectoryModel } from '../models/DirectoryModel.js';
import { sanitize } from 'pankow/utils';
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.origin;
export default {
name: 'Viewer',

6
package-lock.json generated
View File

@@ -37,7 +37,6 @@
"multiparty": "^4.2.3",
"mysql": "^2.18.1",
"nodemailer": "^6.9.13",
"nsyslog-parser-2": "^0.9.11",
"oidc-provider": "^8.4.6",
"ovh": "^2.0.3",
"qrcode": "^1.5.3",
@@ -4215,11 +4214,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/nsyslog-parser-2": {
"version": "0.9.11",
"resolved": "https://registry.npmjs.org/nsyslog-parser-2/-/nsyslog-parser-2-0.9.11.tgz",
"integrity": "sha512-QF13YP12BAA38NOWescMjiEoyJtnRV5k++fYOP8kNqKFtCubv1w73W9UhjCeER4l87M+4CWlm3MJcD5ZbgDJAg=="
},
"node_modules/nwsapi": {
"version": "2.2.10",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.10.tgz",

View File

@@ -45,7 +45,6 @@
"multiparty": "^4.2.3",
"mysql": "^2.18.1",
"nodemailer": "^6.9.13",
"nsyslog-parser-2": "^0.9.11",
"oidc-provider": "^8.4.6",
"ovh": "^2.0.3",
"qrcode": "^1.5.3",

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
@@ -342,6 +361,16 @@ function check_node() {
success "node version is correct"
}
function check_ipv6() {
ipv6_disable=$(cat /sys/module/ipv6/parameters/disable)
if [[ "${ipv6_disable}" == "1" ]]; then
fail "IPv6 is disabled in kernel. Cloudron requires IPv6 in kernel"
echo "Instead of disabling IPv6 globally, you can disable it at an interface level using 'net.ipv6.conf.<interface>.disable_ipv6 = 1'"
fi
success "IPv6 is enabled"
}
function check_docker() {
if ! systemctl is-active -q docker; then
info "Docker is down. Trying to restart docker ..."
@@ -430,7 +459,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'"
@@ -484,6 +513,7 @@ function troubleshoot() {
# note: disk space test has already been run globally
print_system
check_node
check_ipv6
check_docker
check_host_mysql
check_nginx # requires mysql to be checked
@@ -572,6 +602,7 @@ function ask_reboot() {
function recreate_docker() {
readonly logfile="/home/yellowtent/platformdata/logs/box.log"
readonly stagefile="/home/yellowtent/platformdata/recreate-docker-stage"
readonly containerd_root="/var/lib/containerd"
if ! docker_root=$(docker info -f '{{ .DockerRootDir }}' 2>/dev/null); then
warning "Unable to detect docker root. Assuming /var/lib/docker"
@@ -597,7 +628,7 @@ function recreate_docker() {
if grep -q "clearing_storage" "${stagefile}"; then
info "Clearing docker storage at ${docker_root}"
if ! rm -rf "${docker_root}/"*; then
if ! rm -rf "${docker_root}/"* "${containerd_root}/"*; then
echo -e "\nThe server has to be rebooted to clear the docker storage. After reboot,"
echo -e "run 'cloudron-support --recreate-docker' again.\n"
ask_reboot
@@ -658,7 +689,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 +705,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;;

View File

@@ -120,6 +120,7 @@ apt-get -o Dpkg::Options::="--force-confold" install -y --no-install-recommends
# this ensures that unattended upgades are enabled, if it was disabled during ubuntu install time (see #346)
# debconf-set-selection of unattended-upgrades/enable_auto_updates + dpkg-reconfigure does not work
# logs of upgrades are at /var/log/apt/history.log and /var/log/unattended-upgrades/unattended-upgrades-dpkg.log
# apt-daily-upgrade.service (timer) runs the unattended-upgrades script depending on APT::Periodic::Unattended-Upgrade
echo "==> Enabling automatic upgrades"
cp /usr/share/unattended-upgrades/20auto-upgrades /etc/apt/apt.conf.d/20auto-upgrades

View File

@@ -22,6 +22,11 @@ readonly MAIL_DATA_DIR="${HOME_DIR}/boxdata/mail"
readonly script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly ubuntu_version=$(lsb_release -rs)
vergte() {
greater_version=$(echo -e "$1\n$2" | sort -rV | head -n1)
[[ "$1" == "${greater_version}" ]] && return 0 || return 1
}
cp -f "${script_dir}/../scripts/cloudron-support" /usr/bin/cloudron-support
cp -f "${script_dir}/../scripts/cloudron-translation-update" /usr/bin/cloudron-translation-update
rm -f /usr/bin/cloudron-logs # legacy script
@@ -109,7 +114,11 @@ systemctl restart systemd-journald
usermod -a -G adm ${USER}
log "Setting up unbound"
cp -f "${script_dir}/start/unbound.conf" /etc/unbound/unbound.conf.d/cloudron-network.conf
cp -f "${script_dir}/start/unbound/unbound.conf" /etc/unbound/unbound.conf.d/cloudron-network.conf
unbound_version=$(unbound -V | sed -n 's/^Version \([0-9.]*\)/\1/p')
if vergte "${unbound_version}" "1.19.2"; then
cp "${script_dir}/start/unbound/prefer-ip4.conf" /etc/unbound/unbound.conf.d/cloudron-prefer-ip4.conf
fi
rm -f /etc/unbound/unbound.conf.d/remote-control.conf # on ubuntu 24
# update the root anchor after a out-of-disk-space situation (see #269)
# it returns 1 even on fail, it's not clear - https://unbound.docs.nlnetlabs.nl/en/latest/manpages/unbound-anchor.html#exit-code

View File

@@ -8,6 +8,7 @@ Wants=network-online.target nss-lookup.target
[Service]
PIDFile=/run/unbound.pid
ExecStartPre=/usr/sbin/unbound-anchor -a /var/lib/unbound/root.key
ExecStart=/usr/sbin/unbound -d
ExecReload=/bin/kill -HUP $MAINPID
Restart=always

View File

@@ -0,0 +1,6 @@
# Prefer IPv4 outbound queries. Spamhaus often rejects queries from IPv6 addresses
# This setting is in a separate file since it only works from Ubuntu 24 , unbound 1.19.2
server:
prefer-ip4: yes

View File

@@ -2108,7 +2108,7 @@ async function getLogs(app, options) {
const appId = app.id;
const logPaths = await getLogPaths(app);
const cp = logs.tail(logPaths, { lines: options.lines, follow: options.follow });
const cp = logs.tail(logPaths, { lines: options.lines, follow: options.follow, sudo: true }); // need sudo access for paths inside app container (manifest.logPaths)
const logStream = new logs.LogStream({ format: options.format || 'json', source: appId });
logStream.on('close', () => cp.terminate()); // the caller has to call destroy() on logStream. destroy() of Transform emits 'close'
@@ -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}`);
}
}

View File

@@ -243,9 +243,10 @@ async function downloadImage(manifest) {
await docker.downloadImage(manifest);
}
async function updateChecklist(app, newChecks) {
async function updateChecklist(app, newChecks, acknowledged = false) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof newChecks, 'object');
assert.strictEqual(typeof acknowledged, 'boolean');
// add new checklist items depending on sso state
const checklist = app.checklist || {};
@@ -253,7 +254,7 @@ async function updateChecklist(app, newChecks) {
if (app.checklist[k]) continue;
const item = {
acknowledged: false,
acknowledged: acknowledged,
sso: newChecks[k].sso,
appVersion: app.version,
message: newChecks[k].message,
@@ -296,7 +297,7 @@ async function install(app, args, progressCallback) {
await verifyManifest(app.manifest);
// teardown for re-installs
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
await progressCallback({ percent: 10, message: 'Deleting old containers' });
await reverseProxy.unconfigureApp(app);
await deleteContainers(app, { managedOnly: true });
@@ -324,7 +325,7 @@ async function install(app, args, progressCallback) {
await downloadIcon(app);
await progressCallback({ percent: 25, message: 'Updating checklist' });
await updateChecklist(app, app.manifest.checklist || {});
await updateChecklist(app, app.manifest.checklist || {}, restoreConfig ? true : false);
if (!skipDnsSetup) {
await progressCallback({ percent: 30, message: 'Registering subdomains' });
@@ -406,7 +407,7 @@ async function create(app, args, progressCallback) {
assert.strictEqual(typeof args, 'object');
assert.strictEqual(typeof progressCallback, 'function');
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
await progressCallback({ percent: 10, message: 'Deleting old container' });
await deleteContainers(app, { managedOnly: true });
// FIXME: re-setup addons only because sendmail addon to re-inject env vars on mailboxName change
@@ -432,7 +433,7 @@ async function changeLocation(app, args, progressCallback) {
const skipDnsSetup = args.skipDnsSetup;
const overwriteDns = args.overwriteDns;
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
await progressCallback({ percent: 10, message: 'Unregistering old domains' });
await reverseProxy.unconfigureApp(app);
await deleteContainers(app, { managedOnly: true });
@@ -493,7 +494,7 @@ async function changeServices(app, args, progressCallback) {
assert.strictEqual(typeof args, 'object');
assert.strictEqual(typeof progressCallback, 'function');
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
await progressCallback({ percent: 10, message: 'Deleting old containers' });
await deleteContainers(app, { managedOnly: true });
const unusedAddons = {};
@@ -526,7 +527,7 @@ async function migrateDataDir(app, args, progressCallback) {
assert(newStorageVolumeId === null || typeof newStorageVolumeId === 'string');
assert(newStorageVolumePrefix === null || typeof newStorageVolumePrefix === 'string');
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
await progressCallback({ percent: 10, message: 'Deleting old containers' });
await deleteContainers(app, { managedOnly: true });
await progressCallback({ percent: 45, message: 'Ensuring app data directory' });
@@ -556,7 +557,7 @@ async function configure(app, args, progressCallback) {
assert.strictEqual(typeof args, 'object');
assert.strictEqual(typeof progressCallback, 'function');
await progressCallback({ percent: 10, message: 'Cleaning up old install' });
await progressCallback({ percent: 10, message: 'Deleting old containers' });
await reverseProxy.unconfigureApp(app);
await deleteContainers(app, { managedOnly: true });
@@ -616,7 +617,7 @@ async function update(app, args, progressCallback) {
}
await progressCallback({ percent: 20, message: 'Updating checklist' });
await updateChecklist(app, app.manifest.checklist || {});
await updateChecklist(app, app.manifest.checklist || {}, true /* new state acked */);
// download new image before app is stopped. this is so we can reduce downtime
// and also not remove the 'common' layers when the old image is deleted
@@ -625,7 +626,7 @@ async function update(app, args, progressCallback) {
// note: we cleanup first and then backup. this is done so that the app is not running should backup fail
// we cannot easily 'recover' from backup failures because we have to revert manfest and portBindings
await progressCallback({ percent: 35, message: 'Cleaning up old install' });
await progressCallback({ percent: 35, message: 'Deleting old containers' });
await deleteContainers(app, { managedOnly: true });
if (app.manifest.dockerImage !== updateConfig.manifest.dockerImage) await docker.deleteImage(app.manifest);

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
@@ -276,7 +279,7 @@ async function run(progressCallback) {
const { retention } = await backups.getPolicy();
debug(`run: retention is ${JSON.stringify(retention)}`);
const status = await storage.api(backupConfig.provider).getProviderStatus(backupConfig);
const status = await backups.ensureMounted();
debug(`run: mount point status is ${JSON.stringify(status)}`);
if (status.state !== 'active') throw new BoxError(BoxError.MOUNT_ERROR, `Backup endpoint is not mounted: ${status.message}`);

View File

@@ -39,6 +39,7 @@ exports = module.exports = {
remount,
getMountStatus,
ensureMounted,
BACKUP_IDENTIFIER_BOX: 'box',
BACKUP_IDENTIFIER_MAIL: 'mail',
@@ -359,9 +360,7 @@ function managedBackupMountObject(backupConfig) {
};
}
async function remount(auditSource) {
assert.strictEqual(typeof auditSource, 'object');
async function remount() {
const backupConfig = await getConfig();
if (mounts.isManagedProvider(backupConfig.provider)) {
@@ -380,12 +379,20 @@ async function getMountStatus() {
} else if (backupConfig.provider === 'filesystem') {
hostPath = backupConfig.backupFolder;
} else {
throw new BoxError(BoxError.BAD_STATE, 'Backup location is not a mount');
return { state: 'active' };
}
return await mounts.getStatus(backupConfig.provider, hostPath); // { state, message }
}
async function ensureMounted() {
const status = await getMountStatus();
if (status.state === 'active') return status;
await remount();
return await getMountStatus();
}
async function getPolicy() {
const result = await settings.getJson(settings.BACKUP_POLICY_KEY);
return result || {

View File

@@ -52,7 +52,7 @@ async function checkPreconditions(backupConfig, dataLayout) {
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
// check mount status before uploading
const status = await storage.api(backupConfig.provider).getProviderStatus(backupConfig);
const status = await backups.ensureMounted();
debug(`upload: mount point status is ${JSON.stringify(status)}`);
if (status.state !== 'active') throw new BoxError(BoxError.MOUNT_ERROR, `Backup endpoint is not active: ${status.message}`);

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 {

View File

@@ -355,7 +355,7 @@ async function createSubcontainer(app, name, cmd, options) {
},
// CpuPeriod (100000 microseconds) and CpuQuota(app.cpuQuota% of CpuPeriod)
// 1000000000 is one core https://github.com/moby/moby/issues/24713#issuecomment-233167619 and https://stackoverflow.com/questions/52391877/set-the-number-of-cpu-cores-of-a-container-using-docker-engine-api
NanoCPUs: app.cpuQuota === 100 ? 0 : (os.cpus().length * app.cpuQuota/100).toFixed(2) * 1000000000,
NanoCPUs: app.cpuQuota === 100 ? 0 : Math.round(os.cpus().length * app.cpuQuota/100 * 1000000000),
VolumesFrom: isAppContainer ? null : [ app.containerId + ':rw' ],
SecurityOpt: [ 'apparmor=docker-cloudron-app' ],
CapAdd: [],
@@ -373,8 +373,7 @@ async function createSubcontainer(app, name, cmd, options) {
if (isAppContainer) {
containerOptions.Hostname = app.id;
containerOptions.HostConfig.NetworkMode = 'cloudron'; // user defined bridge network
containerOptions.HostConfig.Dns = ['172.18.0.1']; // use internal dns
containerOptions.HostConfig.DnsSearch = ['.']; // use internal dns
containerOptions.HostConfig.ExtraHosts = [ `${dashboardFqdn}:172.18.0.1` ];
containerOptions.NetworkingConfig = {
EndpointsConfig: {

View File

@@ -6,7 +6,7 @@
exports = module.exports = {
// a version change recreates all containers with latest docker config
'version': '49.7.0',
'version': '49.8.0',
// a major version bump in the db containers will trigger the restore logic that uses the db dumps
// docker inspect --format='{{index .RepoDigests 0}}' $IMAGE to get the sha256
@@ -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',
}
};

View File

@@ -57,10 +57,17 @@ function tail(filePaths, options) {
assert.strictEqual(typeof options, 'object');
const lines = options.lines === -1 ? '+1' : options.lines;
const args = [ LOGTAIL_CMD, '--lines=' + lines ];
const args = options.sudo ? [ LOGTAIL_CMD ] : [];
args.push(`--lines=${lines}`);
if (options.follow) args.push('--follow');
return shell.sudo('tail', args.concat(filePaths), { streamStdout: true }, () => {});
if (options.sudo) {
return shell.sudo('tail', args.concat(filePaths), { quiet: true }, () => {});
} else {
const cp = spawn('/usr/bin/tail', args.concat(filePaths));
cp.terminate = () => cp.kill('SIGKILL');
return cp;
}
}
function journalctl(unit, options) {
@@ -76,7 +83,9 @@ function journalctl(unit, options) {
if (options.follow) args.push('--follow');
return spawn('journalctl', args);
const cp = spawn('journalctl', args);
cp.terminate = () => cp.kill('SIGKILL');
return cp;
}
exports = module.exports = {

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')

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;
}

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';
}
@@ -151,7 +151,7 @@ async function getStatus(mountType, hostPath) {
if (mountType === 'filesystem') return { state: 'active', message: 'Mounted' };
const [error] = await safe(shell.execArgs('getVolumeStatus', 'mountpoint', [ '-q', '--', hostPath ], { timeout: 5000 }));
const [error] = await safe(shell.execArgs('getStatus', 'mountpoint', [ '-q', '--', hostPath ], { timeout: 5000 }));
const state = error ? 'inactive' : 'active';
if (mountType === 'mountpoint') return { state, message: state === 'active' ? 'Mounted' : 'Not mounted' };

View File

@@ -288,6 +288,10 @@ server {
location ~ ^/translation/ {
root <%= sourceDir %>/dashboard/dist;
add_header "Access-Control-Allow-Origin" "*";
types {
application/json json;
}
}
# Cross domain webfont access for proxy auth login page https://github.com/h5bp/server-configs/issues/85

View File

@@ -18,6 +18,7 @@ exports = module.exports = {
INFRA_VERSION_FILE: path.join(baseDir(), 'platformdata/INFRA_VERSION'),
CRON_SEED_FILE: path.join(baseDir(), 'platformdata/CRON_SEED'),
DASHBOARD_DIR: constants.TEST ? path.join(__dirname, '../dashboard/src') : path.join(baseDir(), 'box/dashboard/dist'),
TRANSLATIONS_DIR: constants.TEST ? path.join(__dirname, '../dashboard/src/translation') : path.join(baseDir(), 'box/dashboard/dist/translation'),
PROVIDER_FILE: '/etc/cloudron/PROVIDER',
SETUP_TOKEN_FILE: '/etc/cloudron/SETUP_TOKEN',

View File

@@ -266,9 +266,9 @@ async function onDashboardLocationSet(subdomain, domain) {
async function onDashboardLocationChanged(auditSource) {
assert.strictEqual(typeof auditSource, 'object');
// mark apps using oidc addon to be reconfigured
// mark all apps to be reconfigured, all have ExtraHosts injected
const [, installedApps] = await safe(apps.list());
await safe(apps.configureApps(installedApps.filter((a) => !!a.manifest.addons?.oidc), { scheduleNow: true }, auditSource), { debug });
await safe(apps.configureApps(installedApps, { scheduleNow: true }, auditSource), { debug });
await safe(services.rebuildService('turn', auditSource), { debug }); // to update the realm variable
}

View File

@@ -66,7 +66,7 @@ async function cleanup(req, res, next) {
}
async function remount(req, res, next) {
const [error] = await safe(backups.remount(AuditSource.fromRequest(req)));
const [error] = await safe(backups.remount());
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(202, {}));

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);
}
};
}

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) {

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'));

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 "$@"

View File

@@ -23,7 +23,7 @@ host_path="$1"
mount_filename=$(systemd-escape -p --suffix=mount "$host_path")
mount_file="/etc/systemd/system/${mount_filename}"
# stop and start will do the reumount
# stop and start will do the remount
systemctl stop "${mount_filename}"
systemctl start "${mount_filename}"

View File

@@ -113,23 +113,24 @@ async function initializeExpressSync() {
router.post('/api/v1/dashboard/location', json, token, authorizeAdmin, routes.dashboard.changeLocation);
// system (vm/server)
router.get ('/api/v1/system/info', token, authorizeAdmin, routes.system.getInfo);
router.get ('/api/v1/system/info', token, authorizeAdmin, routes.system.getInfo); // vendor, product name etc
router.post('/api/v1/system/reboot', json, token, authorizeAdmin, routes.system.reboot);
router.get ('/api/v1/system/graphs', token, authorizeAdmin, routes.system.getSystemGraphs);
router.get ('/api/v1/system/cpus', token, authorizeAdmin, routes.system.getCpus);
router.get ('/api/v1/system/disk_usage', token, authorizeAdmin, routes.system.getDiskUsage);
router.post('/api/v1/system/disk_usage', token, authorizeAdmin, routes.system.updateDiskUsage);
router.get ('/api/v1/system/block_devices', token, authorizeAdmin, routes.system.getBlockDevices);
router.get ('/api/v1/system/memory', token, authorizeAdmin, routes.system.getMemory);
router.get ('/api/v1/system/logs/:unit', token, authorizeAdmin, routes.system.getLogs);
router.get ('/api/v1/system/logstream/:unit', token, authorizeAdmin, routes.system.getLogStream);
// app operators require cpu and memory info for the Resources UI
router.get ('/api/v1/system/cpus', token, authorizeUser, routes.system.getCpus);
router.get ('/api/v1/system/memory', token, authorizeUser, routes.system.getMemory);
// eventlog
router.get ('/api/v1/eventlog', token, authorizeAdmin, routes.eventlog.list);
router.get ('/api/v1/eventlog/:eventId', token, authorizeAdmin, routes.eventlog.get);
// updater
router.get ('/api/v1/updater/updates', token, authorizeAdmin, routes.updater.getUpdateInfo);
router.get ('/api/v1/updater/updates', token, authorizeUser, routes.updater.getUpdateInfo);
router.post('/api/v1/updater/update', json, token, authorizeAdmin, routes.updater.update);
router.post('/api/v1/updater/check_for_updates', json, token, authorizeAdmin, routes.updater.checkForUpdates);
router.get ('/api/v1/updater/autoupdate_pattern', token, authorizeAdmin, routes.updater.getAutoupdatePattern);

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}`;

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 \

View File

@@ -20,7 +20,7 @@ exports = module.exports = {
const SUDO = '/usr/bin/sudo';
// default encoding utf8, no shell, separate args, wait for process to finish
// default encoding utf8, no shell, handles input, separate args, wait for process to finish
async function execArgs(tag, file, args, options) {
assert.strictEqual(typeof tag, 'string');
assert.strictEqual(typeof file, 'string');
@@ -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

View File

@@ -1,7 +1,6 @@
'use strict';
exports = module.exports = {
getProviderStatus,
getAvailableSize,
upload,
@@ -35,23 +34,12 @@ const assert = require('assert'),
debug = require('debug')('box:storage/filesystem'),
df = require('../df.js'),
fs = require('fs'),
mounts = require('../mounts.js'),
path = require('path'),
paths = require('../paths.js'),
readdirp = require('readdirp'),
safe = require('safetydance'),
shell = require('../shell.js');
async function getProviderStatus(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
// Check filesystem is mounted so we don't write into the actual folder on disk
if (!mounts.isManagedProvider(apiConfig.provider) && apiConfig.provider !== 'mountpoint') return await mounts.getStatus(apiConfig.provider, apiConfig.backupFolder);
const hostPath = mounts.isManagedProvider(apiConfig.provider) ? paths.MANAGED_BACKUP_MOUNT_DIR : apiConfig.mountPoint;
return await mounts.getStatus(apiConfig.provider, hostPath); // { state, message }
}
// the du call in the function below requires root
async function getAvailableSize(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
@@ -88,7 +76,7 @@ async function upload(apiConfig, backupFilePath) {
const [mkdirError] = await safe(fs.promises.mkdir(path.dirname(backupFilePath), { recursive: true }));
if (mkdirError) throw new BoxError(BoxError.FS_ERROR, `Error creating directory ${backupFilePath}: ${mkdirError.message}`);
await safe(fs.promises.unlink(backupFilePath), { debug }); // remove any hardlink
await safe(fs.promises.unlink(backupFilePath)); // remove any hardlink
return {
stream: fs.createWriteStream(backupFilePath, { autoClose: true }),
@@ -171,7 +159,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/', '') ]);

View File

@@ -1,7 +1,6 @@
'use strict';
exports = module.exports = {
getProviderStatus,
getAvailableSize,
upload,
@@ -61,12 +60,6 @@ function getBucket(apiConfig) {
return new GCS(gcsConfig).bucket(apiConfig.bucket);
}
async function getProviderStatus(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
return { state: 'active' };
}
async function getAvailableSize(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');

View File

@@ -11,7 +11,6 @@
// for the other API calls we leave it to the backend to retry. this allows
// them to tune the concurrency based on failures/rate limits accordingly
exports = module.exports = {
getProviderStatus,
getAvailableSize,
upload,
@@ -44,12 +43,6 @@ function injectPrivateFields(newConfig, currentConfig) {
// in-place injection of tokens and api keys which came in with constants.SECRET_PLACEHOLDER
}
async function getProviderStatus(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
return { state: 'active' };
}
async function getAvailableSize(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');

View File

@@ -1,7 +1,6 @@
'use strict';
exports = module.exports = {
getProviderStatus,
getAvailableSize,
upload,
@@ -21,13 +20,8 @@ exports = module.exports = {
const assert = require('assert'),
BoxError = require('../boxerror.js'),
debug = require('debug')('box:storage/noop');
async function getProviderStatus(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
return { state: 'active' };
}
debug = require('debug')('box:storage/noop'),
fs = require('fs');
async function getAvailableSize(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
@@ -35,15 +29,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) {

View File

@@ -1,7 +1,6 @@
'use strict';
exports = module.exports = {
getProviderStatus,
getAvailableSize,
upload,
@@ -92,12 +91,6 @@ function getS3Config(apiConfig) {
return credentials;
}
async function getProviderStatus(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
return { state: 'active' };
}
async function getAvailableSize(apiConfig) {
assert.strictEqual(typeof apiConfig, 'object');
@@ -133,7 +126,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)}`);
}
};

View File

@@ -300,12 +300,9 @@ async function getLogs(unit, options) {
assert.strictEqual(typeof unit, 'string');
assert(options && typeof options === 'object');
debug(`Getting logs for ${unit}`);
let logFile = '';
if (unit === 'box') logFile = path.join(paths.LOG_DIR, 'box.log'); // box.log is at the top
else throw new BoxError(BoxError.BAD_FIELD, `No such unit '${unit}'`);
if (unit !== 'box') throw new BoxError(BoxError.BAD_FIELD, `No such unit '${unit}'`);
const logFile = path.join(paths.LOG_DIR, 'box.log');
const cp = logs.tail([logFile], { lines: options.lines, follow: options.follow });
const logStream = new logs.LogStream({ format: options.format || 'json', source: unit });

View File

@@ -160,11 +160,8 @@ describe('Storage', function () {
format: 'tgz'
};
it('upload works', function (done) {
noop.upload(gBackupConfig, 'file', { }, function (error) {
expect(error).to.be(null);
done();
});
it('upload works', async function () {
await noop.upload(gBackupConfig, 'file', {});
});
it('can download file', async function () {

View File

@@ -14,8 +14,6 @@ const assert = require('assert'),
paths = require('./paths.js'),
safe = require('safetydance');
const TRANSLATION_FOLDER = path.join(paths.DASHBOARD_DIR, 'translation');
// to be used together with getTranslations() => { translations, fallback }
function translate(input, assets) {
assert.strictEqual(typeof input, 'string');
@@ -51,21 +49,23 @@ function translate(input, assets) {
}
async function getTranslations() {
const fallback = safe.JSON.parse(fs.readFileSync(path.join(TRANSLATION_FOLDER, 'en.json'), 'utf8'));
if (!fallback) debug(`getTranslations: Fallback language en not found. ${safe.error.message}`);
const fallbackData = fs.readFileSync(path.join(paths.TRANSLATIONS_DIR, 'en.json'), 'utf8');
if (!fallbackData) debug(`getTranslations: Fallback language en not found. ${safe.error.message}`);
const fallback = safe.JSON.parse(fallbackData) || {};
const lang = await cloudron.getLanguage();
const translations = safe.JSON.parse(fs.readFileSync(path.join(TRANSLATION_FOLDER, lang + '.json'), 'utf8'));
if (!translations) debug(`getTranslations: Requested language ${lang} not found. ${safe.error.message}`);
const translationData = safe.fs.readFileSync(path.join(paths.TRANSLATIONS_DIR, `${lang}.json`), 'utf8');
if (!translationData) debug(`getTranslations: Requested language ${lang} not found. ${safe.error.message}`);
const translations = safe.JSON.parse(translationData) || {};
return { translations: translations || {}, fallback: fallback || {} };
return { translations, fallback };
}
async function listLanguages() {
const [error, result] = await safe(fs.promises.readdir(TRANSLATION_FOLDER));
const [error, result] = await safe(fs.promises.readdir(paths.TRANSLATIONS_DIR));
if (error) {
debug('listLanguages: Failed to list translations. %o', error);
debug(`listLanguages: Failed to list translations. %${error.message}`);
return [ 'en' ]; // we always return english to avoid dashboard breakage
}

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');

View File

@@ -12,11 +12,33 @@ const debug = require('debug')('syslog:server'),
net = require('net'),
path = require('path'),
paths = require('./src/paths.js'),
parser = require('nsyslog-parser-2'),
util = require('util');
let gServer = null;
// https://docs.docker.com/engine/logging/drivers/syslog/
// example: <34>1 2023-09-07T14:33:22Z myhost myapp 1234 5678 [exampleSDID@32473 iut="3" eventSource="Application"] An example message
function parseRFC5424Message(rawMessage) {
const syslogRegex = /^<(\d+)>(\d+) (\S+) (\S+) (\S+) (\S+) (\S+) (?:\[(.*?)\])?(.*)$/s; // /s means .* will match newline
const match = rawMessage.match(syslogRegex);
if (!match) return null;
const [, pri, version, timestamp, hostname, appName, procId, msgId, structuredData, message] = match;
return {
pri: parseInt(pri, 10), // priority
version: parseInt(version, 10), // version
timestamp, // timestamp
hostname, // hostname
appName, // app name
procId, // process ID
msgId, // message ID
structuredData: structuredData ? structuredData : null, // structured data (if present)
message // message
};
}
async function start() {
debug('==========================================');
debug(' Cloudron Syslog Daemon ');
@@ -29,33 +51,24 @@ async function start() {
});
gServer.on('connection', function (socket) {
socket.on('data', function (msg) {
const lines = msg.toString().split('\n'); // may be multiline data
socket.on('data', function (data) {
const msg = data.toString('utf8');
const info = parseRFC5424Message(msg);
if (!info) return debug('Unable to parse:', msg);
if (!info.appName) return debug('Ignore unknown app:', msg);
lines.forEach(function (msg) {
if (!msg) return;
const appLogDir = path.join(paths.LOG_DIR, info.appName);
const info = parser(msg);
if (!info || !info.appName) return debug('Ignore unknown app log:', msg);
// remove line breaks to avoid holes in the log file
// we do not ignore empty log lines, to allow gaps for potential ease of readability
const message = info.message.replace(/[\n\r]+/g, '');
const appLogDir = path.join(paths.LOG_DIR, info.appName);
try {
fs.mkdirSync(appLogDir, { recursive: true });
fs.appendFileSync(`${appLogDir}/app.log`, info.ts.toISOString() + ' ' + message + '\n');
} catch (error) {
console.error(error);
}
});
try {
fs.mkdirSync(appLogDir, { recursive: true });
fs.appendFileSync(`${appLogDir}/app.log`, `${info.timestamp} ${info.message.trim()}\n`);
} catch (error) {
debug(error);
}
});
socket.on('error', function (error) {
console.error(`socket error: ${error}`);
debug(`socket error: ${error}`);
});
});