Compare commits

..

208 Commits

Author SHA1 Message Date
Girish Ramakrishnan e8850eeac2 8.0.6 changelog 2024-09-18 15:33:42 +02:00
Girish Ramakrishnan 777834d790 dig: set tries parameter 2024-09-18 15:25:48 +02:00
Girish Ramakrishnan dca9246450 Fix AdGuard resolving dashboard to docker bridge IP
Issue 1: DO droplet when given the name my.blah.com , will put an entry
in /etc/hosts with `127.0.1.1 my.blah.com` . When app containers use
system DNS, they get this IP address which does not work inside a container.

An idea is to remove this entry when running cloudron-setup, but maybe this
causes trouble later.

Issue 2: Some networks seem to lack loopback networking. With OIDC changes,
we want the apps to access my.blah.com even if hairpin nat is not working.

Solution: make my.blah.com to resolve to the docker bridge IP (172.18.0.1)
where nginx also listens to. This means that such requests never go outside the server

Caveats:
* This breaks AdGuard which now starts resolving it to 172.18.0.1 for
the entire network! So, we skip ExtraHosts configuration for adguard

* Maybe ExtraHosts should be scoped to OIDC apps only. But the thought here is
that it will help apps like say n8n which are querying dasahboard.
2024-09-18 14:42:11 +02:00
Girish Ramakrishnan 767f7ab40e capitalize view name 2024-09-18 13:10:26 +02:00
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
Girish Ramakrishnan 8d9043e590 logviewer: reduce it back to 100
a lot of delay with 300
2024-07-23 17:27:36 +02:00
Johannes Zellner 59c3e8817c frontend: Reduce initial logs to 300 lines only 2024-07-23 16:33:56 +02:00
Girish Ramakrishnan 3132b3035a 8.0.2 changes 2024-07-23 08:34:26 +02:00
Girish Ramakrishnan 7ebf5ca16a Bring back upload route to keep e2e happy
let's maybe remove it in next release
2024-07-23 08:28:44 +02:00
Johannes Zellner d96f132dc0 frontend: reduce DOM node creation on very fast logstreams and cap to 1k loglines 2024-07-22 23:55:47 +02:00
Girish Ramakrishnan b26ff08a3c shell: copy over code and signal values from cp object 2024-07-22 21:24:27 +02:00
Girish Ramakrishnan 44678cf5f1 sshfs: if remote copy fails, fallback to sshfs based copy
remote copy can file if there is no cp in the remote . for example,
if it was a windows server.
2024-07-22 20:53:19 +02:00
Girish Ramakrishnan 5084ee761e update postgresql conf notes 2024-07-22 18:53:51 +02:00
Girish Ramakrishnan 91f50ae949 mysql: add template custom.cnf 2024-07-22 18:53:51 +02:00
Johannes Zellner 15f04edcf1 frontend: update dependencies 2024-07-22 18:00:33 +02:00
Johannes Zellner 01945675ed Check if addons exists in database import 2024-07-22 16:45:13 +02:00
Johannes Zellner 185c16c3e2 remove apps upload api in favor of sftp container api 2024-07-22 16:20:15 +02:00
Johannes Zellner d25814b84b Remove stray console.log 2024-07-22 15:07:43 +02:00
Girish Ramakrishnan a09a3fd012 postgresql: add template custom.conf 2024-07-22 14:44:23 +02:00
Johannes Zellner 871fd83148 Use new sftp service image without multipart file upload 2024-07-22 13:29:56 +02:00
Girish Ramakrishnan dd8bc493e7 postgresql: add custom.conf include 2024-07-22 12:50:23 +02:00
Johannes Zellner 44d3baf51a dashboard: show longer pretty datetime for backups 2024-07-21 20:45:34 +02:00
Girish Ramakrishnan c85c0558b9 multipart: cleanup files after reading their contents
one idea is just use express.raw() . however, we have to implement some
file size limit there.

one case this does not handle is aborted uploads from a box.service restart.
for this rare case, a server reboot will clean up /tmp anyway.
2024-07-19 23:11:26 +02:00
Girish Ramakrishnan 7f11699fac remove urlencoded
we don't use this in our API
2024-07-19 22:44:22 +02:00
Girish Ramakrishnan 525e48ae59 json middleware is part of Express v4.16.0 2024-07-19 22:26:24 +02:00
Johannes Zellner a6369a7dde Fix linter error 2024-07-19 22:24:34 +02:00
Girish Ramakrishnan d5ea99603f backups: give is a low oomScoreAdjust to not get killed 2024-07-19 13:05:09 +02:00
Girish Ramakrishnan 083432cbfe test: add EnsureFileSizeStream test 2024-07-18 15:39:45 +02:00
Girish Ramakrishnan dbbce4160d tgz: underflow/overflow proxy stream
In tar, the entry header contains the file size. If we don't provide it those many bytes, the tar will become corrupt
Linux provides no guarantee of how many bytes can be read from a file. This is the case with sqlite and log files
which are accessed by other processes when tar is in action. This class handles overflow and underflow
2024-07-18 15:13:38 +02:00
Girish Ramakrishnan 885aac69c5 tgz: handle addEntryToPack to error 2024-07-18 14:47:31 +02:00
Girish Ramakrishnan b3c301fc2a lint 2024-07-18 13:31:29 +02:00
Girish Ramakrishnan 01deb4d285 update: updateConfig can be missing values, selectively update db 2024-07-17 08:58:43 +02:00
Girish Ramakrishnan aeddaa4566 apps: rework portBindings
ports is REST API input . Map of env var to the host port
portBinding is the database structure. Map of env var to host port, count, type etc

also, rename portCount -> count in various places to keep things consistent
2024-07-17 00:25:47 +02:00
Girish Ramakrishnan eb314ef507 lint 2024-07-16 22:07:22 +02:00
Girish Ramakrishnan 620c49cf76 Fix signature of checkForPortBindingsConflict 2024-07-16 19:31:54 +02:00
Girish Ramakrishnan 6d73dfdb40 parse port count as integer 2024-07-16 19:28:22 +02:00
Girish Ramakrishnan 232cdb8cb1 cloudron-support: do cert check before site check 2024-07-16 19:21:09 +02:00
Girish Ramakrishnan fd53174099 lint 2024-07-16 10:32:37 +02:00
Girish Ramakrishnan 9bf240d83b update: handle change in secondary domains and multiDomain flag 2024-07-16 10:32:31 +02:00
Girish Ramakrishnan 421567ff14 Add to changes 2024-07-15 21:52:04 +02:00
Girish Ramakrishnan ce05008fce setup: when activated redirect to adminFqdn 2024-07-15 21:52:01 +02:00
Girish Ramakrishnan a250cb9fe2 capitalize 2024-07-15 21:08:16 +02:00
Girish Ramakrishnan 012f8bc14e setup: show message on how to redo setup 2024-07-15 21:08:13 +02:00
Girish Ramakrishnan 11dce549bd refactor init sequence 2024-07-15 18:52:22 +02:00
Girish Ramakrishnan 5b567ac941 lint 2024-07-15 16:57:47 +02:00
Girish Ramakrishnan 5b103c78e5 lint 2024-07-15 16:54:48 +02:00
Girish Ramakrishnan bc96f9c5e5 update: match the ui in settings page 2024-07-15 16:28:00 +02:00
Girish Ramakrishnan d97d82b225 settings: do not show "skip backup" when updates is blocked
https://forum.cloudron.io/topic/12092/imho-the-skip-backup-checkbox-makes-no-sense
2024-07-15 09:48:04 +02:00
Girish Ramakrishnan e9b6002f63 s3: fix exists check 2024-07-14 22:04:12 +02:00
Girish Ramakrishnan 704999a05f backups: fix incorrect "memoryLimit must be a number" error 2024-07-14 18:21:12 +02:00
Girish Ramakrishnan ba99e3b9b7 already in setup script now 2024-07-14 17:06:13 +02:00
Girish Ramakrishnan 9adeaed1b9 support: add hidden troubleshooting section 2024-07-12 16:20:25 +02:00
Girish Ramakrishnan 10bd2e930f support: remove commented out sections 2024-07-12 14:25:11 +02:00
Girish Ramakrishnan 07396c9824 Revert "support: add route to repair apps"
This reverts commit 0bab0ed748.

It's better to somehow integrate this into the dashboard ...
2024-07-12 14:22:47 +02:00
Girish Ramakrishnan bf34b13b7f cloudron-support: add --patch 2024-07-12 11:06:06 +02:00
Girish Ramakrishnan 0bab0ed748 support: add route to repair apps 2024-07-11 18:30:29 +02:00
Girish Ramakrishnan 8754a208b1 tgz: preserve mode 2024-07-11 18:10:40 +02:00
Johannes Zellner 19100c7999 dashboard: make app update section a bit more explicit 2024-07-11 17:51:20 +02:00
Johannes Zellner d98ec77abf Update German translation 2024-07-11 16:47:39 +02:00
Johannes Zellner 34c2decd91 Remove indonesian and portuguese translation as they are way below 50% 2024-07-11 15:48:43 +02:00
Johannes Zellner 09fb4ea89f Add Indonesian (lang.id) translation name 2024-07-11 15:44:38 +02:00
Girish Ramakrishnan d6bb32aead syncer: expose as async 2024-07-10 19:10:55 +02:00
Girish Ramakrishnan 3a21191fba tgz: fix error handling 2024-07-10 19:10:24 +02:00
Girish Ramakrishnan ad4e0ba9aa tests: fix storage test 2024-07-08 22:29:45 +02:00
Girish Ramakrishnan baf598099f rsync: fix upload logic to match new upload api 2024-07-08 15:21:56 +02:00
Johannes Zellner 7d017d83d6 dashboard: do not open email client setup if disabled 2024-07-08 15:19:00 +02:00
Girish Ramakrishnan 7911780a16 const 2024-07-08 13:18:22 +02:00
Girish Ramakrishnan 1dc6b40a68 tgz: extract using tar-stream directly
we used have a fork of tar-fs. using tar-stream directly gives us
more control
2024-07-08 13:06:56 +02:00
Girish Ramakrishnan dd9e6e63ad apptask: only delete image if it is different 2024-07-08 12:59:34 +02:00
Girish Ramakrishnan 30633e7820 lint 2024-07-08 10:47:07 +02:00
Girish Ramakrishnan acfc67ed0a backuptask: typo in usage of getAvailableSize 2024-07-08 10:46:28 +02:00
Girish Ramakrishnan a99a8ef382 services: fix crash because of missing safe() 2024-07-08 10:30:10 +02:00
Girish Ramakrishnan 7aec713e6c shell: fix streaming of stdout/stderr with sudo 2024-07-08 10:09:00 +02:00
Girish Ramakrishnan 60c4dd3875 sudo: add explicit captureStdout flag 2024-07-08 09:58:25 +02:00
Girish Ramakrishnan 7d8ba8d42c tests: datalayout 2024-07-07 20:23:32 +02:00
Girish Ramakrishnan 7ff7842441 lint 2024-07-05 21:56:33 +02:00
Girish Ramakrishnan bcf497b460 translation.js -> translations.js
kept confusing my why i can't find this file! this is in line
with the rest of our code
2024-07-05 12:45:27 +02:00
Girish Ramakrishnan bf51a60986 change args of translation.translate 2024-07-05 12:42:33 +02:00
Girish Ramakrishnan 41809d1ca8 mailer: format is not used 2024-07-05 12:20:30 +02:00
Girish Ramakrishnan acb1445270 make these const 2024-07-05 11:41:07 +02:00
Girish Ramakrishnan 86530df37e mailer: add html version of test mail 2024-07-05 11:07:51 +02:00
Girish Ramakrishnan b64b513b14 Revert "use node-tar for extract"
This reverts commit 285feb4f8b.
2024-07-05 09:26:38 +02:00
Girish Ramakrishnan 285feb4f8b use node-tar for extract
we will switch over our tgz module to node-tar. Main advantage is that
it is used by npm. Currently, we have our own fork to ignore stat errors
in the other module.

unfortunately, I cannot get this to work with the create logic. It doesn't
support path modification - https://github.com/isaacs/node-tar/issues/271
so, will revert this immediately and keep this for future
2024-07-05 09:26:28 +02:00
Girish Ramakrishnan c6f4395578 Fix deprecation of Buffer.slice 2024-07-05 09:26:28 +02:00
Johannes Zellner 4981854c7f dashboard: do not duplicate app info in update section 2024-07-04 16:57:54 +02:00
Girish Ramakrishnan 65f6ff35e0 Update translations 2024-07-04 09:28:18 +02:00
Johannes Zellner d892cc5763 Add comment how to debug the openid provider 2024-07-03 11:33:58 +02:00
Johannes Zellner d122ece8e9 Attempt server side copy on sshfs via ssh exec
only so far tested agains hetzner storage boxes which apparently run BSD
unix tools
2024-07-02 19:51:34 +02:00
Girish Ramakrishnan a363e508b6 ami: disable route53
we got an email from AWS team that their policy prevents collection
of AMI credentials in AMI images
2024-07-02 16:09:36 +02:00
Girish Ramakrishnan e481606d0e lint 2024-07-02 16:09:36 +02:00
Girish Ramakrishnan a1e2c9fd08 cloudron-support: print_system 2024-07-01 14:08:55 +02:00
Girish Ramakrishnan f5931abdeb cloudron-support: print ubuntu version 2024-07-01 13:38:18 +02:00
Girish Ramakrishnan 4c9e05b08f cloudron-support: add netplan and product info 2024-07-01 08:24:01 +02:00
Girish Ramakrishnan 9c34727e88 cloudron-support: dig does not return error on SERVFAIL 2024-07-01 07:57:21 +02:00
Girish Ramakrishnan 939cd94ebb typo 2024-07-01 07:55:08 +02:00
Johannes Zellner 4a33415b06 Set notes on existing apps to empty string if NULL 2024-06-29 09:52:41 +02:00
Girish Ramakrishnan 082e659c7b disable rpcbind
rpcbind is required for NFSv2 and v3 . It seems this gets installed
by nfs-common. It was never used by us since the firewall blocks
port 111 anyways.

NFSv3 needs 2049 for NFS, 111 for portmap, 635 for mountd, 4045 for NLM, 4046 for NSM, 4049 for rquota ...

NFSv4 works better because there's just a single target port, plus the "heartbeat" of lease renewal would keep the TCP/IP session alive.

https://serverfault.com/questions/949127/nfs-client-firewall-settings-and-rpcbind
https://docs.redhat.com/en/documentation/Red_Hat_Enterprise_Linux/6/html/Storage_Administration_Guide/s2-nfs-methodology-portmap.html#s2-nfs-methodology-portmap
https://community.netapp.com/t5/Tech-ONTAP-Blogs/NFSv3-and-NFSv4-What-s-the-difference/ba-p/441316
2024-06-27 20:37:08 +02:00
Girish Ramakrishnan a8059c49e9 lint 2024-06-27 16:50:31 +02:00
Johannes Zellner f7b14b2ee8 dashboard: only show postinstall if notes are not just empty 2024-06-27 16:20:19 +02:00
Johannes Zellner 581a294af1 dashboard: give the checklist done button some space 2024-06-27 13:58:58 +02:00
Johannes Zellner 40e8ba38f0 dashboard: fix app grid item tooltip 2024-06-27 13:49:20 +02:00
Girish Ramakrishnan 65f4ec0f43 cloudron-support: check dns now and not just unbound 2024-06-27 12:58:37 +02:00
134 changed files with 4051 additions and 3262 deletions
-25
View File
@@ -1,25 +0,0 @@
{
"env": {
"node": true,
"es6": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 13
},
"rules": {
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"always"
],
"no-console": "off"
}
}
-5
View File
@@ -1,5 +0,0 @@
{
"node": true,
"unused": true,
"esversion": 11
}
+55
View File
@@ -2795,4 +2795,59 @@
* dashboard: add admin notes
* Use systemd-resolved as the system resolver. unbound is now only for mail server and recursive DNS lookups
[8.0.1]
* nfs: disable rpcbind service. we only support nfsv4 mounting
* dashboard: only show postinstall if notes are not just empty
* ami: disable route53
* mailer: add html version of test mail
* sshfs: server side copying
* backups: rewrite tgz backups using tar-stream
* backups: fix issue with s3 backend where files missing in remote was not detected correctly
* provision: redirect to correct task (setup/restore/activation)
[8.0.2]
* tgz: fix unhandled promise error handler
* tgz: add underflow/overflow proxy stream to ensure size of a changing file
* backups: give task a low oomScoreAdjust to not get killed
* Fix issue with uploads via File Manager where temp files were not cleaned up
* addons: fix crash when importing database of an app with no addons
* 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
[8.0.6]
* Fix AdGuard resolving dashboard to docker bridge IP
+3 -29
View File
@@ -1,6 +1,6 @@
'use strict';
/* global angular */
/* global angular, window, document, localStorage, redirectIfNeeded */
/* global $ */
// create main application module
@@ -70,33 +70,6 @@ app.controller('SetupController', ['$scope', 'Client', function ($scope, Client)
}
};
function redirectIfNeeded(status) {
if ('develop' in search || localStorage.getItem('develop')) {
console.warn('Cloudron develop mode on. To disable run localStorage.removeItem(\'develop\')');
localStorage.setItem('develop', true);
return;
}
// if we are here from https://ip/activation.html ,go to https://admin/activation.html
if (status.adminFqdn && status.adminFqdn !== window.location.hostname) {
window.location.href = 'https://' + status.adminFqdn + '/activation.html';
return true;
}
// if we don't have a domain yet, first go to domain setup
if (!status.adminFqdn) {
window.location.href = '/setup.html';
return true;
}
if (status.activated) {
window.location.href = '/';
return true;
}
return false;
}
function setView(view) {
if (view === 'finished') {
$scope.view = 'finished';
@@ -109,7 +82,8 @@ app.controller('SetupController', ['$scope', 'Client', function ($scope, Client)
Client.getProvisionStatus(function (error, status) {
if (error) return Client.initError(error, init);
if (redirectIfNeeded(status)) return;
if (redirectIfNeeded(status, 'activation')) return; // redirected to some other view...
setView(search.view);
$scope.setupToken = search.setupToken;
+82 -13
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)
@@ -451,6 +454,73 @@ function translateFilterFactory($parse, $translate) {
translateFilterFactory.displayName = 'translateFilterFactory';
angular.module('Application').filter('tr', translateFilterFactory);
// checks provision status and redirects to correct view
// {
// setup: { active, message, errorMessage }
// restore { active, message, errorMessage }
// activated
// adminFqn
// }
// returns true if redirected . currentView is one of dashboard/restore/setup/activation
function redirectIfNeeded(status, currentView) {
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
if ('develop' in search || localStorage.getItem('develop')) {
console.warn('Cloudron develop mode on. To disable run localStorage.removeItem(\'develop\')');
localStorage.setItem('develop', true);
return false;
}
if (status.activated) {
console.log('Already activated');
if (currentView === 'dashboard') {
// support local development with localhost check
if (window.location.hostname !== status.adminFqdn && window.location.hostname !== 'localhost' && !window.location.hostname.startsWith('192.')) {
// user is accessing by IP or by the old admin location (pre-migration)
window.location.href = '/setup.html' + window.location.search;
return true;
}
return false;
}
window.location.href = 'https://' + status.adminFqdn + '/';
return true;
}
if (status.setup.active) {
console.log('Setup is active');
if (currentView === 'setup') return false;
window.location.href = '/setup.html' + window.location.search;
return true;
}
if (status.restore.active) {
console.log('Restore is active');
if (currentView === 'restore') return;
window.location.href = '/restore.html' + window.location.search;
return true;
}
if (status.adminFqdn) {
console.log('adminFqdn is set');
// if we are here from https://ip/activation.html ,go to https://admin/activation.html
if (status.adminFqdn !== window.location.hostname) {
window.location.href = 'https://' + status.adminFqdn + '/activation.html' + (window.location.search);
return true;
}
if (currentView === 'activation') return false;
window.location.href = 'https://' + status.adminFqdn + '/activation.html' + (window.location.search);
return true;
}
if (currentView === 'dashboard') {
window.location.href = '/setup.html' + window.location.search;
return true;
}
// if we are here, proceed with current view
return false;
}
// ----------------------------------------------
// Cloudron REST API wrapper
@@ -888,7 +958,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
subdomain: config.subdomain,
domain: config.domain,
secondaryDomains: config.secondaryDomains,
portBindings: config.portBindings,
ports: config.ports,
accessRestriction: config.accessRestriction,
cert: config.cert,
key: config.key,
@@ -910,7 +980,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
subdomain: config.subdomain,
domain: config.domain,
secondaryDomains: config.secondaryDomains,
portBindings: config.portBindings,
ports: config.ports,
backupId: config.backupId,
overwriteDns: !!config.overwriteDns
};
@@ -1077,7 +1147,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
const storageConfig = Object.assign({}, backupConfig);
delete storageConfig.limits;
post('/api/v1/backups/config/storage', backupConfig, null, function (error, data, status) {
post('/api/v1/backups/config/storage', storageConfig, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
@@ -1330,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));
@@ -2574,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);
});
@@ -3703,7 +3772,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
} else if (data.label) {
return `Label ${appName('of', app)} was set to ${data.label}`;
} else if (data.tags) {
return `Tags ${appName('of', app)} were set to ${data.tags.join(', ')}`;
return `Tags ${appName('of', app)} was set to ${data.tags.join(', ')}`;
} else if (data.icon) {
return 'Icon ' + appName('of', app) + ' was changed';
} else if (data.memoryLimit) {
@@ -3721,9 +3790,9 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
return appName('', app, 'App') + ' was taken out of repair mode';
}
} else if ('enableBackup' in data) {
return 'Automatic backups ' + appName('of', app) + ' were ' + (data.enableBackup ? 'enabled' : 'disabled');
return 'Automatic backups ' + appName('of', app) + ' was ' + (data.enableBackup ? 'enabled' : 'disabled');
} else if ('enableAutomaticUpdate' in data) {
return 'Automatic updates ' + appName('of', app) + ' were ' + (data.enableAutomaticUpdate ? 'enabled' : 'disabled');
return 'Automatic updates ' + appName('of', app) + ' was ' + (data.enableAutomaticUpdate ? 'enabled' : 'disabled');
} else if ('reverseProxyConfig' in data) {
return 'Reverse proxy configuration ' + appName('of', app) + ' was updated';
} else if ('upstreamUri' in data) {
+3 -26
View File
@@ -1,6 +1,6 @@
'use strict';
/* global angular:false */
/* global angular:false, window, document, localStorage, redirectIfNeeded */
/* global $:false */
/* global async */
/* global ERROR,ISTATES,HSTATES,RSTATES,APP_TYPES,NOTIFICATION_TYPES */
@@ -429,7 +429,7 @@ app.filter('errorSuggestion', function () {
};
});
app.filter('readyToUpdate', function () {
app.filter('canUpdate', function () {
return function (apps) {
return apps.every(function (app) {
return (app.installationState === ISTATES.ERROR) || (app.installationState === ISTATES.INSTALLED);
@@ -778,35 +778,12 @@ app.controller('MainController', ['$scope', '$route', '$timeout', '$location', '
});
}
function redirectIfNeeded(status) {
if (!status.activated) {
console.log('Not activated yet, redirecting', status);
if (status.restore.active || status.restore.errorMessage) { // show the error message in restore page
window.location.href = '/restore.html' + window.location.search;
} else if (status.adminFqdn) {
window.location.href = 'https://' + status.adminFqdn + '/activation.html' + (window.location.search);
} else {
window.location.href = '/setup.html' + window.location.search;
}
return true;
}
// support local development with localhost check
if (window.location.hostname !== status.adminFqdn && window.location.hostname !== 'localhost' && !window.location.hostname.startsWith('192.')) {
// user is accessing by IP or by the old admin location (pre-migration)
window.location.href = '/setup.html' + window.location.search;
return true;
}
return false;
}
// this loads the very first thing when accessing via IP or domain
function init() {
Client.getProvisionStatus(function (error, status) {
if (error) return Client.initError(error, init);
if (redirectIfNeeded(status)) return;
if (redirectIfNeeded(status, 'dashboard')) return; // we got redirected...
// check version and force reload if needed
if (!localStorage.version) {
+5 -8
View File
@@ -1,6 +1,6 @@
'use strict';
/* global $, angular, SECRET_PLACEHOLDER, STORAGE_PROVIDERS, BACKUP_FORMATS */
/* global $, angular, SECRET_PLACEHOLDER, STORAGE_PROVIDERS, BACKUP_FORMATS, window, FileReader, document, redirectIfNeeded */
/* global REGIONS_S3, REGIONS_WASABI, REGIONS_DIGITALOCEAN, REGIONS_EXOSCALE, REGIONS_SCALEWAY, REGIONS_LINODE, REGIONS_OVH, REGIONS_IONOS, REGIONS_UPCLOUD, REGIONS_VULTR, REGIONS_CONTABO */
// create main application module
@@ -296,7 +296,7 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
$scope.busy = false;
$scope.error.generic = status.restore.errorMessage;
} else { // restore worked, redirect to admin page
window.location.href = '/';
window.location.href = 'https://' + status.adminFqdn + '/';
}
return;
}
@@ -355,14 +355,11 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
Client.getProvisionStatus(function (error, status) {
if (error) return Client.initError(error, init);
if (redirectIfNeeded(status, 'restore')) return; // redirected to some other view...
if (status.restore.active) return waitForRestore();
if (status.restore.errorMessage) $scope.error.generic = status.restore.errorMessage;
if (status.activated) {
window.location.href = '/';
return;
}
if (status.restore.errorMessage) $scope.error.generic = status.restore.errorMessage; // any previous restore error
Client.getProvisionBlockDevices(function (error, result) {
if (error) {
+17 -13
View File
@@ -1,6 +1,6 @@
'use strict';
/* global $, angular, Clipboard, ENDPOINTS_OVH */
/* global $, angular, Clipboard, ENDPOINTS_OVH, window, FileReader, document, redirectIfNeeded */
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification', 'ui.bootstrap']);
@@ -17,6 +17,7 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f
$scope.clipboardDone = false;
$scope.search = window.location.search;
$scope.setupToken = '';
$scope.taskMinutesActive = null;
$scope.tlsProvider = [
{ name: 'Let\'s Encrypt Prod', value: 'letsencrypt-prod' },
@@ -277,23 +278,25 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f
return;
}
$scope.message = status.setup.message;
if (!error) {
$scope.message = status.setup.message;
$scope.taskMinutesActive = (new Date() - new Date(status.setup.startTime)) / 60000;
}
setTimeout(waitForDnsSetup, 5000);
});
}
function initialize() {
function init() {
Client.getProvisionStatus(function (error, status) {
if (error) {
// During domain migration, the box code restarts and can result in getStatus() failing temporarily
console.error(error);
$scope.state = 'waitingForBox';
return $timeout(initialize, 3000);
}
$scope.state = 'waitingForBox';
if (error) return Client.initError(error, init);
// domain is currently like a lock flag
if (status.adminFqdn) return waitForDnsSetup();
if (redirectIfNeeded(status, 'setup')) return; // redirected to some other view...
if (status.setup.active) return waitForDnsSetup();
$scope.error.setup = status.setup.errorMessage; // show any previous error
if (status.provider === 'digitalocean' || status.provider === 'digitalocean-mp') {
$scope.dnsCredentials.provider = 'digitalocean';
@@ -304,7 +307,8 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f
} else if (status.provider === 'gce') {
$scope.dnsCredentials.provider = 'gcdns';
} else if (status.provider === 'ami') {
$scope.dnsCredentials.provider = 'route53';
// aws marketplace made a policy change that they one cannot provide route53 IAM credentials
$scope.dnsCredentials.provider = 'wildcard';
}
$scope.instanceId = search.instanceId;
@@ -328,5 +332,5 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f
$timeout(function () { $scope.clipboardDone = false; }, 5000);
});
initialize();
init();
}]);
+18 -6
View File
@@ -53,7 +53,9 @@
<body class="setup" ng-app="Application" ng-controller="SetupDNSController">
<div class="main-container ng-cloak text-center" ng-show="state === 'waitingForDnsSetup' || state === 'waitingForBox'">
<a class="offline-banner animateMe" ng-show="client.offline" ng-cloak href="https://docs.cloudron.io/troubleshooting/" target="_blank"><i class="fa fa-circle-notch fa-spin"></i> Cloudron is offline. Reconnecting...</a>
<div class="main-container ng-cloak text-center" ng-show="state === 'waitingForDnsSetup'">
<div class="row">
<div class="col-md-6 col-md-offset-3 text-center">
<i class="fa fa-circle-notch fa-spin fa-5x"></i><br/>
@@ -67,9 +69,14 @@
<br/>
<br/>
<p>
Please wait while Cloudron is setting up the dashboard at my.{{dnsCredentials.domain}}.<br/>
Please wait while Cloudron is setting up the dashboard.<br/>
You can follow the logs on the server at <code class="clipboard hand" data-clipboard-text="/home/yellowtent/platformdata/logs/box.log" uib-tooltip="{{ clipboardDone ? 'Copied' : 'Click to copy' }}" tooltip-placement="right">/home/yellowtent/platformdata/logs/box.log</code>
</p>
<br/>
<br/>
<p ng-show="taskMinutesActive >= 4">
If setup appears stuck, it can be restarted by running <code class="clipboard hand" data-clipboard-text="systemctl restart box" uib-tooltip="{{ clipboardDone ? 'Copied' : 'Click to copy' }}" tooltip-placement="right">systemctl restart box</code> and reloading this page.
</p>
</div>
</div>
</div>
@@ -108,11 +115,16 @@
</div>
<!-- Route53 -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.accessKeyId.$dirty && dnsCredentialsForm.accessKeyId.$invalid }" ng-show="dnsCredentials.provider === 'route53'">
<div ng-if="provider === 'ami'" ng-show="dnsCredentials.provider === 'route53'">
<b class="has-error">This feature is disabled in AWS Marketplace AMI. <a href="https://docs.aws.amazon.com/marketplace/latest/userguide/product-and-ami-policies.html" target="_blank">AWS Marketplace Policy</a> disallows
AMIs from requesting IAM credentials from users to access Route53 hosted domains. Please use the Wildcard or Manual provider instead.</b>
</div>
<div ng-if="provider !== 'ami'" class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.accessKeyId.$dirty && dnsCredentialsForm.accessKeyId.$invalid }" ng-show="dnsCredentials.provider === 'route53'">
<label class="control-label">Access Key Id</label>
<input type="text" class="form-control" ng-model="dnsCredentials.accessKeyId" name="accessKeyId" placeholder="Access Key Id" ng-minlength="16" ng-maxlength="32" ng-required="dnsCredentials.provider === 'route53'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.secretAccessKey.$dirty && dnsCredentialsForm.secretAccessKey.$invalid }" ng-show="dnsCredentials.provider === 'route53'">
<div ng-if="provider !== 'ami'" class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.secretAccessKey.$dirty && dnsCredentialsForm.secretAccessKey.$invalid }" ng-show="dnsCredentials.provider === 'route53'">
<label class="control-label">Secret Access Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.secretAccessKey" name="secretAccessKey" placeholder="Secret Access Key" ng-required="dnsCredentials.provider === 'route53'" ng-disabled="dnsCredentials.busy">
</div>
@@ -176,7 +188,7 @@
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.cloudflareToken.$dirty && dnsCredentialsForm.cloudflareToken.$invalid }" ng-show="dnsCredentials.provider === 'cloudflare'">
<label class="control-label" ng-show="dnsCredentials.cloudflareTokenType === 'GlobalApiKey'">Global API Key</label>
<label class="control-label" ng-show="dnsCredentials.cloudflareTokenType === 'ApiToken'">Api Token</label>
<label class="control-label" ng-show="dnsCredentials.cloudflareTokenType === 'ApiToken'">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.cloudflareToken" name="cloudflareToken" placeholder="API Key/Token" ng-required="dnsCredentials.provider === 'cloudflare'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.cloudflareEmail.$dirty && dnsCredentialsForm.cloudflareEmail.$invalid }" ng-show="dnsCredentials.provider === 'cloudflare' && dnsCredentials.cloudflareTokenType === 'GlobalApiKey'">
@@ -372,7 +384,7 @@
</div>
</div>
<footer class="text-center">
<footer class="text-center" ng-show="state === 'waitingForDnsSetup' || state === 'initialized'">
<span class="text-muted">&copy;2022 <a href="https://cloudron.io" target="_blank">Cloudron</a></span>
<span class="text-muted"><a href="https://forum.cloudron.io" target="_blank">Forum <i class="fa fa-comments"></i></a></span>
</footer>
+5
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);
+84 -31
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."
+147 -50
View File
@@ -22,13 +22,17 @@
"auth": {
"nosso": "Die App verwendet eine eigene Benutzerverwaltung",
"email": "Mit E-Mail-Adresse anmelden",
"sso": "Mit Cloudron Zugangsdaten anmelden"
"sso": "Mit Cloudron Zugangsdaten anmelden",
"openid": "Mit Cloudron OpenID anmelden"
},
"addAppAction": "App hinzufügen",
"addAppproxyAction": "App Proxy hinzufügen",
"addApplinkAction": "App Link hinzufügen",
"filter": {
"clearAll": "Alles löschen"
},
"apps": {
"count": "Appanzahl: {{ count }}"
}
},
"main": {
@@ -51,7 +55,8 @@
},
"action": {
"logs": "Logs",
"reboot": "Neustarten"
"reboot": "Neustarten",
"showLogs": "Zeige Logs"
},
"pagination": {
"perPageSelector": "Zeige {{ n }} pro Seite",
@@ -79,7 +84,8 @@
"justNow": "gerade eben",
"yeserday": "Gestern",
"minutesAgo": "vor {{ m }} Minuten",
"hoursAgo": "vor {{ h }} Stunden"
"hoursAgo": "vor {{ h }} Stunden",
"never": "Nie"
},
"disableAction": "Deaktivieren",
"enableAction": "Aktivieren",
@@ -89,16 +95,18 @@
},
"statusDisabled": "Deaktiviert",
"loadingPlaceholder": "Laden",
"settings": "Einstellungen"
"settings": "Einstellungen",
"saveAction": "Speichern"
},
"network": {
"title": "Netzwerk",
"dyndns": {
"title": "Dynamischer DNS",
"description": "Diese Option aktivieren, um alle DNS-Einträge mit einer sich ändernden IP-Adresse synchron zu halten. Dies ist nützlich, wenn Cloudron in einem Netzwerk mit einer sich häufig ändernden öffentlichen IP-Adresse wie einer Heimverbindung läuft."
"description": "Diese Option aktivieren, um alle DNS-Einträge mit einer sich ändernden IP-Adresse synchron zu halten. Dies ist nützlich, wenn Cloudron in einem Netzwerk mit einer sich häufig ändernden öffentlichen IP-Adresse wie einer Heimverbindung läuft.",
"showLogsAction": "Zeige Logs"
},
"configureIp": {
"title": "IP-Anbieter konfigurieren",
"title": "IPv4-Anbieter konfigurieren",
"providerGenericDescription": "Die öffentliche IP-Adresse des Servers wird automatisch erkannt."
},
"firewall": {
@@ -112,12 +120,12 @@
"blocklist": "{{ blockCount }} IP(s) sind gesperrt"
},
"ip": {
"description": "Cloudron verwendet diese IP-Adresse beim Einrichten von DNS-Einträgen.",
"description": "Cloudron verwendet diese IPv4-Adresse beim Einrichten von DNS A Einträgen.",
"provider": "Anbieter",
"interface": "Name der Netzwerkschnittstelle",
"configure": "Konfigurieren",
"interfaceDescription": "Verfügbare Netzwerkgeräte auf dem Server anzeigen mit:",
"title": "IP-Adresse",
"title": "IPv4",
"detected": "ermittelt",
"address": "IP Adresse"
},
@@ -131,7 +139,13 @@
},
"ipv4": {
"address": "IPv4 Adresse"
}
},
"trustedIps": {
"description": "HTTP header, von übereinstimmenden IP-Adressen, wird vertraut",
"summary": "{{ trustCount }} IPs vertrauen",
"title": "Konfiguriere vertrauenswürdige IPs"
},
"trustedIpRanges": "Vertrauenswürdige IPs & IP-Bereichen "
},
"settings": {
"title": "Einstellungen",
@@ -153,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",
@@ -167,7 +179,7 @@
"description": "Ein Cloudron.io-Konto wird für den Zugriff auf den App-Store und die Verwaltung des Abonnements verwendet.",
"subscriptionSetupAction": "Abonnement einrichten",
"cloudronId": "Cloudron-ID",
"subscriptionChangeAction": "Abonnement ändern",
"subscriptionChangeAction": "Abonnement verwalten",
"setupAction": "Konto einrichten",
"subscription": "Abonnement-Typ",
"subscriptionReactivateAction": "Abonnement reaktivieren",
@@ -216,7 +228,7 @@
"configureAction": "Einrichten",
"syncAction": "Synchronisieren",
"showLogsAction": "Zeige Logs",
"autocreateUsersOnLogin": "Erstelle User automatisch beim Anmelden auf der Cloudron-Instanz",
"autocreateUsersOnLogin": "Erstelle User automatisch beim Anmelden",
"auth": "Authentifizierung",
"groupnameField": "Gruppennamen Feld",
"groupFilter": "Gruppenfilter",
@@ -230,10 +242,11 @@
"provider": "Anbieter",
"noopInfo": "LDAP Authentifizierung ist nicht konfiguriert.",
"subscriptionRequiredAction": "Abonnenement jetzt abschließen",
"description": "Cloudron synchronisiert User und Gruppen aus dem externen LDAP- oder Active-Directory-Server. Passwörter beim Anmelden werden immer durch den externen Server validiert. Die Synchronisierung läuft nicht automatisch, sondern muss manuell gestartet werden.",
"description": "Cloudron synchronisiert User und Gruppen aus dem externen LDAP- oder Active-Directory-Server. Die Synchronisierung läuft automatisch, kann aber auch manuell gestartet werden.",
"title": "Verbinde ein externes Verzeichnis",
"providerOther": "Sonstige",
"providerDisabled": "Deaktiviert"
"providerDisabled": "Deaktiviert",
"disableWarning": "Die Authentifizierungsmethode von allen Usern wird auf die lokale Datenbank zurückgesetzt."
},
"settings": {
"saveAction": "Speichern",
@@ -342,7 +355,9 @@
"username": "Username",
"fullName": "Vollständiger Name",
"fallbackEmailPlaceholder": "Optional. Falls nicht gesetzt wird die Primäre E-Mail benutzt",
"displayNamePlaceholder": "Optional. Kann während der Registrierung gewählt werden"
"displayNamePlaceholder": "Optional. Kann während der Registrierung gewählt werden",
"external2FA": "2FA Einstellungen werden von der externen Authentifikationsmethode verwaltet",
"ldapGroups": "LDAP Gruppen"
},
"addUserDialog": {
"addUserAction": "User hinzufügen",
@@ -386,12 +401,13 @@
},
"description": "Cloudron kann als zentraler Benutzerverzeichnis-Server für externe Anwendungen fungieren.",
"ipRestriction": {
"description": "Der Verzeichnisserver kann auf bestimmte IPs oder Bereiche beschränkt werden.",
"description": "Der Verzeichnisserver muss auf bestimmte IPs oder Bereiche beschränkt werden. Zeilen, die mit <code>#</code> beginnen werden als Kommentare gewertet.",
"label": "Zugriff beschränken",
"placeholder": "Zeilen separierte IP Adresse oder Subnetz"
},
"enabled": "Aktiviert",
"title": "Verzeichnis Server"
"title": "Verzeichnis Server",
"cloudflarePortWarning": "Cloudflare Proxying für die Dashboarddomäne muss deaktiviert sein um den LDAP Server zu erreichen"
},
"invitationNotification": {
"title": "Einladungslink versendet",
@@ -500,7 +516,10 @@
"changeEmail": {
"errorEmailRequired": "Eine gültige E-Mail-Adresse ist erforderlich",
"errorEmailInvalid": "Die E-Mail-Adresse ist nicht gültig",
"title": "Primäre E-Mail-Adresse ändern"
"title": "Primäre E-Mail-Adresse ändern",
"email": "Neue E-Mail-Adresse",
"password": "Passwort zur Bestätigung",
"errorWrongPassword": "Falsches Passwort"
},
"loginTokens": {
"logoutAll": "Von allen abmelden",
@@ -529,14 +548,15 @@
},
"changeBackgroundImage": {
"title": "Hintergrundbild setzen"
}
},
"enable2FANotAvailable": "Für externe User nicht verfügbar"
},
"emails": {
"title": "E-Mail",
"settings": {
"spamFilter": "Spamfilter",
"maxMailSize": "Maximalgröße einer E-Mail",
"location": "Standort des Mail-Servers",
"location": "Domäne des Mail-Servers",
"info": "Die Einstellungen sind global und werden bei allen Domains verwendet.",
"title": "Einstellungen",
"spamFilterOverview": "{{ blacklistCount }} Adressen sind auf der Blockliste.",
@@ -547,7 +567,8 @@
"solrNotRunning": "Inaktiv",
"solrRunning": "Aktiv",
"aclOverview": "{{ dnsblZonesCount }} DNSBL Zonen",
"acl": "Postfachberechtigungen"
"acl": "Postfachberechtigungen",
"virtualAllMail": "\"All Mail\" Ordner"
},
"domains": {
"testEmailTooltip": "Test E-Mail senden",
@@ -596,7 +617,7 @@
},
"changeDomainDialog": {
"locationPlaceholder": "Leer lassen, um die Haupt-Domäne zu verwenden",
"description": "Cloudron nimmt die notwendigen DNS-Änderungen in allen Domänen vor und startet den Mail-Server neu. Desktop & Mobile E-Mail-Clients müssen neu konfiguriert werden, um diese neue Adresse als IMAP- und SMTP-Server zu verwenden.",
"description": "Dies zieht den E-Mail Server auf die neue Domäne um.",
"location": "Adresse",
"title": "E-Mail-Server Standort ändern",
"manualInfo": "Manuell einen A-Eintrag für {{ Domain }} zur öffentlichen IP dieses Cloudrons hinzufügen"
@@ -647,6 +668,10 @@
},
"action": {
"queue": "Warteschlange"
},
"changeVirtualAllMailDialog": {
"description": "Der \"All Mail\" Ordner ist ein einziger Ordner, welcher alle E-Mails des Posteingangs beinhaltet. Dieser Ordner unterstützt mit E-Mail Anwendungen, welche keine rekursive Suche anbieten.",
"title": "\"All Mail\" Ordner"
}
},
"support": {
@@ -669,7 +694,8 @@
"report": "Meldung",
"subscriptionRequiredDescription": "Antworten auf die häufigsten Fragen sind in der <a href=\"{{ supportViewLink }}\" target=\"_blank\">Dokumentation</a> verfügbar. Unser <a href=\"{{ forumLink }}\" target=\"_blank\">Forum</a> bietet einen Platz in die Community einzusteigen und sich auszutauschen.",
"emailVerifyAction": "Jetzt verifizieren",
"emailNotVerified": "Ihre cloudron.io Konto E-Mail {{ email }} ist nicht verifiziert. Bitte bestätigen Sie Ihre E-Mail Adresse, um Support-Tickets zu öffnen."
"emailNotVerified": "Ihre cloudron.io Konto E-Mail {{ email }} ist nicht verifiziert. Bitte bestätigen Sie Ihre E-Mail Adresse, um Support-Tickets zu öffnen.",
"typeBilling": "Problem mit Rechnung"
},
"remoteSupport": {
"title": "Fernwartung",
@@ -678,6 +704,10 @@
"subscriptionRequired": "Fernwartung ist nur im Abo verfügbar.",
"description": "Diese Option aktivieren, um Mitarbeitenden aus dem Support zu erlauben, sich über SSH mit diesem Server zu verbinden.",
"disableAction": "Zugang zur SSH-Unterstützung deaktivieren"
},
"help": {
"description": "Bitte die folgenden Resourcen für Hilfe und Support:\n* [Cloudron Forum]({{ forumLink }}) - Bitte die Support und App spezifischen Kategorien nutzen .\n* [Cloudron Doku & Wissensdatenbank]({{ docsLink }})\n* [Custom App Packaging & API]({{ packagingLink }})\n",
"title": "Hilfe"
}
},
"eventlog": {
@@ -743,12 +773,18 @@
"cloudflareDefaultProxyStatus": "Proxying für neue DNS-Einträge aktivieren",
"porkbunSecretapikey": "Geheimer API-Schlüssel",
"porkbunApikey": "API-Schlüssel",
"bunnyAccessKey": "Bunny Access Key"
"bunnyAccessKey": "Bunny Access Key",
"deSecToken": "deSEC Token",
"dnsimpleAccessToken": "Access Token",
"ovhEndpoint": "Endpoint",
"ovhConsumerKey": "Consumer Key",
"ovhAppKey": "Application Key",
"ovhAppSecret": "Application Secret"
},
"changeDashboardDomain": {
"title": "Die Dashboard-Domäne ändern",
"showLogsAction": "Logfiles anzeigen",
"description": "Dadurch werden das Dashboard und der E-Mail-Server in die Subdomain <code>my</code> der ausgewählten Domäne verschoben.",
"description": "Dadurch wird das Dashboard in die Subdomain <code>my</code> der ausgewählten Domäne verschoben.",
"changeAction": "Domäne ändern",
"cancelAction": "Abbrechen"
},
@@ -776,7 +812,8 @@
"tooltipWellKnown": ".well-known Pfade setzen",
"domainWellKnown": {
"title": ".well-known Pfade von {{ domain }}"
}
},
"count": "Domänenanzahl: {{ count }}"
},
"notifications": {
"title": "Benachrichtigungen",
@@ -807,7 +844,19 @@
"title": "CPU-Auslastung",
"graphSubtext": "Es werden nur Anwendungen angezeigt, die mehr als {{ threshold }} an Rechenleistung benötigen"
},
"selectPeriodLabel": "Zeitraum auswählen"
"selectPeriodLabel": "Zeitraum auswählen",
"info": {
"platformVersion": "Plattform Version",
"title": "Info",
"vendor": "Anbieter",
"product": "Produkt",
"memory": "Arbeitsspeicher",
"uptime": "Betriebszeit",
"activationTime": "Cloudron Aktivierungszeit"
},
"graphs": {
"title": "Graphen"
}
},
"backups": {
"title": "Datensicherung",
@@ -933,7 +982,8 @@
"tooltip": "Dadurch bleiben auch die Mail- und {{ appsLength }} App-Backups erhalten.",
"description": "Backup unabhängig von der Aufbewahrungsrichtlinie beibehalten"
},
"label": "Label"
"label": "Label",
"remotePath": "Remote Pfad"
}
},
"appstore": {
@@ -952,7 +1002,10 @@
"email": "E-Mail",
"description": "Dieses Konto gibt Zugriff zum App-Store und Aboverwaltung",
"titleLogin": "Bei Cloudron.io anmelden",
"titleSignUp": "Bei Cloudron.io registrieren"
"titleSignUp": "Bei Cloudron.io registrieren",
"setupWithTokenAction": "Registrieren",
"setupToken": "Setup Token",
"titleToken": "Mit Setup Token registrieren"
},
"appNotFoundDialog": {
"description": "Die Anwendung <b>{{ appId }}</b> mit der Version <b>{{ version }}</b> existiert nicht.",
@@ -1052,7 +1105,9 @@
"title": "Fußzeile"
},
"logo": "Logo",
"cloudronName": "Name der Cloudron-Instanz"
"cloudronName": "Name der Cloudron-Instanz",
"backgroundImage": "Hintergrundbild der Login-Seite",
"clearBackgroundImage": "Löschen"
},
"login": {
"password": "Passwort",
@@ -1062,7 +1117,9 @@
"loginTo": "Anmeldung bei",
"signInAction": "Anmelden",
"resetPasswordAction": "Passwort zurücksetzen",
"loginWith": "Mit Cloudron anmelden"
"loginWith": "Mit Cloudron anmelden",
"errorIncorrect2FAToken": "2FA Token ist ungültig",
"errorInternal": "Interner Fehler, später nochmals versuchen"
},
"welcomeEmail": {
"welcomeTo": "Willkommen bei <%= cloudronName %>!",
@@ -1171,7 +1228,7 @@
"enableEmailDialog": {
"description": "Dies wird Cloudron so konfigurieren, dass E-Mails für <b>{{ domain }}</b> empfangen werden. Die Dokumentation zum Öffnen der <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">erforderlichen Ports</a> für Cloudron E-Mail lesen.",
"noProviderInfo": "Es ist kein DNS-Anbieter eingerichtet. Die in der Registerkarte Status aufgeführten DNS-Einträge müssen manuell eingerichtet werden.",
"cloudflareInfo": "Die Domäne <code>{{ adminDomain }}</code> wird von Cloudflare verwaltet. Sicherstellen, dass das Cloudflare-Proxying für <code>{{ mailFqdn }}</code> deaktiviert und auf <code>DNS only</code> gesetzt ist. Dies ist erforderlich, da Cloudflare kein E-Mail-Proxying durchführt.",
"cloudflareInfo": "Die E-Mail Domäne <code>{{ adminDomain }}</code> wird von Cloudflare verwaltet. Sicherstellen, dass das Cloudflare-Proxying für <code>{{ mailFqdn }}</code> deaktiviert und auf <code>DNS only</code> gesetzt ist. Dies ist erforderlich, da Cloudflare kein E-Mail-Proxying durchführt.",
"enableAction": "Aktivieren",
"title": "E-Mail für {{ domain }} aktivieren?",
"setupDnsCheckbox": "DNS-Einträge für E-Mail jetzt einrichten",
@@ -1319,7 +1376,8 @@
"renameDialog": {
"newName": "Neuer Name",
"title": "{{ fileName }} umbennen",
"rename": "Umbenennen"
"rename": "Umbenennen",
"reallyOverwrite": "Eine Datei mit diesem Namen existiert bereits. Diese Datei überschreiben?"
},
"extractDialog": {
"title": "Extrahieren von {{ fileName }}",
@@ -1381,7 +1439,19 @@
},
"status": {
"restartingApp": "Die Anwendung wird neugestartet"
}
},
"uploader": {
"uploading": "Hochladen",
"exitWarning": "Aktuell werden noch Dateien hochgeladen. Wirklich schließen?"
},
"textEditor": {
"undo": "Rückgängig",
"redo": "Wiederherstellen",
"save": "Speichern"
},
"extractionInProgress": "Entpacken läuft",
"pasteInProgress": "Einfügen läuft",
"deleteInProgress": "Löschen läuft"
},
"passwordReset": {
"usernameOrEmail": "Username oder E-Mail-Adresse",
@@ -1457,14 +1527,14 @@
"logsActionTooltip": "Logfiles",
"resources": {
"cpu": {
"setAction": "Festlegen",
"title": "CPU-Freigabe",
"description": "Prozent der CPU-Zeit, wenn das System unter hoher Last steht."
"setAction": "Skalieren",
"title": "CPU Limit",
"description": "Maximale CPU Prozente, die dieser App zur Verfügung stehen"
},
"memory": {
"resizeAction": "Größe ändern",
"title": "Speicherlimit",
"description": "Cloudron weist 50% dieses Wertes als RAM und 50% als Swap zu.",
"description": "Maximaler Arbeitsspeicher der dieser App zur Verfügung steht",
"error": "Speicherlimit nicht einstellbar. Weniger versuchen."
}
},
@@ -1531,7 +1601,7 @@
},
"uninstall": {
"backupWarning": "Anwendungs-Backups werden nicht entfernt und auf der Grundlage der Backup-Richtlinie bereinigt. Diese Anwendung kann aus einem bestehenden App-Backup mit den folgenden <a target=\"_blank\" href=\"{{ importBackupDocsLink }}\">Schritten</a> wiederhergestellt werden.",
"description": "Dies wird die Anwendung sofort deinstallieren und alle Daten löschen. Die Anwendung steht anschließend nicht mehr zur Verfügung.",
"description": "Dies wird die Anwendung sofort deinstallieren und alle zugehörigen Daten löschen. Die Anwendung steht anschließend nicht mehr zur Verfügung.",
"title": "Deinstallieren",
"uninstallAction": "Deinstallieren"
}
@@ -1547,12 +1617,12 @@
},
"updates": {
"auto": {
"enableAction": "Automatische Aktualisierungen aktivieren",
"enableAction": "Aktivieren",
"disabled": "Die automatische Aktualisierung ist deaktiviert.",
"enabled": "Die automatische Aktualisierung ist aktiviert.",
"title": "Automatische Aktualisierungen",
"description": "Cloudron fragt regelmäßig den App-Store nach Aktualisierungen ab. Wenn automatisches Aktualisieren deaktiviert ist, bitte sicherstellen, dass manuell nach Aktualisierungen gesucht wird.",
"disableAction": "Automatische Aktualisierungen deaktivieren"
"disableAction": "Deaktivieren"
},
"info": {
"updateAvailableAction": "Aktualisierung verfügbar",
@@ -1563,7 +1633,8 @@
"customAppUpdateInfo": "Aktualiserung steht für benutzerdefinierte Anwendungen nicht zur Verfügung",
"checkForUpdatesAction": "Auf Aktualisierungen überprüfen",
"packageVersion": "Paket-Version",
"repository": "Paket-Repository"
"repository": "Paket-Repository",
"installedAt": "Installationszeitpunkt"
},
"noUpdates": "Keine neuen Updates verfügbar"
},
@@ -1610,7 +1681,8 @@
"dataDirPlaceholder": "Leer lassen, um Systemvorgabe zu verwenden",
"description": "Wenn dem Server der Speicherplatz ausgeht, kann durch Hinzufügen einer <a href=\"/#/volumes\">externen Festplatte</a>, die Daten der Anwendung dorthin verschoben werden.",
"moveAction": "Daten verschieben",
"diskUsage": "Die App verwendet derzeit {{ size }} an Speicherplatz (ab {{ date }})."
"diskUsage": "Die App verwendet derzeit {{ size }} an Speicherplatz (ab {{ date }}).",
"mountTypeWarning": "Das Zieldateisystem muss Dateiberechtigungen und Eigentümerschaft unterstützen, damit die Verschiebung funktioniert"
},
"mounts": {
"title": "Mounts",
@@ -1760,12 +1832,31 @@
},
"addApplinkDialog": {
"title": "Link zur externen Anwendung hinzufügen"
}
},
"redis": {
"disable": "Redis deaktivieren",
"title": "Redis Konfiguration",
"enable": "Die App mit Redis vorkonfigurieren"
},
"infoTabTitle": "Info",
"info": {
"notes": {
"title": "Administrator Notizen"
}
},
"turn": {
"enable": "App für den internen TURN Server konfigurieren",
"disable": "TURN Server dieser App nicht automatisch konfigurieren.",
"title": "TURN Einstellungen"
},
"servicesTabTitle": "Dienste"
},
"logs": {
"download": "Vollständige Logfiles herunterladen",
"title": "Logfiles",
"clear": "Anzeige löschen"
"clear": "Anzeige löschen",
"notFoundError": "Task oder App existiert nicht",
"logsGoneError": "Logdatei(n) nicht gefunden"
},
"lang": {
"en": "Englisch",
@@ -1779,7 +1870,8 @@
"es": "Spanisch",
"ru": "Russisch",
"pt": "Portugiesisch",
"da": "Dänisch"
"da": "Dänisch",
"id": "Indonesian"
},
"volumes": {
"description": "Datenträger sind Verzeichnisse auf dem Server, die von Anwendungen gemeinsam genutzt werden können.",
@@ -1816,7 +1908,11 @@
"mountStatus": "Einhängestatus",
"localDirectory": "Lokales Verzeichnis",
"type": "Typ",
"remountActionTooltip": "Datenträger neu einhängen"
"remountActionTooltip": "Datenträger neu einhängen",
"editVolumeDialog": {
"title": "Datenträger {{ name }} konfigurieren"
},
"editActionTooltip": "Datenträger konfigurieren"
},
"lang.ja": "Japanisch",
"newLoginEmail": {
@@ -1870,5 +1966,6 @@
"newClient": "Neuer Client",
"empty": "Noch keine Clienten erstellt"
}
}
},
"automation": "Automatisierung"
}
+25 -21
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"
},
@@ -1143,7 +1144,8 @@
"copy": "Copy",
"clear": "Clear",
"pasteInfo": "For Paste use Ctrl+v"
}
},
"uploadTo": "Upload to {{ path }}"
},
"filemanager": {
"title": "File Manager",
@@ -1300,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": {
@@ -1592,18 +1594,18 @@
"packageVersion": "Package Version",
"lastUpdated": "Last Updated",
"checkForUpdatesAction": "Check for Updates",
"customAppUpdateInfo": "Updates are not available for custom apps",
"customAppUpdateInfo": "Auto-update is not available for custom apps.",
"updateAvailableAction": "Update Available",
"repository": "Package Repository",
"installedAt": "Installed At"
},
"auto": {
"title": "Automatic Updates",
"description": "Cloudron periodically checks the App Store for updates. If you disable automatic updates, be sure to manually apply the updates.",
"description": "Cloudron periodically checks the <a href=\"{{ appStoreLink }}\" target=\"_blank\">App Store</a> for updates.",
"enabled": "Automatic Updates is currently enabled.",
"disabled": "Automatic Updates is currently disabled.",
"disableAction": "Disable Automatic Updates",
"enableAction": "Enable Automatic Updates"
"disableAction": "Disable Auto-update",
"enableAction": "Enable Auto-update"
},
"noUpdates": "No new updates available"
},
@@ -1626,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",
@@ -1657,7 +1659,7 @@
},
"uninstall": {
"title": "Uninstall",
"description": "This will uninstall the app immediately and remove all its data. The site will be inaccessible.",
"description": "This will uninstall the app immediately and remove the app's data. The site will be inaccessible.",
"backupWarning": "App backups are not removed and will be cleaned up based on the backup policy. You can resurrect this app from an existing app backup using the following <a target=\"_blank\" href=\"{{ importBackupDocsLink }}\">instructions</a>.",
"uninstallAction": "Uninstall"
}
@@ -1671,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 }}",
@@ -1873,7 +1876,8 @@
"es": "Spanish",
"ru": "Russian",
"pt": "Portuguese",
"da": "Danish"
"da": "Danish",
"id": "Indonesian"
},
"volumes": {
"title": "Volumes",
-2
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": {
-2
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": {
-22
View File
@@ -1,22 +0,0 @@
{
"apps": {
"tagsFilterHeaderAll": "Semua Tag",
"adminPageActionTooltip": "Halaman Admin",
"domainsFilterHeader": "Semua Domain",
"groupsFilterHeader": "Semua Grup",
"addAppAction": "Tambah Aplikasi",
"title": "Aplikasi Saya",
"tagsFilterHeader": "Tag: {{ tags }}"
},
"main": {
"dialog": {
"no": "Tidak",
"yes": "Ya",
"delete": "Hapus",
"save": "Simpan"
},
"table": {
"date": "Tanggal"
}
}
}
-2
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": {
+17 -14
View File
@@ -84,7 +84,8 @@
"justNow": "zojuist",
"yeserday": "Gisteren",
"minutesAgo": "{{ m }} minuten geleden",
"hoursAgo": "{{ h }} uur geleden"
"hoursAgo": "{{ h }} uur geleden",
"never": "Nooit"
},
"navbar": {
"users": "Gebruikers"
@@ -657,7 +658,8 @@
"changeLogo": {
"title": "Kies een Cloudron-afbeelding"
},
"backgroundImage": "Inlogpagina achtergrond afbeelding"
"backgroundImage": "Inlogpagina achtergrond afbeelding",
"clearBackgroundImage": "Leegmaken"
},
"emails": {
"title": "E-mail",
@@ -974,7 +976,7 @@
"error": "Kan geheugenlimiet niet instellen, probeer minder."
},
"cpu": {
"setAction": "Vastleggen",
"setAction": "Instellen",
"title": "CPU Limiet",
"description": "Maximum percentage CPU dat een app kan gebruiken"
}
@@ -1036,18 +1038,18 @@
"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"
"installedAt": "Geïnstalleerd op"
},
"auto": {
"title": "Automatische updates",
"enabled": "Automatische updates zijn momenteel ingeschakeld.",
"disabled": "Automatische updates zijn momenteel uitgeschakeld.",
"disableAction": "Automatische updates uitschakelen",
"enableAction": "Automatische updates 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"
},
@@ -1115,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",
@@ -1313,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",
@@ -1490,7 +1491,8 @@
"upload": {
"title": "Uploaden bestand naar {{ name }}"
},
"uploadToTmp": "Upload naar /tmp"
"uploadToTmp": "Upload naar /tmp",
"uploadTo": "Upload naar {{ path }}"
},
"filemanager": {
"title": "Bestandsbeheer",
@@ -1868,7 +1870,8 @@
"es": "Spaans",
"ru": "Russisch",
"pt": "Portugees",
"da": "Deens"
"da": "Deens",
"id": "Indonesisch"
},
"passwordResetEmail": {
"subject": "[<%= cloudron %>] Wachtwoord herstellen",
-156
View File
@@ -1,156 +0,0 @@
{
"apps": {
"title": "As Minhas Aplicações",
"noApps": {
"description": "Que tal instalar algumas? Vê a <a href=\"{{ appStoreLink }}\">Loja de Aplicações</a>",
"title": "Sem aplicações instaladas!"
},
"groupsFilterHeader": "Selecionar Grupo",
"addApplinkAction": "Adicionar Applink",
"noAccess": {
"title": "Não tem acesso a nenhuma aplicação.",
"description": "Assim que tiver, elas vão aparecer aqui."
},
"configActionTooltip": "Configurar",
"logsActionTooltip": "Eventos",
"infoActionTooltip": "Informação",
"adminPageActionTooltip": "Página de Adminstração",
"searchPlaceholder": "Pesquisar Aplicações",
"stateFilterHeader": "Todos os Estados",
"tagsFilterHeader": "Etiquetas: {{ tags }}",
"tagsFilterHeaderAll": "Todas as Etiquetas",
"domainsFilterHeader": "Todos os Domínios",
"auth": {
"sso": "Entrar com as credenciais Cloudron",
"nosso": "Entrar com conta dedicada",
"email": "Entrar com endereço de email"
},
"addAppAction": "Adicionar Aplicação",
"addAppproxyAction": "Adicionar Appproxy",
"filter": {
"clearAll": "Limpar Tudo"
}
},
"main": {
"displayName": "Nome de Apresentação",
"rebootDialog": {
"warning": "Reiniciar o servidor irá causar que todas as aplicações instaladas neste Cloudron fiquem indisponíveis temporariamente!",
"description": "Utilize isto para aplicar atualizações de segurança ou se experienciar comportamento inesperado. Todas as aplicações e serviços em execução neste Cloudron vão iniciar automaticamente quando o reinício estiver completo.",
"title": "Realmente reiniciar o servidor?",
"rebootAction": "Reiniciar agora"
},
"offline": "O Cloudron está offline. A ligar novamente…",
"dialog": {
"cancel": "Cancelar",
"save": "Guardar",
"close": "Fechar",
"no": "Não",
"yes": "Sim"
},
"logout": "Terminar Sessão",
"username": "Nome de Utilizador",
"actions": "Ações",
"table": {
"date": "Data"
},
"pagination": {
"next": "seguinte",
"prev": "anterior",
"perPageSelector": "Mostrar {{ n }} por página"
},
"action": {
"reboot": "Reiniciar",
"logs": "Eventos"
},
"clipboard": {
"copied": "Copiado para a área de transferência",
"clickToCopy": "Clique para copiar",
"clickToCopyBackupId": "Clique para copiar o ID da cópia de segurança"
},
"searchPlaceholder": "Pesquisar",
"multiselect": {
"selected": "{{ n }} selecionados",
"select": "Selecionar",
"filterPlaceholder": "Escreva para filtrar opções"
},
"prettyDate": {
"justNow": "agora mesmo",
"yeserday": "Ontem",
"minutesAgo": "{{ m }} minutos atrás",
"hoursAgo": "{{ h }} horas atrás"
},
"navbar": {
"users": "Utilizadores"
},
"disableAction": "Desativar",
"enableAction": "Ativar",
"statusEnabled": "Ativado",
"statusDisabled": "Desativado"
},
"appstore": {
"category": {
"analytics": "Estatísticas",
"game": "Jogos",
"project": "Gestão de Projetos",
"all": "Tudo",
"popular": "Popular",
"newApps": "Novas Aplicações",
"chat": "Chat",
"blog": "Blog",
"document": "Documentos",
"crm": "CRM",
"forum": "Fórum",
"gallery": "Galeria",
"finance": "Finanças",
"git": "Alojamento de Código",
"email": "Email",
"hosting": "Alojamento Web",
"media": "Multimédia",
"learning": "Aprendizagem",
"notes": "Notas",
"sync": "Sincronização de Ficheiros",
"wiki": "Wiki",
"vpn": "VPN",
"federated": "Federados"
},
"installDialog": {
"lastUpdated": "Última atualização a {{ date }}",
"locationPlaceholder": "Deixe em branco para usar o domínio de raiz",
"userManagementNone": "Esta aplicação tem a sua própria gestão de utilizadores. Esta definição determina se a aplicação está ou não visível no painel do utilizador.",
"memoryRequirement": "Requere pelo menos {{ size }} de memória",
"location": "Localização",
"manualWarning": "Adicione um registo A manualmente para <b>{{ location }}</b> apontando para o endereço IP público deste Cloudron",
"userManagement": "Gestão de utilizadores",
"userManagementMailbox": "Todos os utilizadores com uma caixa de correio neste Cloudron têm acesso.",
"userManagementLeaveToApp": "Deixar a gestão de utilizadores para a aplicação",
"userManagementAllUsers": "Permitir todos os utilizadores deste Cloudron",
"installAnywayAction": "Instalar na mesma",
"doInstallAction": "Instalar {{ dnsOverwrite ? 'e sobrescrever DNS' : '' }}",
"userManagementSelectUsers": "Apenas permitir os seguintes utilizadores e grupos",
"errorUserManagementSelectAtLeastOne": "Selecione pelo menos um utilizador ou grupo",
"users": "Utilizadores",
"groups": "Grupos",
"configuredForCloudronEmail": "Esta aplicação está pré-configurada para ser utilizada com o <a href=\"{{ emailDocsLink }}\" target=\"_blank\">Email do Cloudron</a>.",
"lowOnResources": "Este Cloudron está baixo em recursos.",
"pleaseUpgradeServer": "Por favor, atualize para um servidor com mais memória. Em alternativa, liberte recursos removendo aplicações que não utiliza.",
"subscriptionRequired": "Para instalar mais aplicação, uma subscrição paga é necessária.",
"setupSubscriptionAction": "Configurar Subscrição",
"installAction": "Instalar",
"cloudflarePortWarning": "O proxy do Cloudflare deve estar desativado para o domínio da aplicação para que possa aceder a esta porta",
"titleAndVersion": "Esta aplicação inclui {{ title }} {{ version }}"
},
"title": "Loja de Aplicações",
"searchPlaceholder": "Pesquise por alternativas como Github, Dropbox, Slack, Trello, …",
"noAppsFound": "Nenhuma aplicação encontrada.",
"appMissing": "Falta uma aplicação? Contacte-nos.",
"unstable": "Instável",
"appNotFoundDialog": {
"description": "Não existe nenhuma aplicação <b>{{ appId }}</b> com a versão <b>{{ version }}</b>.",
"title": "Aplicação não encontrada"
},
"accountDialog": {
"titleSignUp": "Registar com Cloudron.io",
"titleLogin": "Entrar com Cloudron.io"
}
}
}
+31 -18
View File
@@ -30,6 +30,9 @@
"addApplinkAction": "Добавить App Link",
"filter": {
"clearAll": "Очистить все"
},
"apps": {
"count": "Всего приложений: {{ count }}"
}
},
"main": {
@@ -49,7 +52,8 @@
"justNow": "только что",
"yeserday": "Вчера",
"minutesAgo": "{{ m }} минут назад",
"hoursAgo": "{{ h }} часов назад"
"hoursAgo": "{{ h }} часов назад",
"never": "Никогда"
},
"logout": "Выйти",
"dialog": {
@@ -535,7 +539,8 @@
"packageVersion": "Версия контейнера",
"lastUpdated": "Обновлен",
"checkForUpdatesAction": "Проверить обновления",
"repository": "Репозиторий"
"repository": "Репозиторий",
"installedAt": "Установлено"
},
"auto": {
"description": "Cloudron периодически проверяет Магазин приложений на наличие обновлений. Если Вы выключаете автоматические обновления, не забывайте применять их вручную.",
@@ -634,12 +639,12 @@
"title": "Лимит памяти",
"error": "Не получилось установить лимит памяти, попробуйте меньшее значение.",
"resizeAction": "Изменить",
"description": "Cloudron выделяет 50% этого значения из оперативной памяти и 50% из swap."
"description": "Максимальное количество ОЗУ, которое может использовать приложение."
},
"cpu": {
"setAction": "Установить",
"title": "Доля CPU",
"description": "Процент времени CPU, когда система находится под нагрузкой."
"setAction": "Масштабировать",
"title": "Лимит CPU",
"description": "Максимальный процент CPU, который может быть задействован в работе приложения"
}
},
"storage": {
@@ -853,6 +858,12 @@
"title": "Настроить Redis",
"enable": "Настроить использование Redis в приложении",
"disable": "Отключить Redis"
},
"infoTabTitle": "Информация",
"info": {
"notes": {
"title": "Заметки администратора"
}
}
},
"backups": {
@@ -971,7 +982,7 @@
},
"check": {
"noop": "Резервное копирование Cloudron выключено. Пожалуйста, убедитесь, что на сервере настроены альтернативные способы резервного копирования. Советуем ознакомиться с информацией по ссылке https://docs.cloudron.io/backups/#storage-providers .",
"sameDisk": "Cloudron сохраняет резервные копии на том же диске, где находится он сам. Это опасно, и может привести к потере данных в случае ошибки диска. Советуем ознакомиться с информацией по ссылке https://docs.cloudron.io/backups/#storage-providers для выбора облачного поставщика."
"sameDisk": "В настоящий момент резервные копии сохраняются на системный диск с установленным Cloudron. Обратите внимание, что при полном заполнении диска бэкапами, Cloudron прекратит свою работу. Также, в случае поломки диска, вы можете полностью потерять доступ к вашим данным и бэкапам. Советуем ознакомиться с документацией https://docs.cloudron.io/backups/#storage-providers для выбора внешнего хранилища бэкапов."
},
"backupEdit": {
"title": "Редактировать резервную копию",
@@ -995,7 +1006,9 @@
},
"changeLogo": {
"title": "Выбрать изображение Cloudron"
}
},
"backgroundImage": "Фоновое изображение экрана входа",
"clearBackgroundImage": "Очистить"
},
"emails": {
"title": "Электронная почта",
@@ -1122,8 +1135,8 @@
},
"network": {
"ip": {
"title": "IP Адрес",
"description": "Cloudron будет использовать данный IP адрес для настройки записей DNS.",
"title": "IPv4",
"description": "Cloudron будет использовать данный IPv4 адрес для настройки A записей DNS.",
"provider": "Источник",
"interface": "Имя сетевого интерфейса",
"configure": "Настроить",
@@ -1148,7 +1161,7 @@
"showLogsAction": "Показать логи"
},
"configureIp": {
"title": "Настроить источник IP",
"title": "Настроить поставщика IPv4",
"providerGenericDescription": "Публичный IP адрес сервера будет обнаружен автоматически."
},
"ipv4": {
@@ -1215,9 +1228,7 @@
"checkForUpdatesAction": "Проверить обновления",
"updateAvailableAction": "Обновление доступно",
"version": "Версия платформы",
"stopUpdateAction": "Остановить обновление",
"autoUpdateDisabled": "Автоматические обновления для платформы и приложений <b>выключены</b>.",
"currentSchedule": "Текущее расписание автоматических обновлений для платформы и приложений"
"stopUpdateAction": "Остановить обновление"
},
"privateDockerRegistry": {
"title": "Частный реестр Docker",
@@ -1419,7 +1430,8 @@
"ovhEndpoint": "Конечная точка",
"ovhConsumerKey": "Ключ пользователя",
"ovhAppKey": "Ключ приложения",
"ovhAppSecret": "Секрет приложения"
"ovhAppSecret": "Секрет приложения",
"deSecToken": "deSEC Токен"
},
"addDomain": "Добавить домен",
"removeDialog": {
@@ -1617,8 +1629,8 @@
"title": "Включить электронную почту для {{ domain }}?",
"setupDnsCheckbox": "Установить почтовые DNS записи",
"enableAction": "Включить",
"description": "Данный параметр настроит Cloudron на получение писем для <b>{{ domain }}</b>. Прости ознакомиться с <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">документацией</a> для открытия необходимых почтовому серверу портов.",
"cloudflareInfo": "Домен <code>{{ adminDomain }}</code> управляется при помощи Cloudflare. Пожалуйста, удостоверьтесь, что проксирование для <code>{{ mailFqdn}}</code> отключено, и активен только режим <code>DNS</code>. Это необходимо, так как Cloudflare не проксирует электронную почту.",
"description": "Данный параметр настроит Cloudron на получение писем для <b>{{ domain }}</b>. Рекомендуем ознакомиться с <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">документацией</a> для открытия необходимых почтовому серверу портов.",
"cloudflareInfo": "Почтовый домен <code>{{ adminDomain }}</code> управляется при помощи Cloudflare. Пожалуйста, удостоверьтесь, что проксирование для <code>{{ mailFqdn}}</code> отключено, и активен режим <code>только DNS</code>. Это необходимо, так как Cloudflare не проксирует электронную почту.",
"setupDnsInfo": "Используйте данную опцию, чтобы автоматически настроить относящиеся к электронной почте записи DNS. Вы можете не отмечать её сразу, чтобы предварительно создать почтовые ящики и <a href=\"{{ importEmailDocsLink }}\">импортировать письма</a>."
},
"backAction": "Вернуться к электронной почте",
@@ -1777,7 +1789,8 @@
"2faToken": "2FA Токен (если включен)",
"errorIncorrectCredentials": "Неправильное имя пользователя или пароль",
"errorIncorrect2FAToken": "Неверный 2FA токен",
"errorInternal": "Внутренняя ошибка, попробуйте позже"
"errorInternal": "Внутренняя ошибка, попробуйте позже",
"loginWith": "Войти с Cloudron"
},
"passwordReset": {
"title": "Сброс пароля",
+117 -55
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": {
-2
View File
@@ -798,8 +798,6 @@
},
"updates": {
"title": "更新",
"autoUpdateDisabled": "平台和应用的自动更新已 <b>停用</b>。",
"currentSchedule": "当前平台和应用的自动更新计划是",
"version": "平台版本",
"showLogsAction": "显示日志",
"changeScheduleAction": "修改计划",
+36 -84
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>
@@ -626,17 +626,17 @@
<p class="text-small text-warning" ng-show="clone.domain.provider === 'noop' || clone.domain.provider === 'manual'" ng-bind-html="'appstore.installDialog.manualWarning' | tr:{ location: ((clone.subdomain ? clone.subdomain + '.' : '') + clone.domain.domain) }"></p>
<div class="has-error text-center" ng-show="clone.error.port">{{ clone.error.port }}</div>
<div ng-repeat="(env, info) in clone.portBindingsInfo">
<div ng-repeat="(env, info) in clone.portInfo">
<ng-form name="portInfo_form">
<div class="form-group" ng-class="{ 'has-error': (!clone.itemName{{$index}}.$dirty && clone.error.port) || (portInfo_form.itemName{{$index}}.$dirty && portInfo_form.itemName{{$index}}.$invalid) }">
<label class="control-label" for="inputPortInfo{{env}}"><input type="checkbox" ng-model="clone.portBindingsEnabled[env]">
<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>
<input type="number" class="form-control" ng-model="clone.portBindings[env]" ng-disabled="!clone.portBindingsEnabled[env]" ng-readonly="info.readOnly" id="inputPortInfo{{env}}" later-name="itemName{{$index}}" min="{{hostPortMin}}" max="{{hostPortMax}}" required>
<input type="number" class="form-control" ng-model="clone.ports[env]" ng-disabled="!clone.portsEnabled[env]" ng-readonly="info.readOnly" id="inputPortInfo{{env}}" later-name="itemName{{$index}}" min="{{hostPortMin}}" max="{{hostPortMax}}" required>
<p class="text-small text-warning text-bold" ng-show="clone.domain.provider === 'cloudflare'">{{ 'appstore.installDialog.cloudflarePortWarning' | tr }} </p>
</div>
</ng-form>
@@ -750,14 +750,14 @@
<div ng-repeat="(key, item) in app.checklist">
<div class="checklist-item" ng-hide="item.acknowledged">
{{ item.message }}
<button class="btn btn-xs btn-default" ng-click="info.checklistAck(item, key)">Done</button>
<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>
@@ -932,18 +932,18 @@
</div>
<div class="has-error text-center" ng-show="location.error.port">{{ location.error.port }}</div>
<div ng-repeat="(env, info) in location.portBindingsInfo">
<div ng-repeat="(env, info) in location.portInfo">
<ng-form name="portInfo_form">
<div class="form-group" ng-class="{ 'has-error': (!portInfo_form.itemName{{$index}}.$dirty && location.error.port) || (portInfo_form.itemName{{$index}}.$dirty && portInfo_form.itemName{{$index}}.$invalid) }">
<label class="control-label" style="width: 100%" for="locationPortInput{{env}}"><input type="checkbox" ng-model="location.portBindingsEnabled[env]">
<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}} ({{ 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">({{ info.portCount }} ports) {{ location.portBindings[env] }} to {{ location.portBindings[env] + info.portCount - 1 }}</span>
<span ng-show="info.portCount" style="display: block; float: right">{{ location.ports[env] }} to {{ location.ports[env] + info.portCount - 1 }} ({{ info.portCount }} ports)</span>
</label>
<input type="number" class="form-control" ng-model="location.portBindings[env]" ng-disabled="!location.portBindingsEnabled[env]" ng-readonly="info.readOnly" id="locationPortInput{{env}}" later-name="itemName{{$index}}" min="{{HOST_PORT_MIN}}" max="{{HOST_PORT_MAX}}" required>
<input type="number" class="form-control" ng-model="location.ports[env]" ng-disabled="!location.portsEnabled[env]" ng-readonly="info.readOnly" id="locationPortInput{{env}}" later-name="itemName{{$index}}" min="{{HOST_PORT_MIN}}" max="{{HOST_PORT_MAX}}" required>
<p class="text-small text-warning text-bold" ng-show="location.domain.provider === 'cloudflare'">{{ 'appstore.installDialog.cloudflarePortWarning' | tr }} </p>
</div>
</ng-form>
@@ -1555,76 +1555,28 @@
</div>
<div class="card" ng-show="view === 'updates'">
<p><label class="control-label">{{ 'app.updates.info.title' | tr }}</label></p>
<div class="row">
<div class="col-xs-4">
<span class="text-muted">{{ 'app.updates.info.description' | tr }}</span>
</div>
<div class="col-xs-8 text-right">
<span ng-show="app.appStoreId">{{ app.manifest.title }} {{ app.upstreamVersion }}</span>
<span ng-show="!app.appStoreId">{{ app.manifest.dockerImage }}</span>
</div>
</div>
<p><label class="control-label">{{ 'app.updatesTabTitle' | tr }}</label></p>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'app.updates.info.appId' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ app.id }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'app.updates.info.packageVersion' | tr }}</span>
</div>
<div class="col-xs-6 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">
<span class="text-muted">{{ 'app.updates.info.lastUpdated' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ app.updateTime | prettyDate }}</span>
<div class="col-md-12">
<p>
<span ng-bind-html="'app.updates.auto.description' | tr:{ appStoreLink: 'https://www.cloudron.io/store/index.html' }"></span>
<span ng-show="app.appStoreId && updates.enableAutomaticUpdate" class="text-success">{{ 'app.updates.auto.enabled' | tr }}</span>
<span ng-show="app.appStoreId && !updates.enableAutomaticUpdate" class="text-danger">{{ 'app.updates.auto.disabled' | tr }}</span>
<span ng-show="!app.appStoreId" class="text-danger">{{ 'app.updates.info.customAppUpdateInfo' | tr }}</span>
</p>
</div>
</div>
<br/>
<div class="row" ng-show="app.appStoreId">
<div class="col-md-6" style="line-height: 34px;">
<span class="text-success" ng-show="!updates.busyCheck && !(config.update[app.id].manifest.version && config.update[app.id].manifest.version !== app.manifest.version)">{{ 'app.updates.noUpdates' | tr }}</span>
</div>
<div class="col-md-6 text-right">
<button type="button" class="btn" ng-class="config.update[app.id].unstable ? 'btn-danger' : 'btn-success'" ng-click="updates.askUpdate()" ng-disabled="app.error || app.runState === 'stopped'" ng-hide="!(config.update[app.id].manifest.version && config.update[app.id].manifest.version !== app.manifest.version && app.installationState !== 'pending_update') || app.taskId" tooltip-enable="app.error || app.taskId || app.runState === 'stopped'" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is not running' }}">{{ 'app.updateDialog.updateAction' | tr }}</button>
<button class="btn btn-default btn-outline" ng-click="updates.check()" ng-disabled="updates.busyCheck"><i class="fas fa-sync-alt fa-spin" ng-show="updates.busyCheck"></i><i class="fas fa-sync-alt" ng-hide="updates.busyCheck"></i> {{ 'settings.updates.checkForUpdatesAction' | tr }}</button>
</div>
</div>
<hr/>
<div class="row" ng-show="!app.appStoreId">
<div class="row">
<div class="col-md-12">
<span class="text-danger pull-right">{{ 'app.updates.info.customAppUpdateInfo' | tr }}</span>
</div>
</div>
<div ng-show="app.appStoreId" class="row">
<div class="col-md-12">
<label class="control-label">{{ 'app.updates.auto.title' | tr }}</label>
<p>{{ 'app.updates.auto.description' | tr }}</p>
</div>
<div class="col-md-6" style="line-height: 34px;">
<span class="text-success" ng-show="app.enableAutomaticUpdate">{{ 'app.updates.auto.enabled' | tr }}</span>
<span class="text-danger" ng-hide="app.enableAutomaticUpdate">{{ 'app.updates.auto.disabled' | tr }}</span>
</div>
<div class="col-md-6">
<button class="btn btn-primary pull-right" uib-tooltip="{{ app.appStoreId ? '' : 'Not available for custom apps' }}" ng-class="{ 'btn-danger': app.enableAutomaticUpdate }" ng-click="updates.toggleAutomaticUpdates()" ng-disabled="updates.busyAutomaticUpdates || !app.appStoreId"><i class="fa fa-circle-notch fa-spin" ng-show="updates.busyAutomaticUpdates"></i> {{ app.enableAutomaticUpdate ? ('app.updates.auto.disableAction' | tr) : ('app.updates.auto.enableAction' | tr) }} </button>
<button class="btn pull-right" uib-tooltip="{{ app.appStoreId ? '' : 'Not available for custom apps' }}" ng-class="updates.enableAutomaticUpdate ? 'btn-danger' : 'btn-success'" ng-click="updates.toggleAutomaticUpdates()" ng-disabled="updates.busyAutomaticUpdates || !app.appStoreId"><i class="fa fa-circle-notch fa-spin" ng-show="updates.busyAutomaticUpdates"></i> {{ updates.enableAutomaticUpdate ? ('app.updates.auto.disableAction' | tr) : ('app.updates.auto.enableAction' | tr) }} </button>
<!-- check for updates button is always visible -->
<button class="btn btn-default btn-outline pull-right" uib-tooltip="{{ app.appStoreId ? '' : 'Not available for custom apps' }}" ng-click="updates.check()" ng-disabled="updates.busyCheck || !app.appStoreId"><i class="fas fa-sync-alt fa-spin" ng-show="updates.busyCheck"></i><i class="fas fa-sync-alt" ng-hide="updates.busyCheck"></i> {{ 'settings.updates.checkForUpdatesAction' | tr }}</button>
<!-- show update button only if update available -->
<button class="btn pull-right" ng-show="config.update[app.id].manifest.version && config.update[app.id].manifest.version !== app.manifest.version && app.installationState !== 'pending_update' && !app.taskId" ng-class="config.update[app.id].unstable ? 'btn-danger' : 'btn-success'" ng-click="updates.askUpdate()" ng-disabled="app.error || app.runState === 'stopped'" tooltip-enable="app.error || app.taskId || app.runState === 'stopped'" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is not running' }}">{{ 'app.updateDialog.updateAction' | tr }}</button>
</div>
</div>
</div>
@@ -1651,7 +1603,7 @@
<td><i class="fas fa-archive" ng-show="backup.preserveSecs === -1" uib-tooltip="{{ 'backups.listing.tooltipPreservedBackup' | tr }}"></i></td>
<!-- <td><div class="hand clipboard" data-clipboard-text="{{ backup.id }}" uib-tooltip="{{ copyBackupIdDone ? ('main.clipboard.copied' | tr) : ('main.clipboard.clickToCopyBackupId' | tr) }}" tooltip-placement="right"><i class="fa fa-copy"></i></div></td> -->
<td ng-click="backupDetails.show(backup)" class="hand"><div>v{{ backup.packageVersion }}</div></td>
<td ng-click="backupDetails.show(backup)" class="hand"><span uib-tooltip="{{ backup.creationTime | prettyLongDate }}">{{ backup.creationTime | prettyDate }} <b ng-show="backup.label">({{ backup.label }})</b></span></td>
<td ng-click="backupDetails.show(backup)" class="hand">{{ backup.creationTime | prettyLongDate }} <b ng-show="backup.label">({{ backup.label }})</b></td>
<td class="text-center" style="vertical-align: bottom">
<div class="dropdown">
<button class="btn btn-xs btn-default dropdown-toggle" type="button" id="dropdownMenu1" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
+46 -37
View File
@@ -1,6 +1,6 @@
'use strict';
/* global angular */
/* global angular, localStorage, document, FileReader */
/* global $ */
/* global async */
/* global RSTATES */
@@ -148,14 +148,14 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
placeholder: 'Add admin notes here...',
edit: function () {
$scope.info.notes.content = $scope.app.notes || $scope.app.manifest.postInstallMessage;
$scope.info.notes.content = $scope.app.notes === null ? $scope.app.manifest.postInstallMessage : $scope.app.notes;
$scope.info.notes.editing = true;
setTimeout(function () { document.getElementById('adminNotesTextarea').focus(); }, 1);
},
dismiss: function () {
$scope.info.notes.content = $scope.app.notes || $scope.app.manifest.postInstallMessage;
$scope.info.notes.content = $scope.app.notes === null ? $scope.app.manifest.postInstallMessage : $scope.app.notes;
$scope.info.notes.editing = false;
},
@@ -175,7 +175,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
refreshApp($scope.app.id, function (error) {
if (error) return Client.error(error);
$scope.info.notes.content = $scope.app.notes || $scope.app.manifest.postInstallMessage;
$scope.info.notes.content = $scope.app.notes === null ? $scope.app.manifest.postInstallMessage : $scope.app.notes;
$scope.info.notes.busySave = false;
$scope.info.notes.editing = false;
});
@@ -185,7 +185,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
show: function () {
$scope.info.hasOldChecklist = !!Object.keys($scope.app.checklist).find((k) => { return $scope.app.checklist[k].acknowledged; });
$scope.info.notes.content = $scope.app.notes || $scope.app.manifest.postInstallMessage;
$scope.info.notes.content = $scope.app.notes === null ? $scope.app.manifest.postInstallMessage : $scope.app.notes;
$scope.info.notes.editing = false;
$scope.info.notes.busy = false;
},
@@ -305,9 +305,9 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
secondaryDomains: {},
redirectDomains: [],
aliasDomains: [],
portBindings: {},
portBindingsEnabled: {},
portBindingsInfo: {},
ports: {},
portsEnabled: {},
portInfo: {},
addRedirectDomain: function (event) {
event.preventDefault();
@@ -369,18 +369,18 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
};
});
$scope.location.portBindingsInfo = angular.extend({}, app.manifest.tcpPorts, app.manifest.udpPorts); // Portbinding map only for information
$scope.location.portInfo = angular.extend({}, app.manifest.tcpPorts, app.manifest.udpPorts); // Portbinding map only for information
$scope.location.redirectDomains = app.redirectDomains.map(function (a) { return { subdomain: a.subdomain, domain: $scope.domains.filter(function (d) { return d.domain === a.domain; })[0] };});
$scope.location.aliasDomains = app.aliasDomains.map(function (a) { return { subdomain: a.subdomain, domain: $scope.domains.filter(function (d) { return d.domain === a.domain; })[0] };});
// fill the portBinding structures. There might be holes in the app.portBindings, which signalizes a disabled port
for (var env in $scope.location.portBindingsInfo) {
for (var env in $scope.location.portInfo) {
if (app.portBindings && app.portBindings[env]) {
$scope.location.portBindings[env] = app.portBindings[env];
$scope.location.portBindingsEnabled[env] = true;
$scope.location.ports[env] = app.portBindings[env].hostPort;
$scope.location.portsEnabled[env] = true;
} else {
$scope.location.portBindings[env] = $scope.location.portBindingsInfo[env].defaultValue || 0;
$scope.location.portBindingsEnabled[env] = false;
$scope.location.ports[env] = $scope.location.portInfo[env].defaultValue || 0;
$scope.location.portsEnabled[env] = false;
}
}
},
@@ -400,11 +400,11 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
};
}
// only use enabled ports from portBindings
var portBindings = {};
for (var env in $scope.location.portBindings) {
if ($scope.location.portBindingsEnabled[env]) {
portBindings[env] = $scope.location.portBindings[env];
// only use enabled ports
var ports = {};
for (var env in $scope.location.ports) {
if ($scope.location.portsEnabled[env]) {
ports[env] = $scope.location.ports[env];
}
}
@@ -412,7 +412,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
overwriteDns: !!overwriteDns,
subdomain: $scope.location.subdomain,
domain: $scope.location.domain.domain,
portBindings: portBindings,
ports: ports,
secondaryDomains: secondaryDomains,
redirectDomains: $scope.location.redirectDomains.map(function (a) { return { subdomain: a.subdomain, domain: a.domain.domain };}),
aliasDomains: $scope.location.aliasDomains.map(function (a) { return { subdomain: a.subdomain, domain: a.domain.domain };})
@@ -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
@@ -1287,21 +1290,27 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
busyUpdate: false,
busyAutomaticUpdates: false,
skipBackup: false,
enableAutomaticUpdate: true,
show: function () {
$scope.updates.skipBackup = false;
$scope.updates.enableAutomaticUpdate = $scope.app.enableAutomaticUpdate;
},
toggleAutomaticUpdates: function () {
$scope.updates.busyAutomaticUpdates = true;
Client.configureApp($scope.app.id, 'automatic_update', { enable: !$scope.app.enableAutomaticUpdate }, function (error) {
Client.configureApp($scope.app.id, 'automatic_update', { enable: !$scope.updates.enableAutomaticUpdate }, function (error) {
if (error) return Client.error(error);
refreshApp($scope.app.id, function (error) {
if (error) console.error(error);
$scope.updates.busyAutomaticUpdates = false;
$timeout(function () {
console.log($scope.updates.enableAutomaticUpdate, $scope.app.enableAutomaticUpdate);
$scope.updates.enableAutomaticUpdate = $scope.app.enableAutomaticUpdate;
$scope.updates.busyAutomaticUpdates = false;
}, 2000);
});
});
},
@@ -1788,9 +1797,9 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
secondaryDomains: {},
needsOverwrite: false,
overwriteDns: false,
portBindings: {},
portBindingsInfo: {},
portBindingsEnabled: {},
ports: {},
portsEnabled: {},
portInfo: {},
show: function (backup) {
var app = $scope.app;
@@ -1812,11 +1821,11 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
};
}
$scope.clone.portBindingsInfo = angular.extend({}, backup.manifest.tcpPorts, backup.manifest.udpPorts); // Portbinding map only for information
$scope.clone.portInfo = angular.extend({}, backup.manifest.tcpPorts, backup.manifest.udpPorts); // Portbinding map only for information
// set default ports
for (var env in $scope.clone.portBindingsInfo) {
$scope.clone.portBindings[env] = $scope.clone.portBindingsInfo[env].defaultValue || 0;
$scope.clone.portBindingsEnabled[env] = true;
for (var env in $scope.clone.portInfo) {
$scope.clone.ports[env] = $scope.clone.portInfo[env].defaultValue || 0;
$scope.clone.portsEnabled[env] = true;
}
$('#appCloneModal').modal('show');
@@ -1833,11 +1842,11 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
};
}
// only use enabled ports from portBindings
var finalPortBindings = {};
for (var env in $scope.clone.portBindings) {
if ($scope.clone.portBindingsEnabled[env]) {
finalPortBindings[env] = $scope.clone.portBindings[env];
// only use enabled ports
var finalPorts = {};
for (var env in $scope.clone.ports) {
if ($scope.clone.portsEnabled[env]) {
finalPorts[env] = $scope.clone.ports[env];
}
}
@@ -1845,7 +1854,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
subdomain: $scope.clone.subdomain,
domain: $scope.clone.domain.domain,
secondaryDomains: secondaryDomains,
portBindings: finalPortBindings,
ports: finalPorts,
backupId: $scope.clone.backup.id,
overwriteDns: $scope.clone.overwriteDns
};
@@ -2247,7 +2256,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
delete backupConfig.backupFolder;
}
} catch (e) {
console.error('Unable to parse backup config');
console.error('Unable to parse backup config', e);
return;
}
+6 -2
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>
@@ -160,7 +164,7 @@
<div class="animateMeOpacity ng-hide" ng-show="installedApps.length > 0">
<div class="app-grid" ng-show="view === VIEWS.GRID">
<div class="grid-item" ng-class="{ 'stopped': app.runState === 'stopped' }" ng-repeat="app in installedApps | selectedGroupAccessFilter:selectedGroup | selectedStateFilter:selectedState | selectedTagFilter:selectedTags | selectedDomainFilter:selectedDomain | appSearchFilter:appSearch | orderBy:orderByFilter">
<div class="grid-item-content" uib-tooltip="{{ app.fqdn }}">
<div class="grid-item-content" uib-tooltip="{{ app.fqdn }}" tooltip-append-to-body="true">
<a ng-show="app.type !== APP_TYPES.LINK && isOperator(app)" ng-href="#/app/{{ app.id}}/info" class="btn btn-lg btn-default grid-item-action"><i class="fas fa-cog"></i></a>
<div ng-show="app.type === APP_TYPES.LINK && isOperator(app)" ng-click="applinksEdit.show(app)" class="btn btn-lg btn-default grid-item-action"><i class="fas fa-cog"></i></div>
<a ng-href="{{ app | applicationLink }}" ng-click="onAppClick(app, $event)" target="_blank">
+1
View File
@@ -4,6 +4,7 @@
/* global $:false */
/* global APP_TYPES */
/* global onAppClick */
/* global localStorage, document, FileReader */
angular.module('Application').controller('AppsController', ['$scope', '$translate', '$interval', '$location', 'Client', function ($scope, $translate, $interval, $location, Client) {
var ALL_DOMAINS_DOMAIN = { _alldomains: true, domain: 'All Domains' }; // dummy record for the single select filter
+5 -5
View File
@@ -69,17 +69,17 @@
</div>
<div class="has-error text-center" ng-show="appInstall.error.port">{{ appInstall.error.port }}</div>
<div ng-repeat="(env, info) in appInstall.portBindingsInfo">
<div ng-repeat="(env, info) in appInstall.portInfo">
<ng-form name="portInfo_form">
<div class="form-group" ng-class="{ 'has-error': (!appInstallForm.itemName{{$index}}.$dirty && appInstall.error.port) || (portInfo_form.itemName{{$index}}.$dirty && portInfo_form.itemName{{$index}}.$invalid) }">
<label class="control-label" for="inputPortInfo{{env}}"><input type="checkbox" ng-model="appInstall.portBindingsEnabled[env]">
<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}} ({{ 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>
<input type="number" class="form-control" ng-model="appInstall.portBindings[env]" ng-disabled="!appInstall.portBindingsEnabled[env]" ng-readonly="info.readOnly" id="inputPortInfo{{env}}" later-name="itemName{{$index}}" min="{{hostPortMin}}" max="{{hostPortMax}}" required>
<input type="number" class="form-control" ng-model="appInstall.ports[env]" ng-disabled="!appInstall.portsEnabled[env]" ng-readonly="info.readOnly" id="inputPortInfo{{env}}" later-name="itemName{{$index}}" min="{{hostPortMin}}" max="{{hostPortMax}}" required>
<p class="text-small text-warning text-bold" ng-show="appInstall.domain.provider === 'cloudflare'">{{ 'appstore.installDialog.cloudflarePortWarning' | tr }} </p>
</div>
</ng-form>
@@ -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>
+16 -15
View File
@@ -1,6 +1,6 @@
'use strict';
/* global angular:false */
/* global angular:false, document, window, localStorage, FileReader */
/* global $:false */
/* global async */
/* global ERROR */
@@ -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;
@@ -122,7 +122,8 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
subdomain: '',
domain: null, // object and not the string
secondaryDomains: {},
portBindings: {},
ports: {},
portsEnabled: {},
mediaLinks: [],
certificateFile: null,
certificateFileName: '',
@@ -147,7 +148,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
$scope.appInstall.subdomain = '';
$scope.appInstall.domain = null;
$scope.appInstall.secondaryDomains = {};
$scope.appInstall.portBindings = {};
$scope.appInstall.ports = {};
$scope.appInstall.state = 'appInfo';
$scope.appInstall.mediaLinks = [];
$scope.appInstall.certificateFile = null;
@@ -218,9 +219,9 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
};
}
$scope.appInstall.portBindingsInfo = angular.extend({}, $scope.appInstall.app.manifest.tcpPorts, $scope.appInstall.app.manifest.udpPorts); // Portbinding map only for information
$scope.appInstall.portBindings = {}; // This is the actual model holding the env:port pair
$scope.appInstall.portBindingsEnabled = {}; // This is the actual model holding the enabled/disabled flag
$scope.appInstall.portInfo = angular.extend({}, $scope.appInstall.app.manifest.tcpPorts, $scope.appInstall.app.manifest.udpPorts); // Portbinding map only for information
$scope.appInstall.ports = {}; // This holds the env:port pair
$scope.appInstall.portsEnabled = {}; // This holds the enabled/disabled flag
var manifest = app.manifest;
$scope.appInstall.optionalSso = !!manifest.optionalSso;
@@ -232,8 +233,8 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
// set default ports
var allPorts = angular.extend({}, $scope.appInstall.app.manifest.tcpPorts, $scope.appInstall.app.manifest.udpPorts);
for (var env in allPorts) {
$scope.appInstall.portBindings[env] = allPorts[env].defaultValue || 0;
$scope.appInstall.portBindingsEnabled[env] = true;
$scope.appInstall.ports[env] = allPorts[env].defaultValue || 0;
$scope.appInstall.portsEnabled[env] = true;
}
$('#appInstallModal').modal('show');
@@ -253,11 +254,11 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
};
}
// only use enabled ports from portBindings
var finalPortBindings = {};
for (var env in $scope.appInstall.portBindings) {
if ($scope.appInstall.portBindingsEnabled[env]) {
finalPortBindings[env] = $scope.appInstall.portBindings[env];
// only use enabled ports from ports
var finalPorts = {};
for (var env in $scope.appInstall.ports) {
if ($scope.appInstall.portsEnabled[env]) {
finalPorts[env] = $scope.appInstall.ports[env];
}
}
@@ -273,7 +274,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
subdomain: $scope.appInstall.subdomain || '',
domain: $scope.appInstall.domain.domain,
secondaryDomains: secondaryDomains,
portBindings: finalPortBindings,
ports: finalPorts,
accessRestriction: finalAccessRestriction,
cert: $scope.appInstall.certificateFile,
key: $scope.appInstall.keyFile,
+7 -7
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>
@@ -605,7 +605,7 @@
<tr ng-repeat="backup in backups">
<td><i class="fas fa-archive" ng-show="backup.preserveSecs === -1" uib-tooltip="{{ 'backups.listing.tooltipPreservedBackup' | tr }}"></i></td>
<td ng-click="backupDetails.show(backup)" class="hand">v{{ backup.packageVersion }}</td>
<td ng-click="backupDetails.show(backup)" class="hand"><span uib-tooltip="{{ backup.creationTime | prettyLongDate }}">{{ backup.creationTime | prettyDate }} <b ng-show="backup.label">({{ backup.label }})</b></span></td>
<td ng-click="backupDetails.show(backup)" class="hand">{{ backup.creationTime | prettyLongDate }} <b ng-show="backup.label">({{ backup.label }})</b></td>
<td ng-click="backupDetails.show(backup)" class="hand">
<span ng-show="!backup.contents.length">{{ 'backups.listing.noApps' | tr }}</span>
<span ng-show="backup.contents.length">{{ 'backups.listing.appCount' | tr:{ appCount: backup.contents.length } }}</span>
+4 -2
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 = [];
@@ -542,7 +544,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
const limits = $scope.backupConfig.limits || {};
$scope.configureBackup.memoryLimit = Math.max(limits.memoryLimit, $scope.MIN_MEMORY_LIMIT);
$scope.configureBackup.memoryLimit = limits.memoryLimit ? Math.max(limits.memoryLimit, $scope.MIN_MEMORY_LIMIT) : $scope.MIN_MEMORY_LIMIT;
$scope.configureBackup.uploadPartSize = limits.uploadPartSize || ($scope.configureBackup.provider === 'scaleway-objectstorage' ? 100 * 1024 * 1024 : 10 * 1024 * 1024);
$scope.configureBackup.downloadConcurrency = limits.downloadConcurrency || ($scope.backupConfig.provider === 's3' ? 30 : 10);
$scope.configureBackup.syncConcurrency = limits.syncConcurrency || ($scope.backupConfig.provider === 's3' ? 20 : 10);
@@ -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) {
+53 -108
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">
@@ -386,7 +343,7 @@
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="https://docs.cloudron.io/email/" target="_blank">{{ 'app.docsAction' | tr }}</a></li>
<li ng-class="{ 'disabled': !domain.mailConfig.enabled }"><a href="" ng-click="howToConnectInfo.show()">{{ 'email.config.clientConfiguration' | tr }}</a></li>
<li ng-class="{ 'disabled': !domain.mailConfig.enabled }"><a href="" ng-click="domain.mailConfig.enabled ? howToConnectInfo.show() : null">{{ 'email.config.clientConfiguration' | tr }}</a></li>
</ul>
</div>
</h3>
@@ -417,18 +374,6 @@
<div class="text-left">
<h3 style="margin-bottom: 15px;">{{ 'email.incoming.mailboxes.title' | tr }}
<button class="btn btn-primary btn-outline pull-right" ng-click="mailboxes.add.show()" ng-disabled="!domain.mailConfig.enabled" tooltip-enable="!domain.mailConfig.enabled" uib-tooltip="{{ 'email.incoming.mailboxes.disabledTooltip' | tr }}"><i class="fa fa-inbox"></i> {{ 'email.incoming.mailboxes.addAction' | tr }}</button>
<div class="btn-group pull-right" style="margin-left: 5px;">
<button class="btn btn-default" ng-click="mailboxImport.show()" uib-tooltip="{{ 'email.incoming.mailboxes.importTooltip' | tr }}" tooltip-append-to-body="true"><i class="fas fa-download"></i></button>
<div class="btn-group" role="group">
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown" uib-tooltip="{{ 'email.incoming.mailboxes.exportTooltip' | tr }}" tooltip-append-to-body="true">
<i class="fas fa-upload"></i>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="" ng-click="mailboxExport('csv')">{{ 'email.incoming.mailboxes.mailboxExport.csv' | tr }}</a></li>
<li><a href="" ng-click="mailboxExport('json')">{{ 'email.incoming.mailboxes.mailboxExport.json' | tr }}</a></li>
</ul>
</div>
</div>
<input class="form-control pull-right" style="width: 200px;" placeholder="{{ 'main.searchPlaceholder' | tr }}" type="text" ng-model="mailboxes.search" ng-model-options="{ debounce: 1000 }" ng-change="mailboxes.updateFilter()" />
</h3>
</div>
-166
View File
@@ -377,172 +377,6 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
}
};
$scope.mailboxImport = {
busy: false,
done: false,
error: null,
percent: 0,
success: 0,
mailboxes: [],
reset: function () {
$scope.mailboxImport.busy = false;
$scope.mailboxImport.error = null;
$scope.mailboxImport.mailboxes = [];
$scope.mailboxImport.percent = 0;
$scope.mailboxImport.success = 0;
$scope.mailboxImport.done = false;
},
handleFileChanged: function () {
$scope.mailboxImport.reset();
var fileInput = document.getElementById('mailboxImportFileInput');
if (!fileInput.files || !fileInput.files[0]) return;
var file = fileInput.files[0];
if (file.type !== 'application/json' && file.type !== 'text/csv') return console.log('Unsupported file type.');
const reader = new FileReader();
reader.addEventListener('load', function () {
$scope.$apply(function () {
$scope.mailboxImport.mailboxes = [];
var mailboxes = [];
if (file.type === 'text/csv') {
var lines = reader.result.split('\n');
if (lines.length === 0) return $scope.mailboxImport.error = { file: 'Imported file has no lines' };
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line) continue;
var items = line.split(',');
if (items.length !== 4) {
$scope.mailboxImport.error = { file: 'Line ' + (i+1) + ' has wrong column count. Expecting 4' };
return;
}
mailboxes.push({
name: items[0].trim(),
domain: items[1].trim(),
owner: items[2].trim(),
ownerType: items[3].trim(),
});
}
} else {
try {
mailboxes = JSON.parse(reader.result).map(function (mailbox) {
return {
name: mailbox.name,
domain: mailbox.domain,
owner: mailbox.owner,
ownerType: mailbox.ownerType
};
});
} catch (e) {
console.error('Failed to parse mailboxes.', e);
$scope.mailboxImport.error = { file: 'Imported file is not valid JSON' };
}
}
$scope.mailboxImport.mailboxes = mailboxes;
});
}, false);
reader.readAsText(file);
},
show: function () {
$scope.mailboxImport.reset();
// named so no duplactes
document.getElementById('mailboxImportFileInput').addEventListener('change', $scope.mailboxImport.handleFileChanged);
$('#mailboxImportModal').modal('show');
},
openFileInput: function () {
$('#mailboxImportFileInput').click();
},
import: function () {
$scope.mailboxImport.percent = 0;
$scope.mailboxImport.success = 0;
$scope.mailboxImport.done = false;
$scope.mailboxImport.error = { import: [] };
$scope.mailboxImport.busy = true;
var processed = 0;
async.eachSeries($scope.mailboxImport.mailboxes, function (mailbox, callback) {
var owner = $scope.owners.find(function (o) { return o.display === mailbox.owner && o.type === mailbox.ownerType; }); // owner may not exist
if (!owner) {
$scope.mailboxImport.error.import.push({ error: new Error('Could not detect owner'), mailbox: mailbox });
++processed;
$scope.mailboxImport.percent = 100 * processed / $scope.mailboxImport.mailboxes.length;
return callback();
}
Client.addMailbox(mailbox.domain, mailbox.name, owner.id, mailbox.ownerType, function (error) {
if (error) $scope.mailboxImport.error.import.push({ error: error, mailbox: mailbox });
else ++$scope.mailboxImport.success;
++processed;
$scope.mailboxImport.percent = 100 * processed / $scope.mailboxImport.mailboxes.length;
callback();
});
}, function (error) {
if (error) return console.error(error);
$scope.mailboxImport.busy = false;
$scope.mailboxImport.done = true;
if ($scope.mailboxImport.success) $scope.mailboxes.refresh();
});
}
};
$scope.mailboxExport = function (type) {
// FIXME only does first 10k mailboxes
Client.listMailboxes($scope.domain.domain, '', 1, 10000, function (error, result) {
if (error) {
Client.error('Failed to list mailboxes. Full error in the webinspector.');
return console.error('Failed to list mailboxes.', error);
}
var content = '';
if (type === 'json') {
content = JSON.stringify(result.map(function (mailbox) {
var owner = $scope.owners.find(function (o) { return o.id === mailbox.ownerId; }); // owner may not exist
return {
name: mailbox.name,
domain: mailbox.domain,
owner: owner ? owner.display : '', // this meta property is set when we get the user list
ownerType: owner ? owner.type : '',
active: mailbox.active,
aliases: mailbox.aliases
};
}), null, 2);
} else if (type === 'csv') {
content = result.map(function (mailbox) {
var owner = $scope.owners.find(function (o) { return o.id === mailbox.ownerId; }); // owner may not exist
var aliases = mailbox.aliases.map(function (a) { return a.name + '@' + a.domain; }).join(' ');
return [ mailbox.name, mailbox.domain, owner ? owner.display : '', owner ? owner.type : '', aliases, mailbox.active ].join(',');
}).join('\n');
} else {
return;
}
var file = new Blob([ content ], { type: type === 'json' ? 'application/json' : 'text/csv' });
var a = document.createElement('a');
a.href = URL.createObjectURL(file);
a.download = $scope.domain.domain.replaceAll('.','_') + '-mailboxes.' + type;
document.body.appendChild(a);
a.click();
});
};
$scope.mailboxes = {
mailboxes: [],
search: '',
+3 -3
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>
+17 -8
View File
@@ -7,7 +7,7 @@
<h4 class="modal-title">{{ 'settings.updateDialog.title' | tr }} <b>{{config.update.box.version}}</b> </h4>
</div>
<div class="modal-body">
<div ng-hide="installedApps | readyToUpdate">
<div ng-hide="installedApps | canUpdate">
<p>{{ 'settings.updateDialog.blockingApps' | tr }}</p>
<ul>
<li ng-repeat="app in installedApps | inProgressApps">{{app.fqdn}}</li>
@@ -17,7 +17,7 @@
<br/>
</div>
<div ng-show="installedApps | readyToUpdate">
<div ng-show="installedApps | canUpdate">
<p class="text-danger" ng-show="config.update.box.unstable">{{ 'settings.updateDialog.unstableWarning' | tr }}</p>
<p>{{ 'settings.updateDialog.changes' | tr }}:</p>
<ul>
@@ -28,12 +28,12 @@
</div>
</div>
<div class="modal-footer">
<label class="checkbox-inline pull-left">
<label ng-show="installedApps | canUpdate" class="checkbox-inline pull-left">
<input type="checkbox" ng-model="update.skipBackup">{{ 'settings.updateDialog.skipBackupCheckbox' | tr }}
</label>
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn" ng-class="config.update.box.unstable ? 'btn-danger' : 'btn-success'" ng-click="update.startUpdate()" ng-disabled="update.busy" ng-show="(installedApps | readyToUpdate)"><i class="fa fa-circle-notch fa-spin" ng-show="update.busy"></i> {{ 'settings.updateDialog.updateAction' | tr }}</button>
<button type="button" class="btn" ng-show="installedApps | canUpdate" ng-class="config.update.box.unstable ? 'btn-danger' : 'btn-success'" ng-click="update.startUpdate()" ng-disabled="update.busy"><i class="fa fa-circle-notch fa-spin" ng-show="update.busy"></i> {{ 'settings.updateDialog.updateAction' | tr }}</button>
</div>
</div>
</div>
@@ -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;">
+10 -62
View File
@@ -18,76 +18,24 @@
</div>
</div>
<!-- <div class="text-left">
<h3>{{ 'support.ticket.title' | tr }}</h3>
<div class="text-left" ng-if="troubleshoot">
<h3>Troubleshoot</h3>
</div>
<div class="card">
<div class="card" ng-if="troubleshoot">
<div class="grid-item-top">
<div class="row" ng-hide="ready">
<h2 class="text-center"><i class="fa fa-circle-notch fa-spin"></i></h2>
</div>
<div class="row" ng-show="ready">
<div class="row">
<div class="col-lg-12">
<div ng-show="subscription && !subscription.emailVerified" style="margin-bottom: 30px;">
<p class="text-bold">
{{ 'support.ticket.emailNotVerified' | tr:{ email: subscription.email } }}
<br/>
<center>
<a ng-href="{{ config.consoleServerOrigin }}" target="_blank" class="btn btn-success">{{ 'support.ticket.emailVerifyAction' | tr }}</a>
</center>
</p>
<p>Troubleshooting tools</p>
<div>
<button class="btn btn-default pull-right" ng-click="repairAll()"><i ng-show="repairAllBusy" class="fa fa-circle-notch fa-spin"></i> Repair All</button>
<button class="btn btn-default pull-right" ng-click="updateAll()"><i ng-show="updateAllBusy" class="fa fa-circle-notch fa-spin"></i> Update All</button>
</div>
<p>Use this form to open support tickets. You can also write directly to <a href="mailto:support@cloudron.io">support@cloudron.io.</p>
<ul>
<li><a href="https://docs.cloudron.io/apps/?support_view" target="_blank">Knowledge Base & App Docs</a></li>
<li><a href="https://docs.cloudron.io/custom-apps/tutorial/?support_view" target="_blank">Custom App Packaging & API</li>
<li><a href="https://forum.cloudron.io/" target="_blank">Forum</a></li>
</ul>
<form name="feedbackForm" ng-submit="submitFeedback()">
<div class="form-group">
<label>{{ 'support.ticket.type' | tr }}</label>
<select class="form-control" name="type" style="width: 50%;" ng-model="feedback.type" required ng-disabled="!subscription.emailVerified">
<option value="app_error">{{ 'support.ticket.typeApp' | tr }}</option>
<option value="ticket">{{ 'support.ticket.typeBug' | tr }}</option>
<option value="billing">{{ 'support.ticket.typeBilling' | tr }}</option>
<option value="email_error">{{ 'support.ticket.typeEmail' | tr }}</option>
</select>
</div>
<div class="form-group" ng-show="feedback.type === 'app_error'">
<label>{{ 'support.ticket.selectApp' | tr }}</label>
<select class="form-control" name="type" style="width: 50%;" ng-model="feedback.appId" ng-required="feedback.type === 'app_error'" ng-disabled="!subscription.emailVerified">
<option ng-repeat="app in apps" value="{{ app.id }}">{{ app.fqdn }}</option>
</select>
</div>
<div class="form-group" ng-class="{ 'has-error': (feedbackForm.subject.$dirty && feedbackForm.subject.$invalid) }">
<label>{{ 'support.ticket.topic' | tr }}</label>
<input type="text" class="form-control" name="subject" ng-model="feedback.subject" ng-maxlength="512" ng-minlength="1" required ng-disabled="!subscription.emailVerified">
</div>
<div class="form-group" ng-class="{ 'has-error': (feedbackForm.description.$dirty && feedbackForm.description.$invalid) }">
<label>{{ 'support.ticket.report' | tr }}</label>
<textarea class="form-control" name="description" rows="3" placeholder="{{ 'support.ticket.reportPlaceholder' | tr }}" ng-model="feedback.description" ng-minlength="1" required ng-disabled="!subscription.emailVerified"></textarea>
</div>
<div class="form-group" ng-class="{ 'has-error': feedbackForm.email.$invalid }">
<label>{{ 'support.ticket.email' | tr }}</label> <small>{{ 'support.ticket.emailInfo' | tr:{ email: subscription.email } }}</small>
<input type="email" class="form-control" name="email" placeholder="{{ 'support.ticket.emailPlaceholder' | tr }}" ng-model="feedback.altEmail" ng-required="feedback.type === 'email_error'" ng-disabled="!subscription.emailVerified">
</div>
<div class="form-group">
<label class="control-label">
<input type="checkbox" ng-model="feedback.enableSshSupport" ng-disabled="!subscription.emailVerified"> {{ 'support.ticket.sshCheckbox' | tr }}
</label>
</div>
<button type="submit" class="btn btn-primary pull-right" ng-disabled="!subscription.emailVerified || feedbackForm.$invalid || feedback.busy"><i class="fa fa-circle-notch fa-spin" ng-show="feedback.busy"></i> {{ 'support.ticket.submitAction' | tr }}</button>
<span ng-show="feedback.error" class="text-danger text-bold">{{feedback.error}}</span>
<span ng-show="feedback.result" class="text-success text-bold">{{feedback.result.message}}</span>
</form>
<p class="text-small text-warning">{{ troubleshootingMessage }}</p>
</div>
</div>
</div>
</div> -->
</div>
<div class="text-left section-header">
<h3>{{ 'support.remoteSupport.title' | tr }}</h3>
+48 -63
View File
@@ -2,6 +2,8 @@
/* global angular:false */
/* global $:false */
/* global ISTATES */
/* global async */
angular.module('Application').controller('SupportController', ['$scope', '$location', 'Client', function ($scope, $location, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastOwner) $location.path('/'); });
@@ -9,69 +11,62 @@ angular.module('Application').controller('SupportController', ['$scope', '$locat
$scope.ready = false;
$scope.config = Client.getConfig();
$scope.user = Client.getUserInfo();
// $scope.apps = Client.getInstalledApps();
// $scope.appsById = {};
// $scope.feedback = {
// error: null,
// result: null,
// busy: false,
// enableSshSupport: false,
// subject: '',
// type: 'app_error',
// description: '',
// appId: '',
// altEmail: ''
// };
$scope.installedApps = Client.getInstalledApps();
$scope.toggleSshSupportError = '';
$scope.sshSupportEnabled = false;
// $scope.subscription = null;
// function resetFeedback() {
// $scope.feedback.enableSshSupport = false;
// $scope.feedback.subject = '';
// $scope.feedback.description = '';
// $scope.feedback.type = 'app_error';
// $scope.feedback.appId = '';
// $scope.feedback.altEmail = '';
$scope.troubleshoot = $location.search().troubleshoot;
// $scope.feedbackForm.$setUntouched();
// $scope.feedbackForm.$setPristine();
// }
$scope.updateAllBusy = false;
$scope.repairAllBusy = false;
$scope.troubleshootingMessage = '';
// $scope.submitFeedback = function () {
// $scope.feedback.busy = true;
// $scope.feedback.result = null;
// $scope.feedback.error = null;
$scope.updateAll = function () {
$scope.updateAllBusy = true;
$scope.troubleshootingMessage = '';
let count = 0, unstable = 0;
// var data = {
// enableSshSupport: $scope.feedback.enableSshSupport,
// subject: $scope.feedback.subject,
// description: $scope.feedback.description,
// type: $scope.feedback.type,
// appId: $scope.feedback.appId,
// altEmail: $scope.feedback.altEmail
// };
Client.checkForUpdates(function (error) {
if (error) Client.error(error);
// Client.createTicket(data, function (error, result) {
// if (error) {
// $scope.feedback.error = error.message;
// } else {
// $scope.feedback.result = result;
// resetFeedback();
// }
async.eachSeries(Object.keys($scope.config.update), function (appId, iteratorDone) {
if ($scope.config.update[appId].unstable) { ++unstable; return iteratorDone(); }
// $scope.feedback.busy = false;
Client.updateApp(appId, $scope.config.update[appId].manifest, { skipBackup: false }, function (error) {
if (error) Client.error(error);
else ++count;
// // refresh state
// Client.getRemoteSupport(function (error, enabled) {
// if (error) return console.error(error);
iteratorDone();
});
}, function () {
$scope.troubleshootingMessage = `${count} apps updated. ${unstable} apps with unstable updates skipped.`;
$scope.updateAllBusy = false;
});
});
};
// $scope.sshSupportEnabled = enabled;
// });
// });
// };
$scope.repairAll = function () {
$scope.repairAllBusy = true;
$scope.troubleshootingMessage = '';
let count = 0;
Client.refreshInstalledApps(function () {
async.eachSeries($scope.installedApps, function (app, iteratorDone) {
if (app.installationState !== ISTATES.ERROR) return iteratorDone();
Client.repairApp(app.id, {}, function (error) {
if (error) Client.error(error);
else ++count;
iteratorDone();
});
}, function () {
$scope.troubleshootingMessage = `${count} apps repaired.`;
$scope.repairAllBusy = false;
});
});
};
$scope.toggleSshSupport = function () {
$scope.toggleSshSupportError = '';
@@ -93,17 +88,7 @@ angular.module('Application').controller('SupportController', ['$scope', '$locat
$scope.sshSupportEnabled = enabled;
// Client.getSubscription(function (error, result) {
// if (error && error.statusCode === 402) return $scope.ready = true; // not yet registered
// if (error && error.statusCode === 412) return $scope.ready = true; // invalid appstore token
// if (error) return console.error(error);
// $scope.subscription = result;
// Client.getInstalledApps().forEach(function (app) { $scope.appsById[app.id] = app; });
$scope.ready = true;
// });
$scope.ready = true;
});
});
-55
View File
@@ -292,48 +292,6 @@
</div>
</div>
<!-- Modal user import -->
<div class="modal fade" id="userImportModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'users.userImportDialog.title' | tr }}</h4>
</div>
<div class="modal-body">
<div ng-show="!userImport.done">
<div ng-show="!userImport.busy">
<p ng-bind-html=" 'users.userImportDialog.description' | tr:{ docsLink: 'https://docs.cloudron.io/user-management/#import-users' } "></p>
<input type="file" style="display: none;" id="userImportFileInput" accept="application/json,text/csv"/>
<button class="btn btn-primary" ng-click="userImport.openFileInput()">{{ 'users.userImportDialog.fileInput' | tr }}</button>
<br/>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="userImport.sendInvite" id="inputUserImportSendInvite"> {{ 'users.userImportDialog.sendInviteCheckbox' | tr }}
</label>
</div>
<p class="text-danger" ng-show="userImport.error.file">{{ userImport.error.file }}</p>
<p class="text-info" ng-show="userImport.users.length">{{ 'users.userImportDialog.usersFound' | tr:{ count: userImport.users.length } }}</p>
</div>
<div ng-show="userImport.busy" class="progress progress-striped active">
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ userImport.percent }}%"></div>
</div>
</div>
<div ng-show="userImport.done">
<p>{{ 'users.userImportDialog.success' | tr:{ count: userImport.success } }}</p>
<div ng-show="userImport.error.import.length">
<p class="text-danger">{{ 'users.userImportDialog.failed' | tr }}</p>
<div ng-repeat="tmp in userImport.error.import"><b>{{ tmp.user.email }}:</b> {{ tmp.error.message }}</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<button type="button" class="btn btn-primary" ng-click="userImport.import()" ng-show="!userImport.done" ng-disabled="userImport.busy || !userImport.users.length"><i class="fa fa-circle-notch fa-spin" ng-show="userImport.busy"></i> {{ 'users.userImportDialog.importAction' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal password reset -->
<div class="modal fade" id="passwordResetModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
@@ -454,19 +412,6 @@
<input type="text" id="userSearchInput" class="form-control" style="max-width: 350px;" ng-model="userSearchString" ng-model-options="{ debounce: 1000 }" ng-change="updateFilter()" placeholder="{{ 'main.searchPlaceholder' | tr }}"/>
<multiselect ng-model="userStateFilter" ms-header="{{ 'apps.stateFilterHeader' | tr }}" ms-selected="{{ userStateFilter }}" options="state.label for state in userStates" data-multiple="false"></multiselect>
<div style="flex-grow: 1;"></div>
<!-- import/export buttons are hidden until we figure what the exact use case is -->
<div class="btn-group" ng-hide="true">
<button class="btn btn-default" ng-click="userImport.show()" uib-tooltip="{{ 'users.userImport.tooltip' | tr }}" tooltip-append-to-body="true"><i class="fas fa-download"></i></button>
<div class="btn-group" role="group">
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown" uib-tooltip="{{ 'users.userExport.tooltip' | tr }}" tooltip-append-to-body="true">
<i class="fas fa-upload"></i>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="" ng-click="userExport('csv')">{{ 'users.userExport.csv' | tr }}</a></li>
<li><a href="" ng-click="userExport('json')">{{ 'users.userExport.json' | tr }}</a></li>
</ul>
</div>
</div>
<button class="btn btn-primary btn-outline" ng-click="userAdd.show()">
<i class="fa fa-user-plus"></i> {{ 'users.newUserAction' | tr }}
</button>
-165
View File
@@ -67,171 +67,6 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
return true;
};
$scope.userImport = {
busy: false,
done: false,
error: null,
percent: 0,
success: 0,
users: [],
sendInvite: false,
reset: function () {
$scope.userImport.busy = false;
$scope.userImport.error = null;
$scope.userImport.users = [];
$scope.userImport.percent = 0;
$scope.userImport.success = 0;
$scope.userImport.done = false;
$scope.userImport.sendInvite = false;
},
handleFileChanged: function () {
$scope.userImport.reset();
var fileInput = document.getElementById('userImportFileInput');
if (!fileInput.files || !fileInput.files[0]) return;
var file = fileInput.files[0];
if (file.type !== 'application/json' && file.type !== 'text/csv') return console.log('Unsupported file type.');
const reader = new FileReader();
reader.addEventListener('load', function () {
$scope.$apply(function () {
$scope.userImport.users = [];
var users = [];
if (file.type === 'text/csv') {
var lines = reader.result.split('\n');
if (lines.length === 0) return $scope.userImport.error = { file: 'Imported file has no lines' };
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line) continue;
var items = line.split(',');
if (items.length !== 5) {
$scope.userImport.error = { file: 'Line ' + (i+1) + ' has wrong column count. Expecting 5' };
return;
}
users.push({
username: items[0].trim(),
email: items[1].trim(),
fallbackEmail: items[2].trim(),
displayName: items[3].trim(),
role: items[4].trim()
});
}
} else {
try {
users = JSON.parse(reader.result).map(function (user) {
return {
username: user.username,
email: user.email,
fallbackEmail: user.fallbackEmail,
displayName: user.displayName,
role: user.role
};
});
} catch (e) {
console.error('Failed to parse users.', e);
$scope.userImport.error = { file: 'Imported file is not valid JSON:' + e.message };
}
}
$scope.userImport.users = users;
});
}, false);
reader.readAsText(file);
},
show: function () {
$scope.userImport.reset();
// named so no duplactes
document.getElementById('userImportFileInput').addEventListener('change', $scope.userImport.handleFileChanged);
$('#userImportModal').modal('show');
},
openFileInput: function () {
$('#userImportFileInput').click();
},
import: function () {
$scope.userImport.percent = 0;
$scope.userImport.success = 0;
$scope.userImport.done = false;
$scope.userImport.error = { import: [] };
$scope.userImport.busy = true;
var processed = 0;
async.eachSeries($scope.userImport.users, function (user, callback) {
Client.addUser(user, function (error, userId) {
if (error) $scope.userImport.error.import.push({ error: error, user: user });
else ++$scope.userImport.success;
++processed;
$scope.userImport.percent = 100 * processed / $scope.userImport.users.length;
if (!error && $scope.userImport.sendInvite) {
console.log('sending', userId, user.email);
Client.sendInviteEmail(userId, user.email, function (error) {
if (error) console.error('Failed to send invite.', error);
});
}
callback();
});
}, function (error) {
if (error) return console.error(error);
$scope.userImport.busy = false;
$scope.userImport.done = true;
if ($scope.userImport.success) {
refreshCurrentPage();
refreshAllUsers();
}
});
}
};
// supported types are 'json' and 'csv'
$scope.userExport = function (type) {
Client.getAllUsers(function (error, result) {
if (error) {
Client.error('Failed to list users. Full error in the webinspector.');
return console.error('Failed to list users.', error);
}
var content = '';
if (type === 'json') {
content = JSON.stringify(result.map(function (user) {
return {
id: user.id,
username: user.username,
email: user.email,
fallbackEmail: user.fallbackEmail,
displayName: user.displayName,
role: user.role,
active: user.active
};
}), null, 2);
} else if (type === 'csv') {
content = result.map(function (user) {
return [ user.id, user.username, user.email, user.fallbackEmail, user.displayName, user.role, user.active ].join(',');
}).join('\n');
} else {
return;
}
var file = new Blob([ content ], { type: type === 'json' ? 'application/json' : 'text/csv' });
var a = document.createElement('a');
a.href = URL.createObjectURL(file);
a.download = type === 'json' ? 'users.json' : 'users.csv';
document.body.appendChild(a);
a.click();
});
};
$scope.userRemove = {
busy: false,
error: null,
+22
View File
@@ -0,0 +1,22 @@
import globals from 'globals';
import js from '@eslint/js';
import pluginVue from 'eslint-plugin-vue';
export default [
js.configs.recommended,
...pluginVue.configs['flat/essential'],
{
files: ["**/*.js"],
languageOptions: {
globals: {
...globals.browser,
},
ecmaVersion: 13,
sourceType: 'module'
},
rules: {
semi: "error",
"prefer-const": "error"
}
}
];
+1442 -486
View File
File diff suppressed because it is too large Load Diff
+14 -12
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.2",
"marked": "^13.0.1",
"filesize": "^10.1.6",
"marked": "^14.1.2",
"moment": "^2.30.1",
"pankow": "^1.6.5",
"pankow-viewers": "^1.0.1",
"superagent": "^9.0.2",
"vue": "^3.4.30",
"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.1"
"@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"
}
}
+34 -19
View File
@@ -11,16 +11,14 @@
<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>
<template #body>
<div v-for="line of logLines" class="log-line">
<span class="time">{{ line.time || '[no timestamp]&nbsp;' }}</span> <span v-html="line.html"></span>
</div>
<div ref="linesContainer"></div>
<div class="bottom-spacer"></div>
</template>
</MainLayout>
@@ -33,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',
@@ -45,7 +43,6 @@ export default {
data() {
return {
accessToken: localStorage.token,
apiOrigin: API_ORIGIN || '',
logsModel: null,
appModel: null,
busyRestart: false,
@@ -61,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();
@@ -115,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();
@@ -135,16 +132,34 @@ export default {
this.downloadUrl = this.logsModel.getDownloadUrl();
const maxLines = 1000;
let lines = 0;
let newLogLines = [];
const tmp = document.getElementsByClassName('pankow-main-layout-body')[0];
setInterval(() => {
newLogLines = newLogLines.slice(-maxLines)
for (let line of newLogLines) {
if (lines < maxLines) ++lines;
else this.$refs.linesContainer.removeChild(this.$refs.linesContainer.firstChild);
const logLine = document.createElement('div');
logLine.className = 'log-line';
logLine.innerHTML = `<span class="time">${line.time || '[no timestamp]&nbsp;' }</span> <span>${line.html}</span>`;
this.$refs.linesContainer.appendChild(logLine);
const autoScroll = tmp.scrollTop > (tmp.scrollHeight - tmp.clientHeight - 34);
if (autoScroll) setTimeout(() => tmp.scrollTop = tmp.scrollHeight, 1);
}
newLogLines = [];
}, 500);
this.logsModel.stream((time, html) => {
this.logLines.push({ time, html});
const tmp = document.getElementsByClassName('pankow-main-layout-body')[0];
if (!tmp) return;
const autoScroll = tmp.scrollTop > (tmp.scrollHeight - tmp.clientHeight - 34);
if (autoScroll) setTimeout(() => tmp.scrollTop = tmp.scrollHeight, 1);
newLogLines.push({ time, html });
}, function (error) {
console.error('Failed to start log stream:', error);
newLogLines.push({ time: error.time, html: error.html });
})
}
};
+15 -18
View File
@@ -24,12 +24,12 @@
<Button success @click="terminalInject('redis')" v-show="usesAddon('redis')" :disabled="!connected">Redis</Button>
<!-- upload/download actions -->
<Button style="margin-left: 20px;" :disabled="!connected" @click="onUpload" icon="fa-solid fa-upload">{{ $t('terminal.uploadToTmp') }}</Button>
<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';
@@ -58,6 +56,7 @@ import { AttachAddon } from '@xterm/addon-attach';
import { FitAddon } from '@xterm/addon-fit';
import { create } from '../models/AppModel.js';
import { createDirectoryModel } from '../models/DirectoryModel.js';
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : window.location.origin;
@@ -74,8 +73,8 @@ export default {
data() {
return {
accessToken: localStorage.token,
apiOrigin: API_ORIGIN || '',
appModel: null,
directoryModel: null,
fatalError: false,
busyRestart: false,
connected: false,
@@ -115,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
});
@@ -126,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
@@ -135,13 +134,10 @@ export default {
});
},
onUpload() {
this.$refs.fileUploader.onUploadFile('/tmp');
this.$refs.fileUploader.onUploadFile('/');
},
async uploadHandler(targetDir, file, progressHandler) {
await superagent.post(`${this.apiOrigin}/api/v1/apps/${this.id}/upload`)
.query({ access_token: this.accessToken, file: `${targetDir}/${file.name}` })
.attach('file', file)
.on('progress', progressHandler);
await this.directoryModel.upload(targetDir, file, progressHandler);
},
usesAddon(addon) {
return !!Object.keys(this.addons).find(function (a) { return a === addon; });
@@ -200,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);
@@ -223,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));
@@ -264,7 +260,8 @@ export default {
this.id = id;
this.name = id;
this.appModel = create(this.apiOrigin, this.accessToken, this.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();
+3 -39
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,44 +23,10 @@ const router = createRouter({
routes,
});
const translations = {};
const i18n = createI18n({
locale: 'en', // set locale
fallbackLocale: 'en', // set fallback locale
messages: translations
});
// https://vue-i18n.intlify.dev/guide/advanced/lazy.html
(async function loadLanguages() {
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
async function loadLanguage(lang) {
try {
const result = await superagent.get(`${API_ORIGIN}/translation/${lang}.json`);
// we do not deliver as application/json :/
translations[lang] = JSON.parse(result.text);
} catch (e) {
console.error(`Failed to load language file for ${lang}`, e);
}
}
await loadLanguage('en');
const locale = window.localStorage.NG_TRANSLATE_LANG_KEY;
if (locale && locale !== 'en') {
await loadLanguage(locale);
if (i18n.mode === 'legacy') {
i18n.global.locale = locale;
} else {
i18n.global.locale.value = locale;
}
}
(async function init() {
const app = createApp(FileManager);
app.use(i18n);
app.use(await i18n());
app.use(router);
app.mount('#app');
+51
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;
+3 -39
View File
@@ -1,52 +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
});
// 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');
})();
+9 -9
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);
}
+78 -34
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;
@@ -71,54 +73,96 @@ export function createDirectoryModel(origin, accessToken, api) {
// file may contain a file name or a file path + file name
const relativefilePath = (file.webkitRelativePath ? file.webkitRelativePath : file.name);
return superagent.post(`${origin}/api/v1/${api}/files/${encodeURIComponent(sanitize(targetDir + '/' + relativefilePath))}`)
.query({ access_token: accessToken })
.attach('file', file)
.on('progress', progressHandler);
const xhr = new XMLHttpRequest();
const req = new Promise(function (resolve, reject) {
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.upload.addEventListener('progress', (event) => {
if (event.loaded) progressHandler({ direction: 'upload', loaded: event.loaded});
});
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(folderPath, fileName) {
await superagent.post(`${origin}/api/v1/${api}/files/${folderPath}`)
.query({ access_token: accessToken })
.attach('file', new File([], fileName));
async newFile(filePath) {
await this.save(filePath, '');
},
async newFolder(folderPath) {
await superagent.post(`${origin}/api/v1/${api}/files/${folderPath}`)
.query({ access_token: accessToken })
.send({ directory: true });
await fetcher.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 });
await fetcher.del(`${origin}/api/v1/${api}/files/${filePath}`, { 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 });
await fetcher.put(`${origin}/api/v1/${api}/files/${fromFilePath}`, { action: 'rename', newFilePath: sanitize(toFilePath), overwrite }, { 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 });
await fetcher.put(`${origin}/api/v1/${api}/files/${fromFilePath}`, { action: 'copy', newFilePath: sanitize(toFilePath) }, { 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 });
await fetcher.put(`${origin}/api/v1/${api}/files/${filePath}`, { action: 'chown', uid: uid, recursive: true }, { access_token: accessToken });
},
async extract(path) {
await superagent.put(`${origin}/api/v1/${api}/files/${path}`)
.send({ action: 'extract' })
.query({ access_token: accessToken });
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');
await superagent.post(`${origin}/api/v1/${api}/files/${filePath}`)
.query({ access_token: accessToken })
.attach('file', file)
.field('overwrite', 'true');
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);
xhr.send(file);
});
await req;
},
async getFile(path) {
let result;
@@ -134,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) {
+12 -49
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);
}
}
};
}
-19
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;
}
}
+3 -39
View File
@@ -1,52 +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
});
// 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');
})();
+125 -144
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('/')}` : '/';
});
}
};
+5 -2
View File
@@ -6,7 +6,7 @@
@close="onClose"
:tr="$t"
/>
<ImageViewer ref="imageViewer" v-show="active === 'imageViewer'" @close="onClose"/>
<ImageViewer ref="imageViewer" v-show="active === 'imageViewer'" @close="onClose" :navigation-handler="imageViewerNavigationHandler"/>
</div>
</template>
@@ -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',
@@ -36,6 +36,9 @@ export default {
onClose() {
location.replace('#/home' + location.hash.slice('#/viewer'.length, location.hash.lastIndexOf('/')+1));
},
imageViewerNavigationHandler() {
// nothing to do
},
async saveHandler(item, content) {
await this.directoryModel.save(this.filePath, content);
}
@@ -0,0 +1,8 @@
'use strict';
exports.up = async function(db) {
await db.runSql('UPDATE apps SET notes="" WHERE notes IS NULL');
};
exports.down = async function(/* db */) {
};
@@ -0,0 +1,9 @@
'use strict';
exports.up = async function(db) {
await db.runSql('UPDATE appPortBindings SET count=1 WHERE count IS NULL');
await db.runSql('ALTER TABLE appPortBindings MODIFY count INTEGER NOT NULL DEFAULT 1');
};
exports.down = async function(/* db */) {
};
+1 -1
View File
@@ -118,7 +118,7 @@ CREATE TABLE IF NOT EXISTS appPortBindings(
type VARCHAR(8) NOT NULL DEFAULT "tcp",
environmentVariable VARCHAR(128) NOT NULL,
appId VARCHAR(128) NOT NULL,
count INTEGER DEFAULT 1,
count INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY(appId) REFERENCES apps(id),
PRIMARY KEY(hostPort));
+164 -107
View File
@@ -13,7 +13,6 @@
"async": "^3.2.5",
"aws-sdk": "^2.1637.0",
"basic-auth": "^2.0.1",
"body-parser": "^1.20.2",
"cloudron-manifestformat": "^5.24.0",
"connect": "^3.7.0",
"connect-lastmile": "^2.2.0",
@@ -38,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",
@@ -47,7 +45,7 @@
"semver": "^7.6.2",
"speakeasy": "^2.0.0",
"superagent": "9.0.1",
"tar-fs": "github:cloudron-io/tar-fs#ignore_stat_error",
"tar-stream": "^3.1.7",
"tldjs": "^2.3.1",
"ua-parser-js": "^1.0.38",
"underscore": "^1.13.6",
@@ -69,7 +67,7 @@
"hock": "^1.4.1",
"js2xmlparser": "^5.0.0",
"mocha": "^10.4.0",
"mock-aws-s3": "git+https://github.com/cloudron-io/mock-aws-s3.git",
"mock-aws-s3": "github:cloudron-io/mock-aws-s3#0ad36e5ba",
"nock": "^13.5.4",
"ssh2": "^1.15.0",
"yesno": "^0.4.0"
@@ -708,6 +706,11 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/b4a": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.6.tgz",
"integrity": "sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg=="
},
"node_modules/backoff": {
"version": "2.5.0",
"license": "MIT",
@@ -722,6 +725,12 @@
"version": "1.0.0",
"license": "MIT"
},
"node_modules/bare-events": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.4.2.tgz",
"integrity": "sha512-qMKFd2qG/36aA4GwvKq8MxnPgCQAmBWmSyLWsJcbn8v03wvIPQ/hG1Ms8bPzndZxMDoHpxez5VOS+gC9Yi24/Q==",
"optional": true
},
"node_modules/base32.js": {
"version": "0.0.1",
"license": "MIT"
@@ -763,6 +772,52 @@
"node": ">=8"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/bl/node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/bl/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/bluebird": {
"version": "3.7.2",
"license": "MIT"
@@ -1668,6 +1723,19 @@
"node": ">= 8.0"
}
},
"node_modules/dockerode/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/dockerode/node_modules/tar-fs": {
"version": "2.0.1",
"license": "MIT",
@@ -1678,6 +1746,21 @@
"tar-stream": "^2.0.0"
}
},
"node_modules/dockerode/node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/dotenv": {
"version": "5.0.1",
"license": "BSD-2-Clause",
@@ -2236,6 +2319,11 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
},
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -2458,37 +2546,30 @@
},
"node_modules/fs-constants": {
"version": "1.0.0",
"license": "MIT"
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="
},
"node_modules/fs-extra": {
"version": "0.6.4",
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz",
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"dependencies": {
"jsonfile": "~1.0.1",
"mkdirp": "0.3.x",
"ncp": "~0.4.2",
"rimraf": "~2.2.0"
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/fs-extra/node_modules/mkdirp": {
"version": "0.3.5",
"node_modules/fs-extra/node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"dev": true,
"license": "MIT"
},
"node_modules/fs-extra/node_modules/ncp": {
"version": "0.4.2",
"dev": true,
"license": "MIT",
"bin": {
"ncp": "bin/ncp"
}
},
"node_modules/fs-extra/node_modules/rimraf": {
"version": "2.2.8",
"dev": true,
"license": "MIT",
"bin": {
"rimraf": "bin.js"
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/fs.realpath": {
@@ -2654,6 +2735,12 @@
"url": "https://github.com/sindresorhus/got?sponsor=1"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true
},
"node_modules/gtoken": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz",
@@ -3253,8 +3340,13 @@
"license": "ISC"
},
"node_modules/jsonfile": {
"version": "1.0.1",
"dev": true
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"dev": true,
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
@@ -3828,21 +3920,23 @@
}
},
"node_modules/mock-aws-s3": {
"version": "2.6.0",
"resolved": "git+ssh://git@github.com/cloudron-io/mock-aws-s3.git#1306f1722b82897382a2339d52a94ded15003d8c",
"version": "4.0.2",
"resolved": "git+ssh://git@github.com/cloudron-io/mock-aws-s3.git#0ad36e5bae8d821921779012dd9f1a70397daca3",
"dev": true,
"dependencies": {
"fs-extra": "0.6.4",
"underscore": "1.8.3"
"bluebird": "^3.5.1",
"fs-extra": "^7.0.1",
"underscore": "1.12.1"
},
"engines": {
"node": ">= 0.8.0"
"node": ">=10.0.0"
}
},
"node_modules/mock-aws-s3/node_modules/underscore": {
"version": "1.8.3",
"dev": true,
"license": "MIT"
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz",
"integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==",
"dev": true
},
"node_modules/moment": {
"version": "2.30.1",
@@ -4120,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",
@@ -4558,6 +4647,11 @@
}
]
},
"node_modules/queue-tick": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz",
"integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag=="
},
"node_modules/quick-lru": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-7.0.0.tgz",
@@ -5082,6 +5176,19 @@
"node": ">=0.8.0"
}
},
"node_modules/streamx": {
"version": "2.18.0",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.18.0.tgz",
"integrity": "sha512-LLUC1TWdjVdn1weXGcSxyTR3T4+acB6tVGXT95y0nGbca4t4o/ng1wKAGTljm9VicuCVLvRlqFYXYy5GwgM7sQ==",
"dependencies": {
"fast-fifo": "^1.3.2",
"queue-tick": "^1.0.1",
"text-decoder": "^1.1.0"
},
"optionalDependencies": {
"bare-events": "^2.2.0"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"license": "MIT",
@@ -5170,72 +5277,14 @@
"version": "3.2.4",
"license": "MIT"
},
"node_modules/tar-fs": {
"version": "2.0.0",
"resolved": "git+ssh://git@github.com/cloudron-io/tar-fs.git#08e18e67201e352697251fe98c816c9d2afddd38",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp": "^0.5.1",
"pump": "^3.0.0",
"tar-stream": "^2.0.0"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"license": "MIT",
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz",
"integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tar-stream/node_modules/bl": {
"version": "4.0.3",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/tar-stream/node_modules/buffer": {
"version": "5.7.1",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/tar-stream/node_modules/readable-stream": {
"version": "3.6.0",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
"b4a": "^1.6.4",
"fast-fifo": "^1.2.0",
"streamx": "^2.15.0"
}
},
"node_modules/teeny-request": {
@@ -5276,6 +5325,14 @@
"node": ">= 6"
}
},
"node_modules/text-decoder": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.1.0.tgz",
"integrity": "sha512-TmLJNj6UgX8xcUZo4UDStGQtDiTzF7BzWlzn9g7UWrjkpHr5uJTK1ld16wZ3LXb2vb6jH8qU89dW5whuMdXYdw==",
"dependencies": {
"b4a": "^1.6.4"
}
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+2 -4
View File
@@ -21,7 +21,6 @@
"async": "^3.2.5",
"aws-sdk": "^2.1637.0",
"basic-auth": "^2.0.1",
"body-parser": "^1.20.2",
"cloudron-manifestformat": "^5.24.0",
"connect": "^3.7.0",
"connect-lastmile": "^2.2.0",
@@ -46,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",
@@ -55,7 +53,7 @@
"semver": "^7.6.2",
"speakeasy": "^2.0.0",
"superagent": "9.0.1",
"tar-fs": "github:cloudron-io/tar-fs#ignore_stat_error",
"tar-stream": "^3.1.7",
"tldjs": "^2.3.1",
"ua-parser-js": "^1.0.38",
"underscore": "^1.13.6",
@@ -73,7 +71,7 @@
"hock": "^1.4.1",
"js2xmlparser": "^5.0.0",
"mocha": "^10.4.0",
"mock-aws-s3": "git+https://github.com/cloudron-io/mock-aws-s3.git",
"mock-aws-s3": "github:cloudron-io/mock-aws-s3#0ad36e5ba",
"nock": "^13.5.4",
"ssh2": "^1.15.0",
"yesno": "^0.4.0"
+134 -20
View File
@@ -19,15 +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
--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() {
@@ -125,6 +128,19 @@ function check_box() {
success "box v${version} is running"
}
function check_netplan() {
if ! output=$(netplan get all 2>/dev/null); then
fail "netplan is not working"
exit 1
fi
if [[ -z "${output}" ]]; then
warn "netplan configuration is empty. this might be OK depending on your networking setup"
else
success "netplan is good"
fi
}
function owner_login() {
check_host_mysql >/dev/null
@@ -201,6 +217,34 @@ function send_diagnostics() {
echo -e "\nPlease email the following link to support@cloudron.io : ${PASTEBIN}/${paste_key}"
}
function check_dns() {
if host cloudron.io &>/dev/null; then
success "DNS is resolving via systemd-resolved"
return
fi
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() {
if ! systemctl is-active -q unbound; then
info "unbound is down. updating root anchor to see if it fixes it"
@@ -218,9 +262,9 @@ function check_unbound() {
exit 1
fi
test_resolve=$(dig cloudron.io @127.0.0.1 +short)
if [[ -z "test_resolve" ]]; then
fail "DNS is not resolving, maybe try forwarding all DNS requests using the --use-external-dns option"
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 --unbound-use-external-dns' option"
host cloudron.io 127.0.0.150
exit 1
fi
@@ -242,6 +286,8 @@ function check_dashboard_cert() {
echo -e "\tCommon issues include expiry of domain's API key OR incoming http port 80 not being open"
exit 1
fi
success "dashboard cert is valid"
}
function check_nginx() {
@@ -295,6 +341,8 @@ function check_dashboard_site_loopback() {
fail "Could not load dashboard website with loopback check"
exit 1
fi
success "dashboard is reachable via loopback"
}
function check_node() {
@@ -313,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 ..."
@@ -381,13 +439,13 @@ function check_expired_domain() {
if ! command -v whois &> /dev/null; then
info "Domain ${dashboard_domain} expiry check skipped because whois is not installed. Run 'apt install whois' to check"
exit 0
return
fi
local -r expdate=$(whois ${dashboard_domain} | egrep -i 'Expiration Date:|Expires on|Expiry Date:' | head -1 | awk '{print $NF}')
if [[ -z "${expdate}" ]]; then
warn "Domain ${dashboard_domain} expiry check skipped because whois does not have this information"
exit 0
return
fi
local -r expdate_secs=$(date -d"$expdate" +%s)
@@ -401,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'"
@@ -433,18 +491,40 @@ EOF
success "DNSSEC Disabled"
}
function print_system() {
vendor=$(cat /sys/devices/virtual/dmi/id/sys_vendor)
product=$(cat /sys/devices/virtual/dmi/id/product_name)
echo "Vendor: ${vendor} Product: ${product}"
ubuntu_codename=$(lsb_release -cs)
ubuntu_version=$(lsb_release -rs)
linux_version=$(uname -r)
echo "Linux: ${linux_version}"
echo "Ubuntu: ${ubuntu_codename} ${ubuntu_version}"
proc_count=$(grep -c ^processor /proc/cpuinfo)
proc_name=$(lscpu | grep "Model name:" | sed -r 's/Model name:\s{1,}//g')
echo "Processor: ${proc_name} x ${proc_count}"
ram_kb=$(grep MemTotal /proc/meminfo | awk '{print $2}')
echo "RAM: ${ram_kb}KB"
disk_size=$(LC_ALL=C df -h --output=source,avail / | tail -n1)
echo "Disk: ${disk_size}"
}
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
check_dashboard_cert
check_dashboard_site_loopback # checks website via loopback
check_box
check_unbound
check_dashboard_cert
check_netplan
check_dns
check_dashboard_site_domain # check website via domain name
check_expired_domain
check_unbound # this is less fatal after 8.0
}
function check_disk_space() {
@@ -522,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"
@@ -547,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
@@ -574,9 +655,41 @@ function recreate_docker() {
rm "${stagefile}"
}
function apply_patch() {
commit_id="$1"
patch_file="/tmp/${commit_id}.patch"
# gitlab will return 404 if it looks like a valid commit id but doesn't exist. it returns login page with invalid commit id
if ! curl -s "https://git.cloudron.io/cloudron/box/-/commit/${commit_id}.patch" -D /tmp/headers -o "${patch_file}"; then
echo "Could not connect to git"
exit 1
fi
if ! grep -q "content-type: text/plain" /tmp/headers; then
echo "Not a valid commit"
exit 1
fi
echo "This will apply ${commit_id} (${patch_file}) from git and restart the box code."
warn "Do not proceed unless you know what you are doing."
read -p "Do you want to apply the patch ? (y/N) " -n 1 -r choice
echo -e "\n"
[[ ! $choice =~ ^[Yy]$ ]] && exit 1
if ! patch --force --dry-run -d /home/yellowtent/box -p1 -i "${patch_file}"; then
echo "Patch does not apply cleanly"
exit 1
fi
patch -d /home/yellowtent/box -p1 -i "${patch_file}"
systemctl restart box
echo "Patch applied"
}
check_disk_space
args=$(getopt -o "" -l "admin-login,disable-dnssec,enable-ssh,enable-remote-access,help,owner-login,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
@@ -592,9 +705,10 @@ 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;;
--help) break;;
--) break;;
*) echo "Unknown option $1"; exit 1;;
+5
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
@@ -199,6 +200,10 @@ systemctl disable postfix || true
# on vultr, ufw is enabled by default. we have our own firewall
ufw disable || true
# nfs-common depends on rpcbind which is only needed for NFS v2/v3 . systemctl list-sockets | grep 111
systemctl disable rpcbind.socket rpcbind.service || true
systemctl stop rpcbind.socket rpcbind.service || true
# Ubuntu 22 has private home directories by default (https://discourse.ubuntu.com/t/private-home-directories-for-ubuntu-21-04-onwards/)
sed -e 's/^HOME_MODE\([[:space:]]\+\).*$/HOME_MODE\10755/' -i /etc/login.defs
+14 -10
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
@@ -132,6 +141,10 @@ systemctl enable --now cron
# ensure unbound runs
systemctl restart unbound
# nfs-common depends on rpcbind which is only needed for NFS v2/v3 . can be removed after 8.0.1 . systemctl list-sockets | grep 111
systemctl disable rpcbind.socket rpcbind.service || true
systemctl stop rpcbind.socket rpcbind.service || true
log "Configuring sudoers"
rm -f /etc/sudoers.d/${USER} /etc/sudoers.d/cloudron
cp "${script_dir}/start/sudoers" /etc/sudoers.d/cloudron
@@ -142,15 +155,6 @@ ln -sfF "${PLATFORM_DATA_DIR}/collectd" /etc/collectd
cp "${script_dir}/start/collectd/collectd.conf" "${PLATFORM_DATA_DIR}/collectd/collectd.conf"
systemctl restart collectd
log "Configuring sysctl"
# If privacy extensions are not disabled on server, this breaks IPv6 detection
# https://bugs.launchpad.net/ubuntu/+source/procps/+bug/1068756
if [[ ! -f /etc/sysctl.d/99-cloudimg-ipv6.conf ]]; then
echo "==> Disable temporary address (IPv6)"
echo -e "# See https://bugs.launchpad.net/ubuntu/+source/procps/+bug/1068756\nnet.ipv6.conf.all.use_tempaddr = 0\nnet.ipv6.conf.default.use_tempaddr = 0\n\n" > /etc/sysctl.d/99-cloudimg-ipv6.conf
sysctl -p
fi
log "Configuring logrotate"
if ! grep -q "^include ${PLATFORM_DATA_DIR}/logrotate.d" /etc/logrotate.conf; then
echo -e "\ninclude ${PLATFORM_DATA_DIR}/logrotate.d\n" >> /etc/logrotate.conf
+1
View File
@@ -14,6 +14,7 @@ Restart=always
ExecStart=/home/yellowtent/box/box.js
ExecReload=/bin/kill -HUP $MAINPID
; we run commands like df which will parse properly only with correct locale
; add "oidc-provider:*" to DEBUG for OpenID debugging
Environment="HOME=/home/yellowtent" "USER=yellowtent" "DEBUG=box:*,connect-lastmile,-box:ldap" "BOX_ENV=cloudron" "NODE_ENV=production" "LC_ALL=C" "AWS_SDK_JS_SUPPRESS_MAINTENANCE_MODE_MESSAGE=1"
; kill apptask processes as well
KillMode=control-group
+1
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
+6
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
+70 -86
View File
@@ -12,7 +12,6 @@ exports = module.exports = {
update,
setHealth,
del,
delPortBinding,
get,
getByIpAddress,
@@ -135,11 +134,10 @@ exports = module.exports = {
// exported for testing
_checkForPortBindingConflict: checkForPortBindingConflict,
_validatePortBindings: validatePortBindings,
_validatePorts: validatePorts,
_validateAccessRestriction: validateAccessRestriction,
_validateUpstreamUri: validateUpstreamUri,
_validateLocations: validateLocations,
_translatePortBindings: translatePortBindings,
_parseCrontab: parseCrontab,
_clear: clear
};
@@ -192,13 +190,14 @@ const APPS_FIELDS_PREFIXED = [ 'apps.id', 'apps.appStoreId', 'apps.installationS
'apps.enableMailbox', 'apps.mailboxDisplayName', 'apps.mailboxName', 'apps.mailboxDomain', 'apps.enableInbox', 'apps.inboxName', 'apps.inboxDomain',
'apps.enableTurn', 'apps.enableRedis', 'apps.storageVolumeId', 'apps.storageVolumePrefix', 'apps.ts', 'apps.healthTime', '(apps.icon IS NOT NULL) AS hasIcon', '(apps.appStoreIcon IS NOT NULL) AS hasAppStoreIcon' ].join(',');
// const PORT_BINDINGS_FIELDS = [ 'hostPort', 'type', 'environmentVariable', 'appId' ].join(',');
// const PORT_BINDINGS_FIELDS = [ 'hostPort', 'type', 'environmentVariable', 'appId', 'count' ].join(',');
const LOCATION_FIELDS = [ 'appId', 'subdomain', 'domain', 'type', 'certificateJson' ];
const CHECKVOLUME_CMD = path.join(__dirname, 'scripts/checkvolume.sh');
function validatePortBindings(portBindings, manifest) {
assert.strictEqual(typeof portBindings, 'object');
// ports is a map of envvar -> hostPort
function validatePorts(ports, manifest) {
assert.strictEqual(typeof ports, 'object');
assert.strictEqual(typeof manifest, 'object');
// keep the public ports in sync with firewall rules in setup/start/cloudron-firewall.sh
@@ -237,45 +236,48 @@ function validatePortBindings(portBindings, manifest) {
853 // dns over tls
];
if (!portBindings) return null;
if (!ports) return null;
const tcpPorts = manifest.tcpPorts || {};
const udpPorts = manifest.udpPorts || {};
for (const portName in portBindings) {
if (!/^[A-Z0-9_]+$/.test(portName)) return new BoxError(BoxError.BAD_FIELD, `${portName} is not a valid environment variable in portBindings`);
for (const portName in ports) {
if (!/^[A-Z0-9_]+$/.test(portName)) return new BoxError(BoxError.BAD_FIELD, `${portName} is not a valid environment variable in ports`);
const hostPort = portBindings[portName];
if (!Number.isInteger(hostPort)) return new BoxError(BoxError.BAD_FIELD, `${hostPort} is not an integer in ${portName} portBindings`);
if (RESERVED_PORTS.indexOf(hostPort) !== -1) return new BoxError(BoxError.BAD_FIELD, `Port ${hostPort} for ${portName} is reserved in portBindings`);
if (RESERVED_PORT_RANGES.find(range => (hostPort >= range[0] && hostPort <= range[1]))) return new BoxError(BoxError.BAD_FIELD, `Port ${hostPort} for ${portName} is reserved in portBindings`);
if (ALLOWED_PORTS.indexOf(hostPort) === -1 && (hostPort <= 1023 || hostPort > 65535)) return new BoxError(BoxError.BAD_FIELD, `${hostPort} for ${portName} is not in permitted range in portBindings`);
const hostPort = ports[portName];
if (!Number.isInteger(hostPort)) return new BoxError(BoxError.BAD_FIELD, `${hostPort} is not an integer in ${portName} ports`);
if (RESERVED_PORTS.indexOf(hostPort) !== -1) return new BoxError(BoxError.BAD_FIELD, `Port ${hostPort} for ${portName} is reserved in ports`);
if (RESERVED_PORT_RANGES.find(range => (hostPort >= range[0] && hostPort <= range[1]))) return new BoxError(BoxError.BAD_FIELD, `Port ${hostPort} for ${portName} is reserved in ports`);
if (ALLOWED_PORTS.indexOf(hostPort) === -1 && (hostPort <= 1023 || hostPort > 65535)) return new BoxError(BoxError.BAD_FIELD, `${hostPort} for ${portName} is not in permitted range in ports`);
// it is OK if there is no 1-1 mapping between values in manifest.tcpPorts and portBindings. missing values implies the service is disabled
// it is OK if there is no 1-1 mapping between values in manifest.tcpPorts and ports. missing values implies the service is disabled
const portSpec = tcpPorts[portName] || udpPorts[portName];
if (!portSpec) return new BoxError(BoxError.BAD_FIELD, `Invalid portBinding ${portName}`);
if (portSpec.readOnly && portSpec.defaultValue !== hostPort) return new BoxError(BoxError.BAD_FIELD, `portBinding ${portName} is readOnly and cannot have a different value that the default`);
if ((hostPort + (portSpec.portCount || 1)) > 65535) return new BoxError(BoxError.BAD_FIELD, `${hostPort}+${portSpec.portCount} for ${portName} exceeds valid port range`);
}
return null;
}
function translatePortBindings(portBindings, manifest) {
assert.strictEqual(typeof portBindings, 'object');
// translates the REST API ports (envvar -> hostPort) to database portBindings (envvar -> { hostPort, count, type })
function translateToPortBindings(ports, manifest) {
assert.strictEqual(typeof ports, 'object');
assert.strictEqual(typeof manifest, 'object');
if (!portBindings) return null;
const portBindings = {};
const result = {};
const tcpPorts = manifest.tcpPorts || { };
if (!ports) return portBindings;
for (const portName in portBindings) {
const tcpPorts = manifest.tcpPorts || {};
for (const portName in ports) {
const portType = portName in tcpPorts ? exports.PORT_TYPE_TCP : exports.PORT_TYPE_UDP;
const portCount = portBindings[portName].portCount || (portName in tcpPorts ? manifest.tcpPorts[portName].portCount : manifest.udpPorts[portName].portCount);
result[portName] = { hostPort: portBindings[portName], type: portType, portCount: portCount || 1 };
const portCount = portName in tcpPorts ? tcpPorts[portName].portCount : manifest.udpPorts[portName].portCount; // since count is optional, this can be undefined
portBindings[portName] = { hostPort: ports[portName], type: portType, count: portCount || 1 };
}
return result;
return portBindings;
}
function validateSecondaryDomains(secondaryDomains, manifest) {
@@ -544,9 +546,8 @@ function getDuplicateErrorDetails(errorMessage, locations, portBindings) {
}
}
// check if any of the port bindings conflict
for (const portName in portBindings) {
if (portBindings[portName] === parseInt(match[1])) return new BoxError(BoxError.ALREADY_EXISTS, `Port ${match[1]} is in use`);
if (portBindings[portName].hostPort === parseInt(match[1])) return new BoxError(BoxError.ALREADY_EXISTS, `Port ${match[1]} is in use`);
}
if (match[2] === 'apps_storageVolume') {
@@ -648,17 +649,19 @@ function postProcess(result) {
assert(result.hostPorts === null || typeof result.hostPorts === 'string');
assert(result.environmentVariables === null || typeof result.environmentVariables === 'string');
result.portBindings = { };
result.portBindings = {};
const hostPorts = result.hostPorts === null ? [ ] : result.hostPorts.split(',');
const environmentVariables = result.environmentVariables === null ? [ ] : result.environmentVariables.split(',');
const portTypes = result.portTypes === null ? [ ] : result.portTypes.split(',');
const portCounts = result.portCounts === null ? [ ] : result.portCounts.split(',');
delete result.hostPorts;
delete result.environmentVariables;
delete result.portTypes;
delete result.portCounts;
for (let i = 0; i < environmentVariables.length; i++) {
result.portBindings[environmentVariables[i]] = { hostPort: parseInt(hostPorts[i], 10), type: portTypes[i] };
result.portBindings[environmentVariables[i]] = { hostPort: parseInt(hostPorts[i], 10), type: portTypes[i], count: parseInt(portCounts[i], 10) };
}
assert(result.accessRestrictionJson === null || typeof result.accessRestrictionJson === 'string');
@@ -739,15 +742,11 @@ function postProcess(result) {
result.taskId = result.taskId ? String(result.taskId) : null;
}
// attaches computed properties
function attachProperties(app, domainObjectMap) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof domainObjectMap, 'object');
const result = {};
for (const portName in app.portBindings) {
result[portName] = app.portBindings[portName].hostPort;
}
app.portBindings = result;
app.iconUrl = app.hasIcon || app.hasAppStoreIcon ? `/api/v1/apps/${app.id}/icon` : null;
app.fqdn = dns.fqdn(app.subdomain, app.domain);
app.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
@@ -793,36 +792,36 @@ function accessLevel(app, user) {
return canAccess(app, user) ? 'user' : null;
}
async function checkForPortBindingConflict(portBindings, id = '') {
async function checkForPortBindingConflict(portBindings, options) {
assert.strictEqual(typeof portBindings, 'object');
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof options, 'object');
let existingPortBindings;
if (id) existingPortBindings = await database.query('SELECT * FROM appPortBindings WHERE appId != ?', [ id ]);
else existingPortBindings = await database.query('SELECT * FROM appPortBindings', []);
const existingPortBindings = options.appId
? await database.query('SELECT * FROM appPortBindings WHERE appId != ?', [ options.appId ])
: await database.query('SELECT * FROM appPortBindings', []);
if (existingPortBindings.length === 0) return;
const tcpPorts = existingPortBindings.filter((p) => p.type === 'tcp');
const udpPorts = existingPortBindings.filter((p) => p.type === 'udp');
const tcpPortBindings = existingPortBindings.filter((p) => p.type === 'tcp');
const udpPortBindings = existingPortBindings.filter((p) => p.type === 'udp');
for (const portName in portBindings) {
const p = portBindings[portName];
const testPorts = p.type === 'tcp' ? tcpPorts : udpPorts;
const portBinding = portBindings[portName];
const existingPortBinding = portBinding.type === 'tcp' ? tcpPortBindings : udpPortBindings;
const found = testPorts.find((e) => {
const found = existingPortBinding.find((epb) => {
// if one is true we dont have a conflict
// a1 <----> a2 b1 <-------> b2
// b1 <------> b2 a1 <-----> a2
const a2 = (e.hostPort + e.count - 1);
const b1 = p.hostPort;
const b2 = (p.hostPort + p.portCount -1);
const a1 = e.hostPort;
const a2 = (epb.hostPort + epb.count - 1);
const b1 = portBinding.hostPort;
const b2 = (portBinding.hostPort + portBinding.count - 1);
const a1 = epb.hostPort;
return !((a2 < b1) || (b2 < a1));
});
if (found) throw new BoxError(BoxError.CONFLICT, `Conflicting port ${p.hostPort}`);
if (found) throw new BoxError(BoxError.CONFLICT, `Conflicting ${portBinding.type} port ${portBinding.hostPort}`);
}
}
@@ -833,11 +832,9 @@ async function add(id, appStoreId, manifest, subdomain, domain, portBindings, da
assert.strictEqual(typeof manifest.version, 'string');
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof portBindings, 'object');
assert(portBindings && typeof portBindings === 'object');
assert(data && typeof data === 'object');
portBindings = portBindings || { };
const manifestJson = JSON.stringify(manifest),
accessRestriction = data.accessRestriction || null,
accessRestrictionJson = JSON.stringify(accessRestriction),
@@ -862,7 +859,7 @@ async function add(id, appStoreId, manifest, subdomain, domain, portBindings, da
enableRedis = 'enableRedis' in data ? data.enableRedis : true,
icon = data.icon || null;
await checkForPortBindingConflict(portBindings);
await checkForPortBindingConflict(portBindings, { appId: null });
const queries = [];
@@ -884,7 +881,7 @@ async function add(id, appStoreId, manifest, subdomain, domain, portBindings, da
Object.keys(portBindings).forEach(function (env) {
queries.push({
query: 'INSERT INTO appPortBindings (environmentVariable, hostPort, type, appId, count) VALUES (?, ?, ?, ?, ?)',
args: [ env, portBindings[env].hostPort, portBindings[env].type, id, portBindings[env].portCount ]
args: [ env, portBindings[env].hostPort, portBindings[env].type, id, portBindings[env].count ]
});
});
@@ -949,18 +946,17 @@ async function updateWithConstraints(id, app, constraints) {
assert(!('checklist' in app) || typeof app.checklist === 'object');
assert(!('env' in app) || typeof app.env === 'object');
const queries = [ ];
if ('portBindings' in app) {
const portBindings = app.portBindings || { };
const portBindings = app.portBindings;
await checkForPortBindingConflict(portBindings, id);
await checkForPortBindingConflict(portBindings, { appId: id });
// replace entries by app id
queries.push({ query: 'DELETE FROM appPortBindings WHERE appId = ?', args: [ id ] });
Object.keys(portBindings).forEach(function (env) {
const values = [ portBindings[env].hostPort, portBindings[env].type, env, id, portBindings[env].portCount ];
const values = [ portBindings[env].hostPort, portBindings[env].type, env, id, portBindings[env].count ];
queries.push({ query: 'INSERT INTO appPortBindings (hostPort, type, environmentVariable, appId, count) VALUES(?, ?, ?, ?, ?)', args: values });
});
}
@@ -1075,14 +1071,6 @@ async function del(id) {
if (results[5].affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'App not found');
}
async function delPortBinding(hostPort, type) {
assert.strictEqual(typeof hostPort, 'number');
assert.strictEqual(typeof type, 'string');
const result = await database.query('DELETE FROM appPortBindings WHERE hostPort=? AND type=?', [ hostPort, type ]);
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'App not found');
}
async function clear() {
await database.query('DELETE FROM locations');
await database.query('DELETE FROM appPortBindings');
@@ -1092,11 +1080,11 @@ async function clear() {
}
// each query simply join apps table with another table by id. we then join the full result together
const PB_QUERY = 'SELECT id, GROUP_CONCAT(CAST(appPortBindings.hostPort AS CHAR(6))) AS hostPorts, GROUP_CONCAT(appPortBindings.environmentVariable) AS environmentVariables, GROUP_CONCAT(appPortBindings.type) AS portTypes FROM apps LEFT JOIN appPortBindings ON apps.id = appPortBindings.appId GROUP BY apps.id';
const PB_QUERY = 'SELECT id, GROUP_CONCAT(CAST(appPortBindings.hostPort AS CHAR(6))) AS hostPorts, GROUP_CONCAT(appPortBindings.environmentVariable) AS environmentVariables, GROUP_CONCAT(appPortBindings.type) AS portTypes, GROUP_CONCAT(CAST(appPortBindings.count AS CHAR(6))) AS portCounts FROM apps LEFT JOIN appPortBindings ON apps.id = appPortBindings.appId GROUP BY apps.id';
const ENV_QUERY = 'SELECT id, JSON_ARRAYAGG(appEnvVars.name) AS envNames, JSON_ARRAYAGG(appEnvVars.value) AS envValues FROM apps LEFT JOIN appEnvVars ON apps.id = appEnvVars.appId GROUP BY apps.id';
const SUBDOMAIN_QUERY = 'SELECT id, JSON_ARRAYAGG(locations.subdomain) AS subdomains, JSON_ARRAYAGG(locations.domain) AS domains, JSON_ARRAYAGG(locations.type) AS subdomainTypes, JSON_ARRAYAGG(locations.environmentVariable) AS subdomainEnvironmentVariables, JSON_ARRAYAGG(locations.certificateJson) AS subdomainCertificateJsons FROM apps LEFT JOIN locations ON apps.id = locations.appId GROUP BY apps.id';
const MOUNTS_QUERY = 'SELECT id, JSON_ARRAYAGG(appMounts.volumeId) AS volumeIds, JSON_ARRAYAGG(appMounts.readOnly) AS volumeReadOnlys FROM apps LEFT JOIN appMounts ON apps.id = appMounts.appId GROUP BY apps.id';
const APPS_QUERY = `SELECT ${APPS_FIELDS_PREFIXED}, hostPorts, environmentVariables, portTypes, envNames, envValues, subdomains, domains, subdomainTypes, subdomainEnvironmentVariables, subdomainCertificateJsons, volumeIds, volumeReadOnlys FROM apps`
const APPS_QUERY = `SELECT ${APPS_FIELDS_PREFIXED}, hostPorts, environmentVariables, portTypes, portCounts, envNames, envValues, subdomains, domains, subdomainTypes, subdomainEnvironmentVariables, subdomainCertificateJsons, volumeIds, volumeReadOnlys FROM apps`
+ ` LEFT JOIN (${PB_QUERY}) AS q1 on q1.id = apps.id`
+ ` LEFT JOIN (${ENV_QUERY}) AS q2 on q2.id = apps.id`
+ ` LEFT JOIN (${SUBDOMAIN_QUERY}) AS q3 on q3.id = apps.id`
@@ -1350,9 +1338,9 @@ async function install(data, auditSource) {
error = await checkManifest(manifest);
if (error) throw error;
error = validatePortBindings(data.portBindings || null, manifest);
error = validatePorts(data.ports || null, manifest);
if (error) throw error;
const portBindings = translatePortBindings(data.portBindings || null, manifest);
const portBindings = translateToPortBindings(data.ports || null, manifest);
error = validateAccessRestriction(accessRestriction);
if (error) throw error;
@@ -1914,16 +1902,16 @@ async function setLocation(app, data, auditSource) {
subdomain: data.subdomain.toLowerCase(),
domain: data.domain.toLowerCase(),
// these are intentionally reset, if not set
portBindings: null,
portBindings: {},
secondaryDomains: [],
redirectDomains: [],
aliasDomains: []
};
if ('portBindings' in data) {
error = validatePortBindings(data.portBindings || null, app.manifest);
if ('ports' in data) {
error = validatePorts(data.ports || null, app.manifest);
if (error) throw error;
values.portBindings = translatePortBindings(data.portBindings || null, app.manifest);
values.portBindings = translateToPortBindings(data.ports || null, app.manifest);
}
// rename the auto-created mailbox to match the new location
@@ -1962,7 +1950,7 @@ async function setLocation(app, data, auditSource) {
};
const [taskError, taskId] = await safe(addTask(appId, exports.ISTATE_PENDING_LOCATION_CHANGE, task, auditSource));
if (taskError && taskError.reason !== BoxError.ALREADY_EXISTS) throw taskError;
if (taskError && taskError.reason === BoxError.ALREADY_EXISTS) throw getDuplicateErrorDetails(taskError.message, locations, data.portBindings);
if (taskError && taskError.reason === BoxError.ALREADY_EXISTS) throw getDuplicateErrorDetails(taskError.message, locations, values.portBindings);
values.fqdn = dns.fqdn(values.subdomain, values.domain);
values.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
@@ -2007,11 +1995,7 @@ async function updateApp(app, data, auditSource) {
assert(data.manifest && typeof data.manifest === 'object');
assert.strictEqual(typeof auditSource, 'object');
const skipBackup = !!data.skipBackup,
appId = app.id,
manifest = data.manifest,
appStoreId = data.appStoreId;
const skipBackup = !!data.skipBackup, appId = app.id, manifest = data.manifest;
const values = {};
if (app.runState === exports.RSTATE_STOPPED) throw new BoxError(BoxError.BAD_STATE, 'Stopped apps cannot be updated');
@@ -2025,9 +2009,8 @@ async function updateApp(app, data, auditSource) {
error = await checkManifest(manifest);
if (error) throw error;
// TODO check if checklist needs to be adjusted either here or in the apptask
const updateConfig = { skipBackup, manifest, appStoreId }; // this will clear appStoreId when updating from a repo and set it if passed in for update route
const updateConfig = { skipBackup, manifest }; // this will clear appStoreId when updating from a repo and set it if passed in for update route
if ('appStoreId' in data) updateConfig.appStoreId = data.appStoreId;
// prevent user from installing a app with different manifest id over an existing app
// this allows cloudron install -f --app <appid> for an app installed from the appStore
@@ -2125,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'
@@ -2369,9 +2352,9 @@ async function clone(app, data, user, auditSource) {
error = await checkManifest(manifest);
if (error) throw error;
error = validatePortBindings(data.portBindings || null, manifest);
error = validatePorts(data.ports || null, manifest);
if (error) throw error;
const portBindings = translatePortBindings(data.portBindings || null, manifest);
const portBindings = translateToPortBindings(data.ports || null, manifest);
// should we copy the original app's mailbox settings instead?
const mailboxName = manifest.addons?.sendmail ? mailboxNameForSubdomain(subdomain, manifest) : null;
@@ -2645,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}`);
}
}
+43 -23
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 });
@@ -313,7 +314,7 @@ async function install(app, args, progressCallback) {
await deleteAppDir(app, { removeDirectory: false }); // do not remove any symlinked appdata dir
}
if (oldManifest && oldManifest.dockerImage === app.manifest.dockerImage) {
if (oldManifest && oldManifest.dockerImage !== app.manifest.dockerImage) {
await docker.deleteImage(oldManifest);
}
@@ -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);
@@ -633,23 +634,42 @@ async function update(app, args, progressCallback) {
await services.teardownAddons(app, unusedAddons);
if (Object.keys(unusedAddons).includes('localstorage')) await updateApp(app, { storageVolumeId: null, storageVolumePrefix: null }); // lose reference
// free unused ports
const currentPorts = app.portBindings || {};
// free unused ports. this is done after backup, so the app object is not in some inconsistent state should backup fail
const newTcpPorts = updateConfig.manifest.tcpPorts || {};
const newUdpPorts = updateConfig.manifest.udpPorts || {};
const portBindings = { ...app.portBindings };
for (const portName of Object.keys(currentPorts)) {
for (const portName of Object.keys(portBindings)) {
if (newTcpPorts[portName] || newUdpPorts[portName]) continue; // port still in use
const [error] = await safe(apps.delPortBinding(currentPorts[portName], apps.PORT_TYPE_TCP));
if (error && error.reason === BoxError.NOT_FOUND) debug('update: portbinding does not exist in database: %o', error);
else if (error) throw error;
// also delete from app object for further processing (the db is updated in the next step)
delete app.portBindings[portName];
delete portBindings[portName];
}
await updateApp(app, _.pick(updateConfig, 'manifest', 'appStoreId', 'memoryLimit')); // switch over to the new config
// clear aliasDomains if needed based multiDomain change
const aliasDomains = app.manifest.multiDomain && !updateConfig.manifest.multiDomain ? [] : app.aliasDomains;
// clear unused secondaryDomains
const secondaryDomains = [ ...app.secondaryDomains ];
const newHttpPorts = updateConfig.manifest.httpPorts || {};
for (let i = secondaryDomains.length-1; i >= 0; i--) {
const { environmentVariable } = secondaryDomains[i];
if (environmentVariable in newHttpPorts) continue; // domain still in use
secondaryDomains.splice(i, 1); // remove domain
}
const values = {
manifest: updateConfig.manifest,
portBindings,
// all domains have to be updated together
subdomain: app.subdomain,
domain: app.domain,
aliasDomains,
secondaryDomains,
redirectDomains: app.redirectDomains
};
if ('memoryLimit' in updateConfig) values.memoryLimit = updateConfig.memoryLimit;
if ('appStoreId' in updateConfig) values.appStoreId = updateConfig.appStoreId;
await updateApp(app, values); // switch over to the new config
await progressCallback({ percent: 45, message: 'Downloading icon' });
await downloadIcon(app);
+2 -2
View File
@@ -15,8 +15,8 @@ const assert = require('assert'),
scheduler = require('./scheduler.js'),
tasks = require('./tasks.js');
let gActiveTasks = { }; // indexed by app id
let gPendingTasks = [ ];
const gActiveTasks = {}; // indexed by app id
const gPendingTasks = [];
let gInitialized = false;
const TASK_CONCURRENCY = 3;
+8 -5
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}`);
+56 -43
View File
@@ -15,16 +15,16 @@ const assert = require('assert'),
DataLayout = require('../datalayout.js'),
{ DecryptStream } = require('../hush.js'),
debug = require('debug')('box:backupformat/rsync'),
{ EncryptStream } = require('../hush.js'),
fs = require('fs'),
hush = require('../hush.js'),
once = require('../once.js'),
path = require('path'),
ProgressStream = require('../progress-stream.js'),
promiseRetry = require('../promise-retry.js'),
safe = require('safetydance'),
shell = require('../shell.js'),
storage = require('../storage.js'),
stream = require('stream'),
stream = require('stream/promises'),
syncer = require('../syncer.js'),
util = require('util');
@@ -38,19 +38,56 @@ function getBackupFilePath(backupConfig, remotePath) {
return path.join(backupConfig.rootPath, remotePath);
}
function sync(backupConfig, remotePath, dataLayout, progressCallback, callback) {
async function addFile(sourceFile, encryption, uploader, progressCallback) {
assert.strictEqual(typeof sourceFile, 'string');
assert.strictEqual(typeof encryption, 'object');
assert.strictEqual(typeof uploader, 'object');
assert.strictEqual(typeof progressCallback, 'function');
// make sure file can be opened for reading before we start the pipeline. otherwise, we end up with
// destinations dirs/file which are owned by root (this process id) and cannot be copied (run as normal user)
const [openError, sourceHandle] = await safe(fs.promises.open(sourceFile, 'r'));
if (openError) {
debug(`addFile: ignoring disappeared file: ${sourceFile}`);
return;
}
const sourceStream = sourceHandle.createReadStream(sourceFile, { autoClose: true });
const ps = new ProgressStream({ interval: 10000 }); // display a progress every 10 seconds
ps.on('progress', function (progress) {
const transferred = Math.round(progress.transferred/1024/1024), speed = Math.round(progress.speed/1024/1024);
if (!transferred && !speed) return progressCallback({ message: `Uploading ${sourceFile}` }); // 0M@0MBps looks wrong
progressCallback({ message: `Uploading ${sourceFile}: ${transferred}M@${speed}MBps` }); // 0M@0MBps looks wrong
});
let pipeline = null;
if (encryption) {
const encryptStream = new EncryptStream(encryption);
pipeline = safe(stream.pipeline(sourceStream, encryptStream, ps, uploader.stream));
} else {
pipeline = safe(stream.pipeline(sourceStream, ps, uploader.stream));
}
const [error] = await safe(pipeline);
if (error && error.message.includes('ENOENT')) { // ignore error if file disappears
}
if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, `tarPack pipeline error: ${error.message}`);
// debug(`addFile: pipeline finished: ${JSON.stringify(ps.stats())}`);
await uploader.finish();
}
async function sync(backupConfig, remotePath, dataLayout, progressCallback) {
assert.strictEqual(typeof backupConfig, 'object');
assert.strictEqual(typeof remotePath, 'string');
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
assert.strictEqual(typeof progressCallback, 'function');
assert.strictEqual(typeof callback, 'function');
// the number here has to take into account the s3.upload partSize (which is 10MB). So 20=200MB
const concurrency = backupConfig.limits?.syncConcurrency || (backupConfig.provider === 's3' ? 20 : 10);
const removeDir = util.callbackify(storage.api(backupConfig.provider).removeDir);
const remove = util.callbackify(storage.api(backupConfig.provider).remove);
syncer.sync(dataLayout, function processTask(task, iteratorCallback) {
await syncer.sync(dataLayout, async function processTask(task) {
debug('sync: processing task: %j', task);
// the empty task.path is special to signify the directory
const destPath = task.path && backupConfig.encryptedFilenames ? hush.encryptFilePath(task.path, backupConfig.encryption) : task.path;
@@ -58,42 +95,20 @@ function sync(backupConfig, remotePath, dataLayout, progressCallback, callback)
if (task.operation === 'removedir') {
debug(`Removing directory ${backupFilePath}`);
return removeDir(backupConfig, backupFilePath, progressCallback, iteratorCallback);
await storage.api(backupConfig.provider).removeDir(backupConfig, backupFilePath, progressCallback);
} else if (task.operation === 'remove') {
debug(`Removing ${backupFilePath}`);
return remove(backupConfig, backupFilePath, iteratorCallback);
}
let retryCount = 0;
async.retry({ times: 5, interval: 20000 }, function (retryCallback) {
retryCallback = once(retryCallback); // protect again upload() erroring much later after read stream error
++retryCount;
if (task.operation === 'add') {
await storage.api(backupConfig.provider).remove(backupConfig, backupFilePath);
} else if (task.operation === 'add') {
await promiseRetry({ times: 5, interval: 20000, debug }, async (retryCount) => {
progressCallback({ message: `Adding ${task.path}` + (retryCount > 1 ? ` (Try ${retryCount})` : '') });
debug(`Adding ${task.path} position ${task.position} try ${retryCount}`);
const stream = hush.createReadStream(dataLayout.toLocalPath('./' + task.path), backupConfig.encryption);
stream.on('error', (error) => retryCallback(error.message.includes('ENOENT') ? null : error)); // ignore error if file disappears
stream.on('progress', function (progress) {
const transferred = Math.round(progress.transferred/1024/1024), speed = Math.round(progress.speed/1024/1024);
if (!transferred && !speed) return progressCallback({ message: `Uploading ${task.path}` }); // 0M@0MBps looks wrong
progressCallback({ message: `Uploading ${task.path}: ${transferred}M@${speed}MBps` }); // 0M@0MBps looks wrong
});
// only create the destination path when we have confirmation that the source is available. otherwise, we end up with
// files owned as 'root' and the cp later will fail
stream.on('open', function () {
storage.api(backupConfig.provider).upload(backupConfig, backupFilePath, stream, function (error) {
debug(error ? `Error uploading ${task.path} try ${retryCount}: ${error.message}` : `Uploaded ${task.path}`);
retryCallback(error);
});
});
}
}, iteratorCallback);
}, concurrency, function (error) {
if (error) return callback(new BoxError(BoxError.EXTERNAL_ERROR, error.message));
callback();
});
const uploader = await storage.api(backupConfig.provider).upload(backupConfig, backupFilePath);
await addFile(dataLayout.toLocalPath('./' + task.path), backupConfig.encryption, uploader, progressCallback);
});
}
}, concurrency);
}
// this is not part of 'snapshotting' because we need root access to traverse
@@ -109,7 +124,7 @@ async function saveFsMetadata(dataLayout, metadataFile) {
};
// we assume small number of files. spawnSync will raise a ENOBUFS error after maxBuffer
for (let lp of dataLayout.localPaths()) {
for (const lp of dataLayout.localPaths()) {
const emptyDirs = await shell.exec('saveFsMetadata', `find ${lp} -type d -empty`, { maxBuffer: 1024 * 1024 * 80 });
if (emptyDirs.length) metadata.emptyDirs = metadata.emptyDirs.concat(emptyDirs.trim().split('\n').map((ed) => dataLayout.toRemotePath(ed)));
@@ -205,7 +220,7 @@ function downloadDir(backupConfig, backupFilePath, dataLayout, progressCallback,
progressCallback({ message: `Downloading ${entry.fullPath} to ${destFilePath}` });
const [pipelineError] = await safe(stream.promises.pipeline(streams));
const [pipelineError] = await safe(stream.pipeline(streams));
if (pipelineError) {
progressCallback({ message: `Download error ${entry.fullPath} to ${destFilePath}: ${pipelineError.message}` });
throw pipelineError;
@@ -243,8 +258,6 @@ async function upload(backupConfig, remotePath, dataLayout, progressCallback) {
assert.strictEqual(typeof dataLayout, 'object');
assert.strictEqual(typeof progressCallback, 'function');
const syncAsync = util.promisify(sync);
await saveFsMetadata(dataLayout, `${dataLayout.localRoot()}/fsmetadata.json`);
await syncAsync(backupConfig, remotePath, dataLayout, progressCallback);
await sync(backupConfig, remotePath, dataLayout, progressCallback);
}
+193 -111
View File
@@ -1,23 +1,19 @@
'use strict';
exports = module.exports = {
getBackupFilePath,
download,
upload
};
const assert = require('assert'),
async = require('async'),
BoxError = require('../boxerror.js'),
DataLayout = require('../datalayout.js'),
debug = require('debug')('box:backupformat/tgz'),
{ DecryptStream, EncryptStream } = require('../hush.js'),
once = require('../once.js'),
fs = require('fs'),
path = require('path'),
ProgressStream = require('../progress-stream.js'),
promiseRetry = require('../promise-retry.js'),
safe = require('safetydance'),
storage = require('../storage.js'),
tar = require('tar-fs'),
stream = require('stream/promises'),
{ Transform } = require('node:stream'),
tar = require('tar-stream'),
zlib = require('zlib');
function getBackupFilePath(backupConfig, remotePath) {
@@ -33,108 +29,206 @@ function getBackupFilePath(backupConfig, remotePath) {
return path.join(rootPath, remotePath + fileType);
}
function tarPack(dataLayout, encryption) {
// In tar, the entry header contains the file size. If we don't provide it those many bytes, the tar will become corrupt
// Linux provides no guarantee of how many bytes can be read from a file. This is the case with sqlite and log files
// which are accessed by other processes when tar is in action. This class handles overflow and underflow
class EnsureFileSizeStream extends Transform {
constructor(options) {
super(options);
this._remaining = options.size;
this._name = options.name;
}
_transform(chunk, encoding, callback) {
if (this._remaining <= 0) {
debug(`EnsureFileSizeStream: ${this._name} dropping ${chunk.length} bytes`);
return callback(null);
}
if (this._remaining - chunk.length < 0) {
debug(`EnsureFileSizeStream: ${this._name} dropping extra ${chunk.length - this._remaining} bytes`);
chunk = chunk.subarray(0, this._remaining);
this._remaining = 0;
} else {
this._remaining -= chunk.length;
}
callback(null, chunk);
}
_flush(callback) {
if (this._remaining > 0) {
debug(`EnsureFileSizeStream: ${this._name} injecting ${this._remaining} bytes`);
this.push(Buffer.alloc(this._remaining, 0));
}
callback();
}
}
function addEntryToPack(pack, header, options) {
assert.strictEqual(typeof pack, 'object');
assert.strictEqual(typeof header, 'object');
assert.strictEqual(typeof options, 'object'); // { input }
return new Promise((resolve, reject) => {
const packEntry = safe(() => pack.entry(header, function (error) {
if (error) {
debug(`addToPack: error adding ${header.name} ${header.type} ${error.message}`);
reject(new BoxError(BoxError.FS_ERROR, error.message));
} else {
debug(`addToPack: added ${header.name} ${header.type}`);
resolve();
}
}));
if (!packEntry) return reject(new BoxError(BoxError.FS_ERROR, `Failed to add ${header.name}: ${safe.error.message}`));
if (options?.input) {
const ensureFileSizeStream = new EnsureFileSizeStream({ name: header.name, size: header.size });
safe(stream.pipeline(options.input, ensureFileSizeStream, packEntry), { debug }); // background. rely on pack.entry callback for promise completion
}
});
}
async function addPathToPack(pack, localPath, dataLayout) {
assert.strictEqual(typeof pack, 'object');
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
assert.strictEqual(typeof localPath, 'string');
const queue = [ localPath ];
while (queue.length) {
// if (pack.destroyed || outStream.destroyed) break;
const dir = queue.shift();
debug(`tarPack: processing ${dir}`);
const [readdirError, entries] = await safe(fs.promises.readdir(dir, { withFileTypes: true }));
if (!entries) {
debug(`tarPack: skipping directory ${dir}: ${readdirError.message}`);
continue;
}
const subdirs = [];
for (const entry of entries) {
const abspath = path.join(dir, entry.name);
const headerName = dataLayout.toRemotePath(abspath);
if (entry.isFile()) {
const [openError, handle] = await safe(fs.promises.open(abspath, 'r'));
if (!handle) { debug(`tarPack: skipping file, could not open ${abspath}: ${openError.message}`); continue; }
const [statError, stat] = await safe(handle.stat());
if (!stat) { debug(`tarPack: skipping file, could not stat ${abspath}: ${statError.message}`); continue; }
const header = { name: headerName, type: 'file', mode: stat.mode, size: stat.size, uid: process.getuid(), gid: process.getgid() };
if (stat.size > 8589934590 || entry.name.length > 99) header.pax = { size: stat.size };
const input = handle.createReadStream({ autoClose: true });
await addEntryToPack(pack, header, { input });
} else if (entry.isDirectory()) {
const header = { name: headerName, type: 'directory', uid: process.getuid(), gid: process.getgid() };
subdirs.push(abspath);
await addEntryToPack(pack, header, { /* options */ });
} else if (entry.isSymbolicLink()) {
const [readlinkError, target] = await safe(fs.promises.readlink(abspath));
if (!target) { debug(`tarPack: skipping link, could not readlink ${abspath}: ${readlinkError.message}`); continue; }
const header = { name: headerName, type: 'symlink', linkname: target, uid: process.getuid(), gid: process.getgid() };
await addEntryToPack(pack, header, { /* options */ });
} else {
debug(`tarPack: ignoring unknown type ${entry.name} ${entry.type}`);
}
}
queue.unshift(...subdirs); // add to front of queue and in order of readdir listing
}
}
async function tarPack(dataLayout, encryption, uploader, progressCallback) {
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
assert.strictEqual(typeof encryption, 'object');
const pack = tar.pack('/', {
dereference: false, // pack the symlink and not what it points to
entries: dataLayout.localPaths(),
ignoreStatError: (path, err) => {
debug(`tarPack: error stat'ing ${path} - ${err.code}`);
return err.code === 'ENOENT'; // ignore if file or dir got removed (probably some temporary file)
},
map: function(header) {
header.name = dataLayout.toRemotePath(header.name);
// the tar pax format allows us to encode filenames > 100 and size > 8GB (see #640)
// https://www.systutorials.com/docs/linux/man/5-star/
if (header.size > 8589934590 || header.name > 99) header.pax = { size: header.size };
return header;
},
strict: false // do not error for unknown types (skip fifo, char/block devices)
});
assert.strictEqual(typeof uploader, 'object');
assert.strictEqual(typeof progressCallback, 'function');
const gzip = zlib.createGzip({});
const ps = new ProgressStream({ interval: 10000 }); // emit 'progress' every 10 seconds
pack.on('error', function (error) {
debug('tarPack: tar stream error. %o', error);
ps.emit('error', new BoxError(BoxError.EXTERNAL_ERROR, error.message));
ps.on('progress', function (progress) {
const transferred = Math.round(progress.transferred/1024/1024), speed = Math.round(progress.speed/1024/1024);
if (!transferred && !speed) return progressCallback({ message: 'Uploading backup' }); // 0M@0MBps looks wrong
progressCallback({ message: `Uploading backup ${transferred}M@${speed}MBps` });
});
gzip.on('error', function (error) {
debug('tarPack: gzip stream error. %o', error);
ps.emit('error', new BoxError(BoxError.EXTERNAL_ERROR, error.message));
});
const pack = tar.pack();
let pipeline = null;
if (encryption) {
const encryptStream = new EncryptStream(encryption);
encryptStream.on('error', function (error) {
debug('tarPack: encrypt stream error. %o', error);
ps.emit('error', new BoxError(BoxError.EXTERNAL_ERROR, error.message));
});
pack.pipe(gzip).pipe(encryptStream).pipe(ps);
pipeline = safe(stream.pipeline(pack, gzip, encryptStream, ps, uploader.stream));
} else {
pack.pipe(gzip).pipe(ps);
pipeline = safe(stream.pipeline(pack, gzip, ps, uploader.stream));
}
return ps;
for (const localPath of dataLayout.localPaths()) {
const [error] = await safe(addPathToPack(pack, localPath, dataLayout), { debug });
if (error) break; // the pipeline will error and we will retry the whole packing all over
}
pack.finalize(); // harmless to call if already in error state
const [error] = await pipeline; // already wrapped in safe()
if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, `tarPack pipeline error: ${error.message}`);
debug(`tarPack: pipeline finished: ${JSON.stringify(ps.stats())}`);
await uploader.finish();
}
function tarExtract(inStream, dataLayout, encryption) {
async function tarExtract(inStream, dataLayout, encryption, progressCallback) {
assert.strictEqual(typeof inStream, 'object');
assert(dataLayout instanceof DataLayout, 'dataLayout must be a DataLayout');
assert.strictEqual(typeof encryption, 'object');
assert.strictEqual(typeof progressCallback, 'function');
const extract = tar.extract();
const now = new Date();
extract.on('entry', async function (header, entryStream, next) {
if (path.isAbsolute(header.name)) {
debug(`tarExtract: ignoring absolute path ${header.name}`);
return next();
}
const abspath = dataLayout.toLocalPath(header.name);
debug(`tarExtract: ${header.name} ${header.size} ${header.type} to ${abspath}`);
let error = null;
if (header.type === 'directory') {
[error] = await safe(fs.promises.mkdir(abspath, { recursive: true, mode: 0o755 }));
} else if (header.type === 'file') {
const output = fs.createWriteStream(abspath);
[error] = await safe(stream.pipeline(entryStream, output));
if (!error) [error] = await safe(fs.promises.chmod(abspath, header.mode));
} else if (header.type === 'symlink') {
await safe(fs.promises.unlink(abspath)); // remove any link created from previous failed extract
[error] = await safe(fs.promises.symlink(header.linkname, abspath));
} else {
debug(`tarExtract: ignoring unknown entry: ${header.name} ${header.type}`);
entryStream.resume(); // drain
}
if (error) return next(error);
[error] = await safe(fs.promises.lutimes(abspath, now /* atime */, header.mtime)); // for dirs, mtime will get overwritten
next(error);
});
extract.on('finish', () => debug('tarExtract: extract finished'));
const gunzip = zlib.createGunzip({});
const ps = new ProgressStream({ interval: 10000 }); // display a progress every 10 seconds
const extract = tar.extract('/', {
map: function (header) {
header.name = dataLayout.toLocalPath(header.name);
return header;
},
dmode: 500 // ensure directory is writable
});
const emitError = once((error) => {
inStream.destroy();
ps.emit('error', error);
});
inStream.on('error', function (error) {
debug('tarExtract: input stream error. %o', error);
emitError(new BoxError(BoxError.EXTERNAL_ERROR, error.message));
});
gunzip.on('error', function (error) {
debug('tarExtract: gunzip stream error. %o', error);
emitError(new BoxError(BoxError.EXTERNAL_ERROR, error.message));
});
extract.on('error', function (error) {
debug('tarExtract: extract stream error. %o', error);
emitError(new BoxError(BoxError.EXTERNAL_ERROR, error.message));
});
extract.on('finish', function () {
debug('tarExtract: done.');
// we use a separate event because ps is a through2 stream which emits 'finish' event indicating end of inStream and not extract
ps.emit('done');
const ps = new ProgressStream({ interval: 10000 });
ps.on('progress', function (progress) {
const transferred = Math.round(progress.transferred/1024/1024), speed = Math.round(progress.speed/1024/1024);
if (!transferred && !speed) return progressCallback({ message: 'Downloading backup' }); // 0M@0MBps looks wrong
progressCallback({ message: `Downloading ${transferred}M@${speed}MBps` });
});
if (encryption) {
const decrypt = new DecryptStream(encryption);
decrypt.on('error', function (error) {
debug('tarExtract: decrypt stream error.', error);
emitError(new BoxError(BoxError.EXTERNAL_ERROR, `Failed to decrypt: ${error.message}`));
});
inStream.pipe(ps).pipe(decrypt).pipe(gunzip).pipe(extract);
const [error] = await safe(stream.pipeline(inStream, ps, decrypt, gunzip, extract));
if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, `tarExtract pipeline error: ${error.message}`);
} else {
inStream.pipe(ps).pipe(gunzip).pipe(extract);
const [error] = await safe(stream.pipeline(inStream, ps, gunzip, extract));
if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, `tarExtract pipeline error: ${error.message}`);
}
return ps;
debug(`tarExtract: pipeline finished: ${ps.stats()}`);
}
async function download(backupConfig, remotePath, dataLayout, progressCallback) {
@@ -151,17 +245,7 @@ async function download(backupConfig, remotePath, dataLayout, progressCallback)
progressCallback({ message: `Downloading backup ${backupFilePath}` });
const sourceStream = await storage.api(backupConfig.provider).download(backupConfig, backupFilePath);
const ps = tarExtract(sourceStream, dataLayout, backupConfig.encryption);
return await new Promise((resolve, reject) => {
ps.on('progress', function (progress) {
const transferred = Math.round(progress.transferred/1024/1024), speed = Math.round(progress.speed/1024/1024);
if (!transferred && !speed) return progressCallback({ message: 'Downloading backup' }); // 0M@0MBps looks wrong
progressCallback({ message: `Downloading ${transferred}M@${speed}MBps` });
});
ps.on('error', reject);
ps.on('done', resolve);
});
await tarExtract(sourceStream, dataLayout, backupConfig.encryption, progressCallback);
});
}
@@ -173,23 +257,21 @@ async function upload(backupConfig, remotePath, dataLayout, progressCallback) {
debug(`upload: Uploading ${dataLayout.toString()} to ${remotePath}`);
return new Promise((resolve, reject) => {
async.retry({ times: 5, interval: 20000 }, function (retryCallback) {
retryCallback = once(retryCallback); // protect again upload() erroring much later after tar stream error
const backupFilePath = getBackupFilePath(backupConfig, remotePath);
const tarStream = tarPack(dataLayout, backupConfig.encryption);
await promiseRetry({ times: 5, interval: 20000, debug }, async () => {
progressCallback({ message: `Uploading backup ${backupFilePath}` });
tarStream.on('progress', function (progress) {
const transferred = Math.round(progress.transferred/1024/1024), speed = Math.round(progress.speed/1024/1024);
if (!transferred && !speed) return progressCallback({ message: 'Uploading backup' }); // 0M@0MBps looks wrong
progressCallback({ message: `Uploading backup ${transferred}M@${speed}MBps` });
});
tarStream.on('error', retryCallback); // already returns BoxError
storage.api(backupConfig.provider).upload(backupConfig, getBackupFilePath(backupConfig, remotePath), tarStream, retryCallback);
}, (error) => {
if (error) return reject(error);
resolve();
});
const uploader = await storage.api(backupConfig.provider).upload(backupConfig, backupFilePath);
await tarPack(dataLayout, backupConfig.encryption, uploader, progressCallback);
});
}
exports = module.exports = {
getBackupFilePath,
download,
upload,
// exported for testing
_EnsureFileSizeStream: EnsureFileSizeStream
};
+12 -5
View File
@@ -39,6 +39,7 @@ exports = module.exports = {
remount,
getMountStatus,
ensureMounted,
BACKUP_IDENTIFIER_BOX: 'box',
BACKUP_IDENTIFIER_MAIL: 'mail',
@@ -249,7 +250,7 @@ async function startBackupTask(auditSource) {
await eventlog.add(eventlog.ACTION_BACKUP_START, auditSource, { taskId });
tasks.startTask(taskId, { timeout: 24 * 60 * 60 * 1000 /* 24 hours */, nice: 15, memoryLimit }, async function (error, backupId) {
tasks.startTask(taskId, { timeout: 24 * 60 * 60 * 1000 /* 24 hours */, nice: 15, memoryLimit, oomScoreAdjust: -999 }, async function (error, backupId) {
locker.unlock(locker.OP_FULL_BACKUP);
const errorMessage = error ? error.message : '';
@@ -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 || {
+6 -5
View File
@@ -23,6 +23,7 @@ const apps = require('./apps.js'),
DataLayout = require('./datalayout.js'),
database = require('./database.js'),
debug = require('debug')('box:backuptask'),
df = require('./df.js'),
path = require('path'),
paths = require('./paths.js'),
safe = require('safetydance'),
@@ -51,12 +52,12 @@ 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}`);
// check availabe size. this requires root for df to work
const df = await storage.api(backupConfig.provider).getAvailableSize(backupConfig);
const available = await storage.api(backupConfig.provider).getAvailableSize(backupConfig);
let used = 0;
for (const localPath of dataLayout.localPaths()) {
debug(`checkPreconditions: getting disk usage of ${localPath}`);
@@ -64,10 +65,10 @@ async function checkPreconditions(backupConfig, dataLayout) {
used += parseInt(result, 10);
}
debug(`checkPreconditions: total required=${used} available=${df.available}`);
debug(`checkPreconditions: total required=${used} available=${available}`);
const needed = 0.6 * used + (1024 * 1024 * 1024); // check if there is atleast 1GB left afterwards. aim for 60% because rsync/tgz won't need full 100%
if (df.available <= needed) throw new BoxError(BoxError.FS_ERROR, `Not enough disk space for backup. Needed: ${df.prettyBytes(needed)} Available: ${df.prettyBytes(df.available)}`);
if (available <= needed) throw new BoxError(BoxError.FS_ERROR, `Not enough disk space for backup. Needed: ${df.prettyBytes(needed)} Available: ${df.prettyBytes(available)}`);
}
// this function is called via backupupload (since it needs root to traverse app's directory)
@@ -486,7 +487,7 @@ async function fullBackup(options, progressCallback) {
const allApps = await apps.list();
let percent = 1;
let step = 100/(allApps.length+3);
const step = 100/(allApps.length+3);
const appBackupIds = [];
for (let i = 0; i < allApps.length; i++) {
+2 -2
View File
@@ -16,7 +16,7 @@ const assert = require('assert'),
cron = require('./cron.js'),
moment = require('moment-timezone'),
settings = require('./settings.js'),
translation = require('./translation.js');
translations = require('./translations.js');
async function getStatus() {
return {
@@ -46,7 +46,7 @@ async function getLanguage() {
async function setLanguage(language) {
assert.strictEqual(typeof language, 'string');
const languages = await translation.listLanguages();
const languages = await translations.listLanguages();
if (languages.indexOf(language) === -1) throw new BoxError(BoxError.BAD_FIELD, 'Language not found');
await settings.set(settings.LANGUAGE_KEY, language);
+2 -2
View File
@@ -223,7 +223,7 @@ async function handleAutoupdatePatternChanged(pattern) {
const updateInfo = updateChecker.getUpdateInfo();
// do box before app updates. for the off chance that the box logic fixes some app update logic issue
if (updateInfo.box && !updateInfo.box.unstable) {
debug('Starting box autoupdate to %j', updateInfo.box);
debug('Starting box autoupdate to %j', updateInfo.box.version);
const [error] = await safe(updater.updateToLatest({ skipBackup: false }, AuditSource.CRON));
if (!error) return; // do not start app updates when a box update got scheduled
debug(`Failed to start box autoupdate task: ${error.message}`);
@@ -232,7 +232,7 @@ async function handleAutoupdatePatternChanged(pattern) {
const appUpdateInfo = _.omit(updateInfo, 'box');
if (Object.keys(appUpdateInfo).length > 0) {
debug('Starting app update to %j', appUpdateInfo);
debug('Starting app autoupdate: %j', Object.keys(appUpdateInfo));
const [error] = await safe(apps.autoupdateApps(appUpdateInfo, AuditSource.CRON));
if (error) debug(`Failed to app autoupdate: ${error.message}`);
} else {
+2
View File
@@ -6,6 +6,7 @@ const assert = require('assert'),
class DataLayout {
constructor(localRoot, dirMap) {
assert.strictEqual(typeof localRoot, 'string');
assert(path.isAbsolute(localRoot));
assert(Array.isArray(dirMap), 'Expecting layout to be an array');
this._localRoot = localRoot;
@@ -13,6 +14,7 @@ class DataLayout {
this._remoteRegexps = dirMap.map((l) => new RegExp('^\\./' + l.remoteDir + '/?'));
this._localRegexps = dirMap.map((l) => new RegExp('^' + l.localDir + '/?'));
}
// returns absolute path
toLocalPath(remoteName) {
assert.strictEqual(typeof remoteName, 'string');
+1 -1
View File
@@ -16,7 +16,7 @@ async function resolve(hostname, rrtype, options) {
assert.strictEqual(typeof rrtype, 'string');
assert(options && typeof options === 'object'); // { server, timeout }
const resolver = new dns.promises.Resolver({ timeout: options.timeout || 10000 });
const resolver = new dns.promises.Resolver({ timeout: options.timeout || 10000, tries: options.tries || 2 });
if (constants.CLOUDRON) resolver.setServers([ options.server || '127.0.0.150' ]); // unbound runs here
+1 -1
View File
@@ -50,7 +50,7 @@ async function isChangeSynced(hostname, type, value, nameserver) {
const status = [];
for (let i = 0; i < nsIPs.length; i++) {
const nsIp = nsIPs[i];
const resolveOptions = { server: nsIp, timeout: 5000 };
const resolveOptions = { server: nsIp, timeout: 5000, tries: 1 };
const resolver = type === 'A' || type === 'AAAA' ? resolveIp(hostname, type, resolveOptions) : dig.resolve(hostname, 'TXT', resolveOptions);
const [error, answer] = await safe(resolver);
+12 -11
View File
@@ -285,12 +285,12 @@ async function createSubcontainer(app, name, cmd, options) {
const portEnv = [];
for (const portName in app.portBindings) {
const hostPort = app.portBindings[portName];
const portType = (manifest.tcpPorts && portName in manifest.tcpPorts) ? 'tcp' : 'udp';
const ports = portType == 'tcp' ? manifest.tcpPorts : manifest.udpPorts;
const containerPort = ports[portName].containerPort || hostPort;
const portCount = ports[portName].portCount || 1;
const hostIps = hostPort === 53 ? await getAddressesForPort53() : [ '0.0.0.0', '::0' ]; // port 53 is special because it is possibly taken by systemd-resolved
const { hostPort, type:portType, count:portCount } = app.portBindings[portName];
const portSpec = portType == 'tcp' ? manifest.tcpPorts : manifest.udpPorts;
const containerPort = portSpec[portName].containerPort || hostPort;
// port 53 is special. systemd-resolved is listening on 127.0.0.x port 53 and another process cannot listen to 0.0.0.0 port 53
// for port 53 alone, we listen explicitly on the server's interface IP
const hostIps = hostPort === 53 ? await getAddressesForPort53() : [ '0.0.0.0', '::0' ];
portEnv.push(`${portName}=${hostPort}`);
if (portCount > 1) portEnv.push(`${portName}_COUNT=${portCount}`);
@@ -298,7 +298,7 @@ async function createSubcontainer(app, name, cmd, options) {
// docker portBindings requires ports to be exposed
for (let i = 0; i < portCount; ++i) {
exposedPorts[`${containerPort+i}/${portType}`] = {};
dockerPortBindings[`${containerPort+i}/${portType}`] = hostIps.map(hip => { return { HostIp: hip, HostPort: (hostPort + i) + '' }; });
dockerPortBindings[`${containerPort+i}/${portType}`] = hostIps.map(hip => { return { HostIp: hip, HostPort: String(hostPort + i) }; });
}
}
@@ -348,7 +348,7 @@ async function createSubcontainer(app, name, cmd, options) {
},
Memory: memoryLimit,
MemorySwap: -1, // Unlimited swap
PortBindings: isAppContainer ? dockerPortBindings : { },
PortBindings: isAppContainer ? dockerPortBindings : {},
PublishAllPorts: false,
ReadonlyRootfs: app.debugMode ? !!app.debugMode.readonlyRootfs : true,
RestartPolicy: {
@@ -357,7 +357,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: [],
@@ -375,8 +375,9 @@ 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
// Do not inject for AdGuard. It ends up resolving the dashboard domain as the docker bridge IP
if (manifest.id !== 'com.adguard.home.cloudronapp') containerOptions.HostConfig.ExtraHosts = [ `${dashboardFqdn}:172.18.0.1` ];
containerOptions.NetworkingConfig = {
EndpointsConfig: {
+1 -1
View File
@@ -118,7 +118,7 @@ function process(req, res, next) {
async function start() {
assert(gHttpServer === null, 'Already started');
const json = middleware.json({ strict: true });
const json = express.json({ strict: true });
// we protect container create as the app/admin can otherwise mount random paths (like the ghost file)
// protected other paths is done by preventing install/exec access of apps using docker addon
+14 -44
View File
@@ -4,8 +4,6 @@ const assert = require('assert'),
BoxError = require('./boxerror.js'),
crypto = require('crypto'),
debug = require('debug')('box:hush'),
fs = require('fs'),
ProgressStream = require('./progress-stream.js'),
TransformStream = require('stream').Transform;
class EncryptStream extends TransformStream {
@@ -64,27 +62,29 @@ class DecryptStream extends TransformStream {
}
_transform(chunk, ignoredEncoding, callback) {
assert(Buffer.isBuffer(chunk));
const needed = 20 - this._header.length; // 4 for magic, 16 for iv
if (this._header.length !== 20) { // not gotten header yet
this._header = Buffer.concat([this._header, chunk.slice(0, needed)]);
this._header = Buffer.concat([this._header, chunk.subarray(0, needed)]);
if (this._header.length !== 20) return callback();
if (!this._header.slice(0, 4).equals(new Buffer.from('CBV2'))) return callback(new BoxError(BoxError.CRYPTO_ERROR, 'Invalid magic in header'));
if (!this._header.subarray(0, 4).equals(new Buffer.from('CBV2'))) return callback(new BoxError(BoxError.CRYPTO_ERROR, 'Invalid magic in header'));
const iv = this._header.slice(4);
const iv = this._header.subarray(4);
this._decipher = crypto.createDecipheriv('aes-256-cbc', this._key, iv);
this._hmac.update(this._header);
}
this._buffer = Buffer.concat([ this._buffer, chunk.slice(needed) ]);
this._buffer = Buffer.concat([ this._buffer, chunk.subarray(needed) ]);
if (this._buffer.length < 32) return callback(); // hmac trailer length is 32
try {
const cipherText = this._buffer.slice(0, -32);
const cipherText = this._buffer.subarray(0, -32);
this._hmac.update(cipherText);
const plainText = this._decipher.update(cipherText);
this._buffer = this._buffer.slice(-32);
this._buffer = this._buffer.subarray(-32);
callback(null, plainText);
} catch (error) {
callback(new BoxError(BoxError.CRYPTO_ERROR, `Decryption error: ${error.message}`));
@@ -110,8 +110,8 @@ function encryptFilePath(filePath, encryption) {
assert.strictEqual(typeof encryption, 'object');
const encryptedParts = filePath.split('/').map(function (part) {
let hmac = crypto.createHmac('sha256', Buffer.from(encryption.filenameHmacKey, 'hex'));
const iv = hmac.update(part).digest().slice(0, 16); // iv has to be deterministic, for our sync (copy) logic to work
const hmac = crypto.createHmac('sha256', Buffer.from(encryption.filenameHmacKey, 'hex'));
const iv = hmac.update(part).digest().subarray(0, 16); // iv has to be deterministic, for our sync (copy) logic to work
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(encryption.filenameKey, 'hex'), iv);
let crypt = cipher.update(part);
crypt = Buffer.concat([ iv, crypt, cipher.final() ]);
@@ -135,12 +135,12 @@ function decryptFilePath(filePath, encryption) {
try {
const buffer = Buffer.from(part, 'base64');
const iv = buffer.slice(0, 16);
let decrypt = crypto.createDecipheriv('aes-256-cbc', Buffer.from(encryption.filenameKey, 'hex'), iv);
const plainText = decrypt.update(buffer.slice(16));
const iv = buffer.subarray(0, 16);
const decrypt = crypto.createDecipheriv('aes-256-cbc', Buffer.from(encryption.filenameKey, 'hex'), iv);
const plainText = decrypt.update(buffer.subarray(16));
const plainTextString = Buffer.concat([ plainText, decrypt.final() ]).toString('utf8');
const hmac = crypto.createHmac('sha256', Buffer.from(encryption.filenameHmacKey, 'hex'));
if (!hmac.update(plainTextString).digest().slice(0, 16).equals(iv)) return { error: new BoxError(BoxError.CRYPTO_ERROR, `mac error decrypting part ${part} of path ${filePath}`) };
if (!hmac.update(plainTextString).digest().subarray(0, 16).equals(iv)) return { error: new BoxError(BoxError.CRYPTO_ERROR, `mac error decrypting part ${part} of path ${filePath}`) };
decryptedParts.push(plainTextString);
} catch (error) {
@@ -152,40 +152,10 @@ function decryptFilePath(filePath, encryption) {
return { result: decryptedParts.join('/') };
}
function createReadStream(sourceFile, encryption) {
assert.strictEqual(typeof sourceFile, 'string');
assert.strictEqual(typeof encryption, 'object');
const stream = fs.createReadStream(sourceFile);
const ps = new ProgressStream({ interval: 10000 }); // display a progress every 10 seconds
stream.on('error', function (error) {
debug(`createReadStream: read stream error at ${sourceFile}. %o`, error);
ps.emit('error', new BoxError(BoxError.FS_ERROR, `Error reading ${sourceFile}: ${error.message} ${error.code}`));
});
stream.on('open', () => ps.emit('open'));
if (encryption) {
let encryptStream = new EncryptStream(encryption);
encryptStream.on('error', function (error) {
debug(`createReadStream: encrypt stream error ${sourceFile}. %o`, error);
ps.emit('error', new BoxError(BoxError.CRYPTO_ERROR, `Encryption error at ${sourceFile}: ${error.message}`));
});
return stream.pipe(encryptStream).pipe(ps);
} else {
return stream.pipe(ps);
}
}
exports = module.exports = {
EncryptStream,
DecryptStream,
encryptFilePath,
decryptFilePath,
createReadStream,
};
+4 -4
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
@@ -15,10 +15,10 @@ exports = module.exports = {
'graphite': 'registry.docker.com/cloudron/graphite:3.4.3@sha256:75df420ece34b31a7ce8d45b932246b7f524c123e1854f5e8f115a9e94e33f20',
'mail': 'registry.docker.com/cloudron/mail:3.13.1@sha256:1ebc59926b42dca2b6803728c2902b98ebf023944dffef5345fa954b022a5774',
'mongodb': 'registry.docker.com/cloudron/mongodb:6.0.0@sha256:1108319805acfb66115aa96a8fdbf2cded28d46da0e04d171a87ec734b453d1e',
'mysql': 'registry.docker.com/cloudron/mysql:3.4.2@sha256:379749708186a89f4ae09d6b23b58bc6d99a2005bac32e812b4b1dafa47071e4',
'postgresql': 'registry.docker.com/cloudron/postgresql:5.2.2@sha256:6be67ab0f06b9b6c34d065bdcfccc63b1bcc41bf985fb20a3b94e2bcd4f3c0b9',
'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.6@sha256:6b4e3f192c23eadb21d2035ba05f8432d7961330edb93921f36a4eaa60c4a4aa',
'sftp': 'registry.docker.com/cloudron/sftp:3.8.9@sha256:f2a126839df99ca420a3ad8177594f58b113f6292e98719f2cf2e0ddc3597696',
'turn': 'registry.docker.com/cloudron/turn:1.7.2@sha256:9ed8da613c1edc5cb8700657cf6e49f0f285b446222a8f459f80919945352f6d',
}
};
+12 -3
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 = {
+20
View File
@@ -0,0 +1,20 @@
<center>
<p>Test email from <%= cloudronName %>.</p>
<br/>
<p>If you can read this, your Cloudron email settings are good.</p>
<br/>
<p>Sent at: <%= new Date().toUTCString() %></p>
<br/>
<br/>
<div style="font-size: 10px; color: #333333; background: #ffffff;">
Powered by <a href="https://cloudron.io">Cloudron</a>
</div>
</center>
+2
View File
@@ -4,6 +4,8 @@ Test email from <%= cloudronName %>,
If you can read this, your Cloudron email settings are good.
Powered by https://cloudron.io
Sent at: <%= new Date().toUTCString() %>
<% } else { %>
+12 -11
View File
@@ -26,7 +26,7 @@ const assert = require('assert'),
nodemailer = require('nodemailer'),
path = require('path'),
safe = require('safetydance'),
translation = require('./translation.js'),
translations = require('./translations.js'),
util = require('util');
const MAIL_TEMPLATES_DIR = path.join(__dirname, 'mail_templates');
@@ -80,7 +80,7 @@ function render(templateFile, params, translationAssets) {
return '';
}
if (typeof translationAssets === 'object') raw = translation.translate(raw, translationAssets.translations || {}, translationAssets.fallback || {});
if (typeof translationAssets === 'object') raw = translations.translate(raw, translationAssets);
try {
content = ejs.render(raw, params);
@@ -98,7 +98,7 @@ async function sendInvite(user, invitor, email, inviteLink) {
assert.strictEqual(typeof inviteLink, 'string');
const mailConfig = await getMailConfig();
const translationAssets = await translation.getTranslations();
const translationAssets = await translations.getTranslations();
const { fqdn:dashboardFqdn } = await dashboard.getLocation();
const templateData = {
@@ -113,7 +113,7 @@ async function sendInvite(user, invitor, email, inviteLink) {
const mailOptions = {
from: mailConfig.notificationFrom,
to: email,
subject: ejs.render(translation.translate('{{ welcomeEmail.subject }}', translationAssets.translations || {}, translationAssets.fallback || {}), { cloudron: mailConfig.cloudronName }),
subject: ejs.render(translations.translate('{{ welcomeEmail.subject }}', translationAssets), { cloudron: mailConfig.cloudronName }),
text: render('welcome_user-text.ejs', templateData, translationAssets),
html: render('welcome_user-html.ejs', templateData, translationAssets)
};
@@ -133,7 +133,7 @@ async function sendNewLoginLocation(user, loginLocation) {
assert.strictEqual(typeof city, 'string');
const mailConfig = await getMailConfig();
const translationAssets = await translation.getTranslations();
const translationAssets = await translations.getTranslations();
const { fqdn:dashboardFqdn } = await dashboard.getLocation();
const templateData = {
@@ -149,7 +149,7 @@ async function sendNewLoginLocation(user, loginLocation) {
const mailOptions = {
from: mailConfig.notificationFrom,
to: user.email,
subject: ejs.render(translation.translate('{{ newLoginEmail.subject }}', translationAssets.translations || {}, translationAssets.fallback || {}), { cloudron: mailConfig.cloudronName }),
subject: ejs.render(translations.translate('{{ newLoginEmail.subject }}', translationAssets), { cloudron: mailConfig.cloudronName }),
text: render('new_login_location-text.ejs', templateData, translationAssets),
html: render('new_login_location-html.ejs', templateData, translationAssets)
};
@@ -163,7 +163,7 @@ async function passwordReset(user, email, resetLink) {
assert.strictEqual(typeof resetLink, 'string');
const mailConfig = await getMailConfig();
const translationAssets = await translation.getTranslations();
const translationAssets = await translations.getTranslations();
const { fqdn:dashboardFqdn } = await dashboard.getLocation();
const templateData = {
@@ -176,7 +176,7 @@ async function passwordReset(user, email, resetLink) {
const mailOptions = {
from: mailConfig.notificationFrom,
to: email,
subject: ejs.render(translation.translate('{{ passwordResetEmail.subject }}', translationAssets.translations || {}, translationAssets.fallback || {}), { cloudron: mailConfig.cloudronName }),
subject: ejs.render(translations.translate('{{ passwordResetEmail.subject }}', translationAssets), { cloudron: mailConfig.cloudronName }),
text: render('password_reset-text.ejs', templateData, translationAssets),
html: render('password_reset-html.ejs', templateData, translationAssets)
};
@@ -195,7 +195,7 @@ async function backupFailed(mailTo, errorMessage, logUrl) {
from: mailConfig.notificationFrom,
to: mailTo,
subject: `[${mailConfig.cloudronName}] Failed to backup`,
text: render('backup_failed.ejs', { cloudronName: mailConfig.cloudronName, message: errorMessage, logUrl, format: 'text' })
text: render('backup_failed.ejs', { cloudronName: mailConfig.cloudronName, message: errorMessage, logUrl })
};
await sendMail(mailOptions);
@@ -212,7 +212,7 @@ async function certificateRenewalError(mailTo, domain, message) {
from: mailConfig.notificationFrom,
to: mailTo,
subject: `[${mailConfig.cloudronName}] Certificate renewal error`,
text: render('certificate_renewal_error.ejs', { domain: domain, message: message, format: 'text' })
text: render('certificate_renewal_error.ejs', { domain, message })
};
await sendMail(mailOptions);
@@ -229,7 +229,8 @@ async function sendTestMail(domain, email) {
from: `"${mailConfig.cloudronName}" <no-reply@${domain}>`,
to: email,
subject: `[${mailConfig.cloudronName}] Test Email`,
text: render('test.ejs', { cloudronName: mailConfig.cloudronName, format: 'text'})
text: render('test.ejs', { cloudronName: mailConfig.cloudronName}),
html: render('test-html.ejs', { cloudronName: mailConfig.cloudronName })
};
await sendMail(mailOptions);
+1 -4
View File
@@ -3,10 +3,7 @@
exports = module.exports = {
cookieParser: require('cookie-parser'),
cors: require('./cors.js'),
json: require('body-parser').json,
proxy: require('./proxy-middleware.js'),
lastMile: require('connect-lastmile'),
multipart: require('./multipart.js'),
timeout: require('connect-timeout'),
urlencoded: require('body-parser').urlencoded
timeout: require('connect-timeout')
};
+9 -5
View File
@@ -3,6 +3,7 @@
'use strict';
const multiparty = require('multiparty'),
safe = require('safetydance'),
timeout = require('connect-timeout');
function _mime(req) {
@@ -19,18 +20,21 @@ module.exports = function multipart(options) {
keepExtensions: true,
maxFieldsSize: options.maxFieldsSize || (2 * 1024), // only field size, not files
limit: options.limit || '8mb', // file sizes
autoFiles: true
autoFiles: true // emit files instead of emitting 'part'
});
// increase timeout of file uploads by default to 3 mins
if (req.clearTimeout) req.clearTimeout(); // clear any previous installed timeout middleware
timeout(options.timeout || (3 * 60 * 1000))(req, res, function () {
req.fields = { };
req.files = { };
req.fields = {};
req.files = {};
form.parse(req, function (err /*, fields, files */) {
if (err) return res.status(400).send('Error parsing request');
form.parse(req, function (error, fields, files) {
if (error) {
for (const file in files) safe.fs.unlinkSync(file.path);
return res.status(400).send('Error parsing request');
}
next(null);
});
-149
View File
@@ -1,149 +0,0 @@
// https://github.com/cloudron-io/node-proxy-middleware
// MIT license
// contains https://github.com/gonzalocasas/node-proxy-middleware/pull/59
var os = require('os');
var http = require('http');
var https = require('https');
var owns = {}.hasOwnProperty;
module.exports = function proxyMiddleware(options) {
//enable ability to quickly pass a url for shorthand setup
if(typeof options === 'string'){
options = require('url').parse(options);
}
var httpLib = options.protocol === 'https:' ? https : http;
var request = httpLib.request;
options = options || {};
options.hostname = options.hostname;
options.port = options.port;
options.pathname = options.pathname || '/';
return function (req, resp, next) {
var url = req.url;
// You can pass the route within the options, as well
if (typeof options.route === 'string') {
if (url === options.route) {
url = '';
} else if (url.slice(0, options.route.length) === options.route) {
url = url.slice(options.route.length);
} else {
return next();
}
}
//options for this request
var opts = extend({}, options);
if (url && url.charAt(0) === '?') { // prevent /api/resource/?offset=0
if (options.pathname.length > 1 && options.pathname.charAt(options.pathname.length - 1) === '/') {
opts.path = options.pathname.substring(0, options.pathname.length - 1) + url;
} else {
opts.path = options.pathname + url;
}
} else if (url) {
opts.path = slashJoin(options.pathname, url);
} else {
opts.path = options.pathname;
}
opts.method = req.method;
opts.headers = options.headers ? merge(req.headers, options.headers) : req.headers;
applyViaHeader(req.headers, opts, opts.headers);
if (!options.preserveHost) {
// Forwarding the host breaks dotcloud
delete opts.headers.host;
}
var myReq = request(opts, function (myRes) {
var statusCode = myRes.statusCode
, headers = myRes.headers
, location = headers.location;
// Fix the location
if (((statusCode > 300 && statusCode < 304) || statusCode === 201) && location && location.indexOf(options.href) > -1) {
// absoulte path
headers.location = location.replace(options.href, slashJoin('/', slashJoin((options.route || ''), '')));
}
applyViaHeader(myRes.headers, opts, myRes.headers);
rewriteCookieHosts(myRes.headers, opts, myRes.headers, req);
resp.writeHead(myRes.statusCode, myRes.headers);
myRes.on('error', function (err) {
next(err);
});
myRes.on('end', function (err) {
next();
});
myRes.pipe(resp);
});
myReq.on('error', function (err) {
next(err);
});
if (!req.readable) {
myReq.end();
} else {
req.pipe(myReq);
}
};
};
function applyViaHeader(existingHeaders, opts, applyTo) {
if (!opts.via) return;
var viaName = (true === opts.via) ? os.hostname() : opts.via;
var viaHeader = '1.1 ' + viaName;
if(existingHeaders.via) {
viaHeader = existingHeaders.via + ', ' + viaHeader;
}
applyTo.via = viaHeader;
}
function rewriteCookieHosts(existingHeaders, opts, applyTo, req) {
if (!opts.cookieRewrite || !owns.call(existingHeaders, 'set-cookie')) {
return;
}
var existingCookies = existingHeaders['set-cookie'],
rewrittenCookies = [],
rewriteHostname = (true === opts.cookieRewrite) ? os.hostname() : opts.cookieRewrite;
if (!Array.isArray(existingCookies)) {
existingCookies = [ existingCookies ];
}
for (var i = 0; i < existingCookies.length; i++) {
var rewrittenCookie = existingCookies[i].replace(/(Domain)=[a-z\.-_]*?(;|$)/gi, '$1=' + rewriteHostname + '$2');
if (!req.connection.encrypted) {
rewrittenCookie = rewrittenCookie.replace(/;\s*?(Secure)/i, '');
}
rewrittenCookies.push(rewrittenCookie);
}
applyTo['set-cookie'] = rewrittenCookies;
}
function slashJoin(p1, p2) {
var trailing_slash = false;
if (p1.length && p1[p1.length - 1] === '/') { trailing_slash = true; }
if (trailing_slash && p2.length && p2[0] === '/') {p2 = p2.substring(1); }
return p1 + p2;
}
function extend(obj, src) {
for (var key in src) if (owns.call(src, key)) obj[key] = src[key];
return obj;
}
//merges data without changing state in either argument
function merge(src1, src2) {
var merged = {};
extend(merged, src1);
extend(merged, src2);
return merged;
}
+2 -2
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' };
+4
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
+4 -4
View File
@@ -41,7 +41,7 @@ const assert = require('assert'),
safe = require('safetydance'),
settings = require('./settings.js'),
tokens = require('./tokens.js'),
translation = require('./translation.js'),
translations = require('./translations.js'),
url = require('url'),
users = require('./users.js'),
util = require('util');
@@ -463,7 +463,7 @@ function renderInteractionPage(provider) {
assert.strictEqual(typeof provider, 'object');
return async function (req, res) {
const translationAssets = await translation.getTranslations();
const translationAssets = await translations.getTranslations();
try {
const { uid, prompt, params, session } = await provider.interactionDetails(req, res);
@@ -489,7 +489,7 @@ function renderInteractionPage(provider) {
}
const template = fs.readFileSync(__dirname + '/oidc_templates/login.ejs', 'utf-8');
const html = ejs.render(translation.translate(template, translationAssets.translations || {}, translationAssets.fallback || {}), options);
const html = ejs.render(translations.translate(template, translationAssets), options);
return res.send(html);
}
@@ -888,7 +888,7 @@ async function start() {
app.set('views', path.join(__dirname, 'oidc_templates'));
app.set('view engine', 'ejs');
const json = middleware.json({ strict: true, limit: '2mb' });
const json = express.json({ strict: true, limit: '2mb' });
function setNoCache(req, res, next) {
res.set('cache-control', 'no-store');
next();
+1
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',
+2 -2
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
}
+6 -4
View File
@@ -37,12 +37,14 @@ const gStatus = {
setup: {
active: false,
message: '',
errorMessage: null
errorMessage: null,
startTime: null
},
restore: {
active: false,
message: '',
errorMessage: null
errorMessage: null,
startTime: null
},
activated: false,
adminFqdn: null,
@@ -98,7 +100,7 @@ async function setup(domainConfig, ipv4Config, ipv6Config, auditSource) {
if (gStatus.setup.active || gStatus.restore.active) throw new BoxError(BoxError.BAD_STATE, 'Already setting up or restoring');
gStatus.setup = { active: true, errorMessage: '', message: 'Adding domain' };
gStatus.setup = { active: true, errorMessage: '', message: 'Adding domain', startTime: (new Date()).toISOString() };
try {
const activated = await users.isActivated();
@@ -220,7 +222,7 @@ async function restore(backupConfig, remotePath, version, ipv4Config, ipv6Config
if (gStatus.setup.active || gStatus.restore.active) throw new BoxError(BoxError.BAD_STATE, 'Already setting up or restoring');
gStatus.restore = { active: true, errorMessage: '', message: 'Testing backup config' };
gStatus.restore = { active: true, errorMessage: '', message: 'Testing backup config', startTime: (new Date()).toISOString() };
try {
const activated = await users.isActivated();
+3 -3
View File
@@ -27,7 +27,7 @@ const apps = require('./apps.js'),
middleware = require('./middleware'),
oidc = require('./oidc.js'),
safe = require('safetydance'),
translation = require('./translation.js'),
translations = require('./translations.js'),
users = require('./users.js'),
util = require('util');
@@ -138,9 +138,9 @@ async function login(req, res, next) {
});
}
const translationAssets = await translation.getTranslations();
const translationAssets = await translations.getTranslations();
const template = fs.readFileSync(__dirname + '/oidc_templates/proxyauth_login.ejs', 'utf-8');
const html = ejs.render(translation.translate(template, translationAssets.translations || {}, translationAssets.fallback || {}), options);
const html = ejs.render(translations.translate(template, translationAssets), options);
return res.send(html);
}
+5 -5
View File
@@ -57,8 +57,8 @@ exports = module.exports = {
clone,
uploadFile,
downloadFile,
uploadFile,
updateBackup,
downloadBackup,
@@ -144,7 +144,7 @@ async function install(req, res, next) {
if (typeof data.accessRestriction !== 'object') return next(new HttpError(400, 'accessRestriction is required'));
// optional
if (('portBindings' in data) && typeof data.portBindings !== 'object') return next(new HttpError(400, 'portBindings must be an object'));
if (('ports' in data) && typeof data.ports !== 'object') return next(new HttpError(400, 'ports must be an object'));
if ('icon' in data && typeof data.icon !== 'string') return next(new HttpError(400, 'icon is not a string'));
if ('label' in data && typeof data.label !== 'string') return next(new HttpError(400, 'label must be a string'));
@@ -468,7 +468,7 @@ async function setLocation(req, res, next) {
if (typeof req.body.subdomain !== 'string') return next(new HttpError(400, 'subdomain must be string')); // subdomain may be an empty string
if (typeof req.body.domain !== 'string') return next(new HttpError(400, 'domain must be string'));
if ('portBindings' in req.body && typeof req.body.portBindings !== 'object') return next(new HttpError(400, 'portBindings must be an object'));
if ('ports' in req.body && typeof req.body.ports !== 'object') return next(new HttpError(400, 'ports must be an object'));
if ('secondaryDomains' in req.body) {
if (!req.body.secondaryDomains || typeof req.body.secondaryDomains !== 'object') return next(new HttpError(400, 'secondaryDomains must be an object'));
@@ -607,7 +607,7 @@ async function clone(req, res, next) {
if (typeof data.backupId !== 'string') return next(new HttpError(400, 'backupId must be a string'));
if (typeof data.subdomain !== 'string') return next(new HttpError(400, 'subdomain is required'));
if (typeof data.domain !== 'string') return next(new HttpError(400, 'domain is required'));
if (('portBindings' in data) && typeof data.portBindings !== 'object') return next(new HttpError(400, 'portBindings must be an object'));
if (('ports' in data) && typeof data.ports !== 'object') return next(new HttpError(400, 'ports must be an object'));
if ('secondaryDomains' in data) {
if (!data.secondaryDomains || typeof data.secondaryDomains !== 'object') return next(new HttpError(400, 'secondaryDomains must be an object'));
@@ -939,7 +939,7 @@ async function uploadFile(req, res, next) {
req.clearTimeout();
const [error] = await safe(apps.uploadFile(req.app, req.files.file.path, req.query.file));
safe.fs.unlinkSync(req.files.file.path); // remove file in /tmp
safe.fs.unlinkSync(req.files.file.path);
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(202, {}));
-2
View File
@@ -71,8 +71,6 @@ async function registerCloudronWithLogin(req, res, next) {
}
async function getSubscription(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
const [error, result] = await safe(appstore.getSubscription());
if (error) return next(BoxError.toHttpError(error));
+1 -1
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, {}));
+2
View File
@@ -59,6 +59,7 @@ async function setCloudronAvatar(req, res, next) {
if (!req.files.avatar) return next(new HttpError(400, 'avatar must be provided'));
const avatar = safe.fs.readFileSync(req.files.avatar.path);
safe.fs.unlinkSync(req.files.avatar.path);
if (!avatar) return next(500, safe.error.message);
const [error] = await safe(branding.setCloudronAvatar(avatar));
@@ -86,6 +87,7 @@ async function setCloudronBackground(req, res, next) {
if (req.files.background) {
backgroundImage = safe.fs.readFileSync(req.files.background.path);
safe.fs.unlinkSync(req.files.background.path);
if (!backgroundImage) return next(500, safe.error.message);
}

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