Compare commits

..

221 Commits

Author SHA1 Message Date
Girish Ramakrishnan 09e00e6d58 scheduler: typo 2024-03-12 17:54:51 +01:00
Johannes Zellner 09d2a63cbb We defer slider changes 2024-03-12 16:07:44 +01:00
Girish Ramakrishnan 8c062a8828 7.7.1 changes 2024-03-12 16:07:44 +01:00
Girish Ramakrishnan c707daf946 postgresql: fix whitelist ext loading 2024-03-12 16:07:44 +01:00
Girish Ramakrishnan 0a91e6b2c0 scheduler: do not create jobs of suspended apps
otherwise, when an app is uninstalling, it creates the docker containers
by calling getDynamicEnvironment. This ends up adding addonConfigs for the
docker addon and prevents the app from getting uninstalled.
2024-03-11 23:34:08 +01:00
Girish Ramakrishnan 90c8348c9c postgresql: fix upgrade route 2024-03-11 15:55:08 +01:00
Girish Ramakrishnan 1426cbec81 postgresql: fix for vectors update
we used:
psql -Uroot  --dbname=postgres --command="ALTER SYSTEM SET shared_preload_libraries = 'vectors.so'"

the above wrote to the auto config file and required a reboot. this resulted in
2024-03-11 09:39:13.250 UTC [34] ERROR:  pgvecto.rs: pgvecto.rs must be loaded via shared_preload_libraries.
	ADVICE: If you encounter this error for your first use of pgvecto.rs, please read `https://docs.pgvecto.rs/getting-started/installation.html`. You should edit `shared_preload_libraries` in `postgresql.conf` to include `vectors.so`, or simply run the command `psql -U postgres -c 'ALTER SYSTEM SET shared_preload_libraries = "vectors.so"'`.
2024-03-11 09:39:13.250 UTC [34] STATEMENT:  CREATE EXTENSION vectors
ERROR:  pgvecto.rs: pgvecto.rs must be loaded via shared_preload_libraries.
ADVICE: If you encounter this error for your first use of pgvecto.rs, please read `https://docs.pgvecto.rs/getting-started/installation.html`. You should edit `shared_preload_libraries` in `postgresql.conf` to include `vectors.so`, or simply run the command `psql -U postgres -c 'ALTER SYSTEM SET shared_preload_libraries = "vectors.so"'`.
2024-03-11 13:32:25 +01:00
Girish Ramakrishnan 7047915995 typo 2024-03-10 19:56:36 +01:00
Girish Ramakrishnan 49b514054f fixup mail fk constraints
it's possible previous releases bad a bug that they did not clear the mail domain
fields properly. this migration fixes it up.
2024-03-10 12:09:20 +01:00
Johannes Zellner bf27374dcc Fix postgresaddon migration for pgvectors 2024-03-07 13:59:30 +01:00
Johannes Zellner 3de1c6e499 Use postgres addon with immich hacks exposed as service api 2024-03-06 19:08:03 +01:00
Johannes Zellner d77285f2c4 frontend: update dependencies 2024-03-06 14:48:38 +01:00
Johannes Zellner 96eeb70076 Update postgres addon to 1.5.10
This contains a hack for immich in apptask to migrate the extension on
immich app update
2024-03-06 13:20:58 +01:00
Girish Ramakrishnan 6a39e442ac platform: use execArgs 2024-03-06 10:46:00 +01:00
Girish Ramakrishnan 91e030be44 sftp: fix buffer (stdin/stdout) overflow 2024-03-06 10:36:08 +01:00
Johannes Zellner 405e20e18e frontend: xtermjs moved to new node module naming scheme 2024-03-03 18:26:17 +01:00
Johannes Zellner 138f770630 frontend: update dependencies 2024-03-03 18:16:19 +01:00
Johannes Zellner eadc4fda30 Optional VectorRS is gone 2024-03-03 12:40:04 +01:00
Girish Ramakrishnan 35c5f19eac groups ui fixes 2024-03-01 18:45:40 +01:00
Girish Ramakrishnan 6d8ae180b3 initial indonesian translation 2024-03-01 18:45:20 +01:00
Girish Ramakrishnan 0fea30969f Remove bad assert 2024-03-01 14:52:54 +01:00
Girish Ramakrishnan 3ff8f5cb33 scheduler: proper crash when app is still being installed 2024-03-01 10:38:49 +01:00
Girish Ramakrishnan b6162a3bef docker addon: env var can be stored in the db 2024-03-01 10:31:41 +01:00
Girish Ramakrishnan 09ca67f408 restore: give a proper example in the placeholder 2024-02-29 19:37:34 +01:00
Johannes Zellner cadb1ad674 dashboard: show port count info 2024-02-29 15:31:32 +01:00
Johannes Zellner dec7bc3ca3 Check for portBindings with range outside the db constraint for now 2024-02-29 15:20:17 +01:00
Girish Ramakrishnan d87460a3cd encoding removed by mistake 2024-02-29 11:51:57 +01:00
Girish Ramakrishnan f076711ad3 add missing await 2024-02-29 10:41:07 +01:00
Girish Ramakrishnan 6149a5ac12 typo 2024-02-29 09:00:22 +01:00
Girish Ramakrishnan 44c61f7bd7 mail: do port 25 connectivity check with ipv4 2024-02-28 20:47:46 +01:00
Girish Ramakrishnan 4ea47da269 use execFile 2024-02-28 20:37:11 +01:00
Girish Ramakrishnan 35f2c0ec7d use --force option to not error 2024-02-28 19:59:38 +01:00
Girish Ramakrishnan 3316dd1f42 fixup various shell usage 2024-02-28 18:59:45 +01:00
Girish Ramakrishnan 07527fe2b1 shell: when using shell use child_process.exec
arg splitting messes up arguments and debug output
2024-02-28 18:34:07 +01:00
Girish Ramakrishnan 03207f62ba acme2: der is a binary format 2024-02-28 18:13:44 +01:00
Girish Ramakrishnan bcc78d81a6 shell: also print the args 2024-02-28 17:56:20 +01:00
Girish Ramakrishnan 0d38e443d1 groups: local groups can have remote and local users 2024-02-28 17:39:08 +01:00
Girish Ramakrishnan 50a069a7fa apphealthmonitor: only treat 5xx codes as truly erroneous 2024-02-28 17:39:08 +01:00
Girish Ramakrishnan 7455490074 Fix tests 2024-02-28 16:02:42 +01:00
Girish Ramakrishnan 64bb53abc3 services: startTurn needs a shell 2024-02-28 16:02:42 +01:00
Girish Ramakrishnan 18a680a85b groups: only the local groups of a user can be set 2024-02-28 15:56:03 +01:00
Girish Ramakrishnan e26f71b603 externalldap: cannot set members of external group 2024-02-28 15:56:03 +01:00
Girish Ramakrishnan f98fe43843 test: add ldap group test 2024-02-28 14:25:19 +01:00
Johannes Zellner 26dad82cd3 Add busy indicator to proxy auth login view 2024-02-28 13:10:36 +01:00
Girish Ramakrishnan 73d1860995 turn: remove quotes 2024-02-28 13:00:29 +01:00
Girish Ramakrishnan aca5c254d2 add release file as of date 2024-02-28 11:46:26 +01:00
Girish Ramakrishnan 3521815646 Next release is 7.7.0 2024-02-28 11:24:37 +01:00
Girish Ramakrishnan aecc16af5d add inboxDomain fk constraint 2024-02-27 13:45:08 +01:00
Girish Ramakrishnan 5927f397a3 translate port bindings after validation 2024-02-27 13:19:19 +01:00
Girish Ramakrishnan 1e85c86e74 clone: also clone crontab, enableTurn, enableRedis etc 2024-02-27 11:49:12 +01:00
Girish Ramakrishnan 6640929b01 remove unnecessary variable 2024-02-27 11:44:42 +01:00
Girish Ramakrishnan 7a333ace11 minor variable rename 2024-02-27 11:35:14 +01:00
Johannes Zellner 32bce25ad5 frontend: update dependencies 2024-02-26 18:09:27 +01:00
Johannes Zellner 5dc023d801 terminal: fix horizontal overflow in firefox 2024-02-26 18:09:15 +01:00
Johannes Zellner e3f31e6560 Ensure we keep the oidc secret on app update 2024-02-26 17:20:00 +01:00
Johannes Zellner e582e147cb dashboard: fix typo for external ldap group membership listing 2024-02-26 15:08:51 +01:00
Girish Ramakrishnan 6525504923 profile: store preferred language in the database 2024-02-26 13:30:35 +01:00
Girish Ramakrishnan 6d6107161e dashboard rename userInfo to getProfile 2024-02-26 12:38:33 +01:00
Girish Ramakrishnan 3196864f0d dashboard: rename refreshUserInfo to refreshProfile 2024-02-26 12:38:33 +01:00
Girish Ramakrishnan d7596beaf3 index: avoid some callback hell 2024-02-26 11:56:31 +01:00
Girish Ramakrishnan 23de5b5a61 appstore: move existing apps sync to common code 2024-02-26 11:37:23 +01:00
Johannes Zellner d98b09f802 Forward portCount during the portBinding translation 2024-02-25 16:52:10 +01:00
Johannes Zellner 97c012b3df Use full portBindings object internally also for validation 2024-02-25 16:28:57 +01:00
Johannes Zellner 867b8e0253 Also adjust portbindings env variable name check according to the manifest uppercase fix 2024-02-25 16:18:02 +01:00
Johannes Zellner 80400db92a Handle portCount in translatePortBindings 2024-02-25 14:33:57 +01:00
Johannes Zellner 72ff84be47 Update manifestformat 2024-02-25 13:59:55 +01:00
Girish Ramakrishnan 13e62bc738 logs: use stream.destroy() instead of custom hooks 2024-02-24 17:35:37 +01:00
Girish Ramakrishnan 0e83658aa3 make sudo commands terminate properly
sudo forks and execs the program. sudo also hangs around as the parent of the program waiting on the program and also forwarding signals.
sudo does not forward signals when the originator comes from the same process group. recently, there has been a change where it will
forward signals as long as sudo or the command is not the group leader (https://www.sudo.ws/repos/sudo/rev/d1bf60eac57f)
for us, this means that calling kill from this node process doesn't work since it's in the same group (and ubuntu 22 doesn't have the above fix).
the workaround is to invoke a kill from a different process group and this is done by starting detached
another idea is: use "ps --pid cp.pid -o pid=" to get the pid of the command and then send it signal directly

see also: https://dxuuu.xyz/sudo.html
2024-02-24 16:19:07 +01:00
Johannes Zellner 8e4506382d dashboard: make real Roboto Bold font-face available 2024-02-23 19:38:22 +01:00
Johannes Zellner 7a0b74d79b dashboard: Sort app grid items by label || fqdn 2024-02-23 18:11:06 +01:00
Johannes Zellner 1026728ab7 dashboard: Ensure fqdn of applink has the schema removed 2024-02-23 17:57:24 +01:00
Johannes Zellner 909fe5dc15 Add appPortBindings port count column 2024-02-23 17:57:24 +01:00
Johannes Zellner aed9801501 Update postgres addon for pgvector_rs 0.2.0 2024-02-23 17:57:24 +01:00
Girish Ramakrishnan 41f92c52e9 add to changes 2024-02-23 17:47:21 +01:00
Girish Ramakrishnan d0dc104ede logs: make logPaths work
we have to tail via sudo script

Fixes #811
2024-02-23 17:46:22 +01:00
Girish Ramakrishnan ce42680888 update mail container (solr, spam acl) 2024-02-23 11:37:08 +01:00
Girish Ramakrishnan 4ebff09f73 lint 2024-02-22 16:50:35 +01:00
Girish Ramakrishnan 8fd7daade6 rsync: empty check was removed by mistake 2024-02-22 14:47:44 +01:00
Girish Ramakrishnan e6aef755e3 shell: merge spawn into sudo 2024-02-22 12:43:23 +01:00
Girish Ramakrishnan c4b8d3b832 restore: add help link to backup path 2024-02-22 12:03:21 +01:00
Girish Ramakrishnan c38457b48d restore: better placeholder text for backup id 2024-02-22 12:01:03 +01:00
Girish Ramakrishnan 60994f9ed1 shell: docker run needs shell
don't want to get into parsing quotes!
2024-02-22 10:59:39 +01:00
Girish Ramakrishnan a6f078330f shell: no need to promise scoping 2024-02-21 19:40:27 +01:00
Girish Ramakrishnan cfd5c0f82b shell: rewrite exec to use execFile
this also renames execFile to execArgs
2024-02-21 18:54:43 +01:00
Girish Ramakrishnan 14c9260ab0 shell: exec encoding is utf8 by default and no shell
explicitly mark calls that require the shell
2024-02-21 17:47:25 +01:00
Girish Ramakrishnan 23cac99fe9 shell: remove spawn 2024-02-21 13:35:56 +01:00
Girish Ramakrishnan 2237d2bbb7 shell: remove usage of .spawn 2024-02-21 13:27:04 +01:00
Girish Ramakrishnan 62ca0487dc cloudron-support: docker info output 2024-02-21 12:54:08 +01:00
Girish Ramakrishnan 0e858dc333 cloudron-support: dump cloudron version 2024-02-21 12:51:50 +01:00
Girish Ramakrishnan fa3e908afc df can hang 2024-02-21 12:47:30 +01:00
Girish Ramakrishnan c1bb4de6a3 reverseproxy: use async exec 2024-02-21 12:33:04 +01:00
Girish Ramakrishnan 9b94cf18d0 convert more execSync to async 2024-02-21 11:00:12 +01:00
Girish Ramakrishnan b51071155a Use the async shell exec 2024-02-20 22:57:36 +01:00
Girish Ramakrishnan 1128edc23e update: remove dead pre-flight checks 2024-02-20 22:48:12 +01:00
Johannes Zellner df9c7010e2 Make backup memory limit slider more predictable with a minimum of 1 GB 2024-02-20 22:12:20 +01:00
Girish Ramakrishnan 54c7757e38 Fix crash 2024-02-20 21:53:52 +01:00
Girish Ramakrishnan 3da3ccedcb volumes: only wait for 5 seconds for mount status
mountpoint -q can never exit if the nfs mount disappears, for example
2024-02-20 21:38:57 +01:00
Girish Ramakrishnan 26eb739b46 shell: add options to exec 2024-02-20 21:11:09 +01:00
Johannes Zellner 7ce5b53753 dashboard: use snap bounds instead of ticks for memory slider 2024-02-20 14:37:18 +01:00
Girish Ramakrishnan 298d446e5f backups: make ui show min 1GB 2024-02-19 17:06:38 +01:00
Girish Ramakrishnan 450dd70ea2 backups: up min memory limit to 1GB 2024-02-19 17:02:14 +01:00
Girish Ramakrishnan 1d1a7af48e rsync: bump the buffer size to 80MB 2024-02-19 14:15:28 +01:00
Girish Ramakrishnan 003bc457bf setupdns: fix typo with bunny DNS 2024-02-18 18:45:20 +01:00
Girish Ramakrishnan bfafcea0b9 Update changes 2024-02-17 16:42:37 +01:00
Johannes Zellner 66da8dd4dc Always resetup oidc client record for apps 2024-02-15 12:40:58 +01:00
Girish Ramakrishnan 307a3ee015 apps: rename the config functions 2024-02-10 11:53:25 +01:00
Girish Ramakrishnan 95be147eb4 make config.json readable 2024-02-10 10:40:56 +01:00
Girish Ramakrishnan 2bf711f1f7 acme2: default to using secp256r1 key
the secp384r1 is not getting accepted by a few mail servers.

the upstream server is TLS 1.2 and advertises:
        {0xC0, 0x2C} TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
        {0xCC, 0xA9} TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
        {0xC0, 0x2B} TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
        {0xC0, 0x24} TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384
        {0xC0, 0x23} TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256
        {0xC0, 0x09} TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA

the connection fails with:
client connection error: Error: C0E703901F7F0000:error:0A0000C1:SSL routines:tls_post_process_client_hello:no shared cipher:../deps/openssl/openssl/ssl/statem/statem_srvr.c:2241:

node's current cipher list is https://nodejs.org/api/tls.html#modifying-the-default-tls-cipher-suite.
It says default cipher suite prefers GCM ciphers. ECDHE-ECDSA-AES256-GCM-SHA384 and ECDHE-ECDSA-AES128-GCM-SHA256
are the valid TLS 1.2 options but neither of these are selected.

the public key strength is somehow tied to cipher selection, I am not entirely sure how. from what i remember
`ecdsa_secp384r1_sha384` was listed in signature_algorithms extension.

Note that one document I found said that exchange server has a further _P256 and _P384 to cipher combinations.
Which suggests to me that one can also select specific curve+cipher combination.

anyway, with this curve, atleast the connection work with TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
2024-02-09 22:01:55 +01:00
Johannes Zellner c3d2c7bcde Update minior version dependency updates 2024-02-09 19:54:50 +01:00
Johannes Zellner 38e32942cb oidc: remove env var for disabled session/end route 2024-02-09 19:37:54 +01:00
Johannes Zellner febd24b203 Expose port count as _COUNT env varible 2024-02-09 15:49:29 +01:00
Johannes Zellner d1afa3fdca Update package.lock 2024-02-08 18:41:30 +01:00
Johannes Zellner a82d1ea832 Use portCount from manifest with 1 as default 2024-02-08 18:25:25 +01:00
Johannes Zellner 7d9e8da660 Update manifest format for portCount support 2024-02-08 18:17:08 +01:00
Johannes Zellner ec990bd16a WIP: Add some portrange support 2024-02-08 17:39:22 +01:00
Girish Ramakrishnan fb12c0e499 typo 2024-02-08 11:51:56 +01:00
Girish Ramakrishnan 3d1a4f8802 mongodb: update mongo to 6.0 2024-02-08 11:37:03 +01:00
Girish Ramakrishnan c978e3b7ea scheduler: add debug if scheduler is running too long 2024-02-08 10:54:07 +01:00
Girish Ramakrishnan 0b201cee71 mail: update haraka to 3.0.3 2024-02-08 10:36:56 +01:00
Johannes Zellner 8b7c5a65d6 Fixup profile avatar tests 2024-02-06 20:48:27 +01:00
Girish Ramakrishnan 8a63f0368e Fix parsing of displayName
Currently, we only have one field for the name. The first part is
first name. The rest is last name. Obviously, this won't work in all
cases but is the best we can do for the moment.
2024-02-06 16:53:03 +01:00
Girish Ramakrishnan ce4bf7e10c Fix cloudron installation on netcup
https://forum.cloudron.io/topic/10097/cloudron-install-error-dpkg-error/
https://twitter.com/netcup/status/1735265955364720757
2024-01-31 17:24:29 +01:00
Girish Ramakrishnan 479946173f df: run async
df hangs on some systems and this brings down the box code

happens on erroneous cifs/sshfs volumes
2024-01-30 12:23:20 +01:00
Girish Ramakrishnan 176baa075f Fix some typos 2024-01-30 11:53:54 +01:00
Girish Ramakrishnan bfbc41d5a7 Add changes 2024-01-29 23:42:59 +01:00
Girish Ramakrishnan d2b303ffd6 directoryserver: cloudflare warning 2024-01-29 23:39:26 +01:00
Girish Ramakrishnan 00bbb4242d cloudron-support: display last cert renewal log file 2024-01-29 15:08:24 +01:00
Girish Ramakrishnan 0a4b0688a8 cloudron-support: add dashboard cert check 2024-01-29 14:44:42 +01:00
Johannes Zellner 9efe399399 oidc: add picture claim 2024-01-29 13:55:31 +01:00
Johannes Zellner b03240ccb8 Send avatarType explicitly in profile 2024-01-29 13:51:03 +01:00
Johannes Zellner 35eb17a922 dashboard: no need for additional avatar query args 2024-01-29 13:27:22 +01:00
Johannes Zellner c8b997f732 Always send an image as avatar 2024-01-29 13:21:19 +01:00
Johannes Zellner 80e83e0c05 Always send images for profile 2024-01-27 22:55:10 +01:00
Girish Ramakrishnan 9491b5aa39 cloudron-support: add node version check 2024-01-25 15:06:22 +01:00
Girish Ramakrishnan 243a254f3e filesystem: remove hook should not rm recursively
this causes a bug in the backupcleaner when it tries to prune
empty directories when using the filesystem backend.

the bug is hit when a box backup is getting cleaned up but
one or more app backups are preserved.
2024-01-25 11:50:48 +01:00
Johannes Zellner 2d1e0ec890 Ensure we never set more memory than swap for containers 2024-01-24 15:54:57 +01:00
Girish Ramakrishnan 793ee38f79 external ldap: show proper error message on timeout 2024-01-23 23:27:06 +01:00
Girish Ramakrishnan 5240068f2f Update translations 2024-01-23 23:04:46 +01:00
Johannes Zellner b8be174610 Send proper content type for avatar 2024-01-23 17:57:22 +01:00
Girish Ramakrishnan b923925a6c better describe 2024-01-23 13:18:14 +01:00
Girish Ramakrishnan 61f5669d76 externalldap: no need to make REST API calls and start server 2024-01-23 13:16:40 +01:00
Girish Ramakrishnan cf707ba657 move the require 2024-01-23 12:44:23 +01:00
Girish Ramakrishnan 660260336c dockerproxy: await on close 2024-01-23 12:38:57 +01:00
Girish Ramakrishnan 0447086882 remove spurious log 2024-01-23 12:13:28 +01:00
Girish Ramakrishnan 29a96e5df1 ldap test: more unbinding 2024-01-23 11:58:00 +01:00
Girish Ramakrishnan c95bb248fb typo: invoke the function 2024-01-23 11:45:25 +01:00
Girish Ramakrishnan d3551826c1 platform: add deactivated for tests to uninitialize properly 2024-01-23 11:42:02 +01:00
Girish Ramakrishnan d2c21627de ldap: server.close has a callback after all 2024-01-23 10:47:09 +01:00
Girish Ramakrishnan 81e21effa4 test: clear cron jobs to make node exit 2024-01-23 10:24:48 +01:00
Girish Ramakrishnan 2d03941745 cron: clean old jobs variable properly 2024-01-23 10:19:56 +01:00
Girish Ramakrishnan 2401c9cee7 test: unbind ldap client 2024-01-23 10:12:29 +01:00
Girish Ramakrishnan 4f0bbcc73b externaldap: 2fa validation for supported sources
a request to verify password to externaldap.js logic can come from
* cloudron app (via ldapserver.js)
* dashboard (via oidc.js) or proxy auth (proxyauth.js) or CLI (accesscontrol.js)

the only supported source is the 'cloudron' provider at this point
2024-01-22 21:35:19 +01:00
Girish Ramakrishnan 5b9700e099 ldapserver: remove totp logic
none of the apps send totptoken and it's dead code
2024-01-22 14:12:40 +01:00
Girish Ramakrishnan d7dda61775 profile: unify password verification check 2024-01-22 14:03:23 +01:00
Girish Ramakrishnan 3220721f84 directoryserver: test all combinations of 2fa checks
directory server cannot know the source of the requesting client.
there are 3 sources - external app, cloudron app, cloudron dashboard.

the 2fa is requested by client by passing `+totpToken=xxx` . totpToken
is ignored if the user has no 2fa setup. If present, it is validated.
2024-01-22 13:14:29 +01:00
Girish Ramakrishnan 0ed144fe81 hide user import/export buttons until we know the use case
maybe people can just script using the REST API
2024-01-20 12:44:23 +01:00
Girish Ramakrishnan 13b9bed48b externalldap: when using cloudron source, disable local 2fa setup 2024-01-20 12:44:19 +01:00
Girish Ramakrishnan c99c24b3bd users: cannot update profile fields of external user 2024-01-20 11:23:35 +01:00
Girish Ramakrishnan bd1ab000f3 users: do not call setGroups when ldap groups synced 2024-01-20 00:32:49 +01:00
Girish Ramakrishnan a1fd5bb996 users: cannot edit groups with external ldap group sync 2024-01-20 00:11:10 +01:00
Girish Ramakrishnan 9ef29343b3 lint: camel case the variables 2024-01-19 23:35:02 +01:00
Girish Ramakrishnan 8bdcdd7810 groups: members cannot be set for external groups 2024-01-19 23:23:25 +01:00
Girish Ramakrishnan a1217e52c8 group: cannot set name of ldap group 2024-01-19 22:28:48 +01:00
Girish Ramakrishnan a8d37b917a groups: remove unused addMember 2024-01-19 17:25:36 +01:00
Girish Ramakrishnan 06ce351d82 externalldap: set group members as a single transaction 2024-01-19 17:24:35 +01:00
Girish Ramakrishnan f43a601e86 profile: email change now requires password 2024-01-18 18:11:42 +01:00
Johannes Zellner 0dfadc5922 remove extra quotes on digitalocean DNS TXT records 2024-01-17 18:35:48 +01:00
Johannes Zellner c8cd67258a dashboard: show mailbox login in eventlog correctly 2024-01-17 16:17:22 +01:00
Johannes Zellner 7499aa9201 Do not fail is we don't have a servicesConfig yet 2024-01-17 13:13:48 +01:00
Johannes Zellner 0f4ea17f29 dashboard: ensure we show postinstall also from app config screen 2024-01-16 13:54:42 +01:00
Johannes Zellner b7631689b0 Add useVectorRsExtension for postgresql service 2024-01-16 12:53:43 +01:00
Girish Ramakrishnan afe670b49c cloudflare: use response.text since json may not be valid 2024-01-16 12:34:18 +01:00
Girish Ramakrishnan ee43dff35f externalldap: reset group source when disabled 2024-01-13 22:35:23 +01:00
Girish Ramakrishnan 1faf83afe4 groups: external groups cannot be updated 2024-01-13 22:33:46 +01:00
Girish Ramakrishnan ce0b66db7d login: show error on password reset 2024-01-13 21:56:18 +01:00
Girish Ramakrishnan 01d33c45bd profile: hide password reset for external users 2024-01-13 21:45:03 +01:00
Girish Ramakrishnan 63766dd10f do not send email reset for external users 2024-01-13 21:37:02 +01:00
Girish Ramakrishnan 8771158f10 Fix test 2024-01-13 21:29:40 +01:00
Girish Ramakrishnan 46a589f794 Use BAD_STATE consistently for demo mode 2024-01-13 21:15:41 +01:00
Girish Ramakrishnan a007a8e40c externalldap: sync log history 2024-01-13 16:50:10 +01:00
Girish Ramakrishnan 6e42cf4ec5 externalldap: available on all plans
looks like an oversight that this needs a subscription
2024-01-13 16:49:35 +01:00
Girish Ramakrishnan 257dc4e271 external ldap: run syncer every 4 hours
hardcoded for now but we should make this configurable
2024-01-13 15:53:14 +01:00
Girish Ramakrishnan 4136272382 externalldap: add eventlog 2024-01-13 13:22:26 +01:00
Girish Ramakrishnan 4f9e43859c directoryserver: comments can be provided in allowlist 2024-01-13 12:54:10 +01:00
Girish Ramakrishnan b57ad9b8c1 directoryserver: allowlist always needs a single IP/range 2024-01-13 12:30:43 +01:00
Girish Ramakrishnan b8c297b178 ldap allow list is not a json 2024-01-13 12:29:00 +01:00
Girish Ramakrishnan a389b863f9 directory server: add eventlog entry 2024-01-13 12:24:28 +01:00
Girish Ramakrishnan 40c82b3e48 external directory: reset auth source when disabled
this allows existing users to login (including the owner itself)

The alternative is to have some system where we have unique superadmin users across cloudrons which don’t get trampled upon by a sync. This is a bit unrealistic. For the future, we could also design this such that ldap auth is asked for in the initial step i.e at superadmin creation time.

If LDAP connection is lost/down, user can always use 'cloudron-support —owner-login'
2024-01-13 11:51:12 +01:00
Girish Ramakrishnan 2ca94f3159 user: remove make local feature
we discussed a bit on what this does and it's confusing as it stands:

* Use case of this is lost in the realms of time
* Possible guess by is that it was to move users of different Cloudron to a central cloudron
* Currently, the design is a bit flawed because the make user local button doesn’t pin the user. The state is lost in next synchronization.
* Maybe, one should use export/import user for this use case
* Let’s disable this button for now, feature is not complete.
2024-01-13 11:02:25 +01:00
Girish Ramakrishnan 33a97d0e50 cloudflare: validate response fields 2024-01-12 14:52:24 +01:00
Girish Ramakrishnan cef0b6d0d8 test: bump retries 2024-01-11 16:31:12 +01:00
Girish Ramakrishnan 7a5e990ad4 email: rewrite loading of email status using async
we start a bunch of requests in the background for each domain. when
we switch views immediately, to say the eventlog, these requests are
still active in the background.

canceling the requests will require a much bigger refactor.

https://forum.cloudron.io/topic/10434/email-event-log-loading-very-slowly-seems-tied-to-overall-email-domain-list-health-checks
2024-01-09 17:34:54 +01:00
Girish Ramakrishnan ca31dc8d78 namecheap: fix TLD
continuation of 6cdb448f62
2024-01-09 09:44:24 +01:00
Girish Ramakrishnan 5b7667fa4d external ldap: ensure dashboard login does totp check 2024-01-08 11:55:35 +01:00
Girish Ramakrishnan 6cdb448f62 namecheap: pass the TLD correctly
this is safe because namecheap does not allow external domains to be hosted.
otherwise, we would have to use tldjs
2024-01-08 11:54:37 +01:00
Girish Ramakrishnan 053f81a53e externalldap: add tests 2024-01-07 22:04:22 +01:00
Girish Ramakrishnan c842d02d6f namecheap: slow down requests for rate limit
https://www.namecheap.com/support/knowledgebase/article.aspx/9739/63/api-faq/#z
2024-01-07 22:01:42 +01:00
Girish Ramakrishnan 4ddcd547ba directoryserver: leave it to client to decide totp check
initially, the idea was to make the server enforce it. this is more secure. however,
we have 3 kinds of clients - an external cloudron dashboard which needs totp,
an external cloudron app, which doesn't have totp and external apps that don't have totp either.

given that the directory server is IP restricted, this is a reasonable compromise until
we move wholesale to oidc.

a directoryserver setting like "enforce totp" also does not work since this policy will be
applied to all clients.
2024-01-07 20:38:36 +01:00
Girish Ramakrishnan 7bb68ea6b5 rename ldap.js to ldapserver.js
this makes it clearer it is server module and not some generic ldap thing
2024-01-06 13:31:32 +01:00
Girish Ramakrishnan e13f427267 directoryserver: 2fa validation tests 2024-01-06 13:25:12 +01:00
Girish Ramakrishnan c422e2d570 users: add tests for 2fa and relaxed 2fa 2024-01-06 13:15:55 +01:00
Girish Ramakrishnan b3f91c4868 make branding and email config available to admin 2024-01-04 21:46:46 +01:00
Johannes Zellner 19dd56c160 filemanager: Skip rename if name didn't change 2024-01-04 16:00:28 +01:00
Johannes Zellner c577d3d91f filemanager: ask user for confirmation on rename conflict 2024-01-04 15:47:26 +01:00
Johannes Zellner 4f57bed03a Update translation 2024-01-04 15:46:59 +01:00
Johannes Zellner 29663a1229 Update sftp addon 2024-01-04 11:59:56 +01:00
Johannes Zellner d9d4798f69 frontend: update dependencies 2024-01-04 11:59:48 +01:00
Girish Ramakrishnan 32d3c0b920 cloudron-support: suppress mysql message 2024-01-03 22:01:53 +01:00
Girish Ramakrishnan 2224ccab7c fix doc links 2024-01-03 21:25:37 +01:00
Johannes Zellner 8d3d3ba875 dashboard: fix crash on uninstalled app 2024-01-03 18:49:49 +01:00
Johannes Zellner 4ad2b2829b dashboard: remove console.log 2024-01-03 18:48:49 +01:00
Girish Ramakrishnan 1ca46a064c ldap: use proper error message instead of dn
the dn is already in lde_dn field of the error object.
lde_message is the message
2024-01-03 15:23:22 +01:00
Girish Ramakrishnan e42579521c Fix tests 2024-01-03 15:12:07 +01:00
Girish Ramakrishnan 96be06188b ldap: send proper error messages 2024-01-03 15:12:07 +01:00
Johannes Zellner 10172e0211 Add login busy indicator 2024-01-03 14:55:07 +01:00
Girish Ramakrishnan 70c8a5a6be directoryserver: totp check must be enforced 2024-01-03 14:40:51 +01:00
Johannes Zellner af42f150f2 Update sftp addon 2024-01-03 13:20:32 +01:00
Girish Ramakrishnan ba16fdaf60 domain: handle alias domain conflict during deletion 2024-01-02 17:18:37 +01:00
Girish Ramakrishnan c5480bfcc1 mail: update limit plugin 2024-01-02 15:50:34 +01:00
Girish Ramakrishnan 79448e9ff9 oidc: fix error message with correct username but bad password 2023-12-29 18:15:33 +01:00
Girish Ramakrishnan e49398eb47 Bump request timeout to a minute, some servers are just too slow 2023-12-29 16:19:52 +01:00
138 changed files with 7799 additions and 2362 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 2020
"ecmaVersion": 13
},
"rules": {
"linebreak-style": [
+32
View File
@@ -2724,3 +2724,35 @@
* postgres: do not clear search_path for restore
* route53: retry on rate limit errors
* update: continue with app update if box update does not start
[7.6.4]
* mail: update limit plugin
* ldap: fix error messages to show proper error messages in the external LDAP connector
* dashboard: fix various UI elements hidden for admin user
* directoryserver: fix totp validation
* email: improve loading of the mail usage to not block other views from loading
* eventlog: add events for directory server and exernal directory configuration
* externalldap: available regardless of subscription
* externalldap: show syncer log history
* externalldap: sync is now run periodically (every 4 hours)
* profile: changing email now requires password
[7.7.0]
* OIDC avatar support via picture claim
* backupcleaner: fix bug where preserved backups were removed incorrectly
* directoryserver: cloudflare warning
* oidc/ldap: fix display name parsing to send anything after first name as the last name
* mail: Update haraka to 3.0.3
* mongodb: Update mongodb to 6.0
* acme: use secp256r1 curve for max compatibility
* add port range support
* docker: disable userland proxy
* oidc: always re-setup oidc client record
* mail: update solr to 8.11.3
* mail: spam acl should allow underscore and question mark
* Fix streaming of logs with `logPaths`
* profile: store user language setting in the database
[7.7.1]
* postgresql: fix bug in loading of contrib extensions
+4 -4
View File
@@ -4,7 +4,7 @@
const constants = require('./src/constants.js'),
fs = require('fs'),
ldap = require('./src/ldap.js'),
ldapServer = require('./src/ldapserver.js'),
oidc = require('./src/oidc.js'),
paths = require('./src/paths.js'),
proxyAuth = require('./src/proxyauth.js'),
@@ -37,7 +37,7 @@ async function startServers() {
await setupLogging();
await server.start(); // do this first since it also inits the database
await proxyAuth.start();
await ldap.start();
await ldapServer.start();
const conf = await directoryServer.getConfig();
if (conf.enabled) await directoryServer.start();
@@ -62,7 +62,7 @@ async function main() {
await proxyAuth.stop();
await server.stop();
await directoryServer.stop();
await ldap.stop();
await ldapServer.stop();
await oidc.stop();
setTimeout(process.exit.bind(process), 3000);
});
@@ -73,7 +73,7 @@ async function main() {
await proxyAuth.stop();
await server.stop();
await directoryServer.stop();
await ldap.stop();
await ldapServer.stop();
await oidc.stop();
setTimeout(process.exit.bind(process), 3000);
});
Binary file not shown.
+1 -1
View File
@@ -177,7 +177,7 @@
<li><a href="#/profile"><i class="fa fa-user fa-fw"></i> {{ 'profile.title' | tr }}</a></li>
<li ng-show="user.isAtLeastMailManager" class="divider"></li>
<li ng-show="user.isAtLeastAdmin"><a href="#/backups"><i class="fa fa-archive fa-fw"></i> {{ 'backups.title' | tr }}</a></li>
<li ng-show="user.isAtLeastOwner"><a href="#/branding"><i class="fa fa-passport fa-fw"></i> {{ 'branding.title' | tr }}</a></li>
<li ng-show="user.isAtLeastAdmin"><a href="#/branding"><i class="fa fa-passport fa-fw"></i> {{ 'branding.title' | tr }}</a></li>
<li ng-show="user.isAtLeastAdmin"><a href="#/domains"><i class="fa fa-globe fa-fw"></i> {{ 'domains.title' | tr }}</a></li>
<li ng-show="user.isAtLeastMailManager"><a href="#/email"><i class="fa fa-envelope fa-fw"></i> {{ 'emails.title' | tr }}</a></li>
<li ng-show="user.isAtLeastAdmin"><a href="#/eventlog"><i class="fa fa-list-alt fa-fw"></i> {{ 'eventlog.title' | tr }}</a></li>
+89 -38
View File
@@ -453,7 +453,7 @@ angular.module('Application').filter('tr', translateFilterFactory);
// Cloudron REST API wrapper
// ----------------------------------------------
angular.module('Application').service('Client', ['$http', '$interval', '$timeout', 'md5', 'Notification', function ($http, $interval, $timeout, md5, Notification) {
angular.module('Application').service('Client', ['$http', '$interval', '$timeout', 'md5', 'Notification', '$translate', function ($http, $interval, $timeout, md5, Notification, $translate) {
var client = null;
// variable available only here to avoid this._property pattern
@@ -618,6 +618,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
twoFactorAuthenticationEnabled: false,
source: null,
avatarUrl: null,
avatarType: null,
hasBackgroundImage: false
};
this._config = {
@@ -746,7 +747,8 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
this._userInfo.twoFactorAuthenticationEnabled = userInfo.twoFactorAuthenticationEnabled;
this._userInfo.role = userInfo.role;
this._userInfo.source = userInfo.source;
this._userInfo.avatarUrl = userInfo.avatarUrl + '?s=128&default=mp&ts=' + Date.now(); // we add the timestamp to avoid caching
this._userInfo.avatarUrl = userInfo.avatarUrl + '?ts=' + Date.now(); // we add the timestamp to avoid caching
this._userInfo.avatarType = userInfo.avatarType;
this._userInfo.hasBackgroundImage = userInfo.hasBackgroundImage;
this._userInfo.isAtLeastOwner = [ ROLES.OWNER ].indexOf(userInfo.role) !== -1;
this._userInfo.isAtLeastAdmin = [ ROLES.OWNER, ROLES.ADMIN ].indexOf(userInfo.role) !== -1;
@@ -763,9 +765,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
this._config.consoleServerOrigin = '<%= appstore.consoleOrigin %>';
<% } -%>
// => This is just for easier testing
// this._config.features.externalLdap = false;
this._configListener.forEach(function (callback) {
callback(that._config);
});
@@ -818,7 +817,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.userInfo = function (callback) {
Client.prototype.getProfile = function (callback) {
get('/api/v1/profile', null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200 || typeof data !== 'object') return callback(new ClientError(status, data));
@@ -1735,8 +1734,8 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.setGroups = function (userId, groupIds, callback) {
put('/api/v1/users/' + userId + '/groups', { groupIds: groupIds }, null, function (error, data, status) {
Client.prototype.setLocalGroups = function (userId, localGroupIds, callback) {
put('/api/v1/users/' + userId + '/groups', { groupIds: localGroupIds }, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 204) return callback(new ClientError(status, data));
@@ -1766,12 +1765,12 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.updateGroup = function (id, name, callback) {
Client.prototype.setGroupName = function (id, name, callback) {
var data = {
name: name
};
post('/api/v1/groups/' + id, data, null, function (error, data, status) {
put('/api/v1/groups/' + id + '/name', data, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
@@ -2115,8 +2114,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
if (error) return callback(error);
if (status !== 200) return callback(new ClientError(status, data));
console.log(data)
callback(null, data.info);
});
};
@@ -2219,7 +2216,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
// amend properties to mimick full app
data.applinks.forEach(function (applink) {
applink.type = APP_TYPES.LINK;
applink.fqdn = applink.upstreamUri; // this fqdn may contain the protocol!
applink.fqdn = new URL(applink.upstreamUri).hostname;
applink.manifest = { addons: {}};
applink.installationState = ISTATES.INSTALLED;
applink.runState = RSTATES.RUNNING;
@@ -2278,17 +2275,26 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.updateUser = function (user, callback) {
var data = {
email: user.email,
displayName: user.displayName,
fallbackEmail: user.fallbackEmail,
active: user.active,
role: user.role
};
if (user.username) data.username = user.username;
Client.prototype.updateUserProfile = function (userId, data, callback) {
post('/api/v1/users/' + userId + '/profile', data, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 204) return callback(new ClientError(status, data));
post('/api/v1/users/' + user.id, data, null, function (error, data, status) {
callback(null);
});
};
Client.prototype.setRole = function (userId, role, callback) {
put('/api/v1/users/' + userId + '/role', { role: role }, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 204) return callback(new ClientError(status, data));
callback(null);
});
};
Client.prototype.setActive = function (userId, active, callback) {
put('/api/v1/users/' + userId + '/active', { active: active }, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 204) return callback(new ClientError(status, data));
@@ -2312,8 +2318,35 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.updateProfile = function (data, callback) {
post('/api/v1/profile', data, null, function (error, data, status) {
Client.prototype.setProfileDisplayName = function (displayName, callback) {
post('/api/v1/profile/display_name', { displayName: displayName }, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 204) return callback(new ClientError(status, data));
callback(null, data);
});
};
Client.prototype.setProfileLanguage = function (language, callback) {
post('/api/v1/profile/language', { language: language }, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 204) return callback(new ClientError(status, data));
callback(null, data);
});
};
Client.prototype.setProfileEmail = function (email, password, callback) {
post('/api/v1/profile/email', { email: email, password: password }, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 204) return callback(new ClientError(status, data));
callback(null, data);
});
};
Client.prototype.setProfileFallbackEmail = function (fallbackEmail, password, callback) {
post('/api/v1/profile/fallback_email', { fallbackEmail: fallbackEmail, password: password }, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 204) return callback(new ClientError(status, data));
@@ -2367,15 +2400,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.makeUserLocal = function (userId, callback) {
post('/api/v1/users/' + userId + '/make_local', {}, null, function (error, data, status) {
if (error) return callback(error);
if (status !== 204) return callback(new ClientError(status, data));
callback(null);
});
};
Client.prototype.changePassword = function (currentPassword, newPassword, callback) {
var data = {
password: currentPassword,
@@ -2507,14 +2531,19 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
Client.prototype.refreshUserInfo = function (callback) {
Client.prototype.refreshProfile = function (callback) {
var that = this;
callback = typeof callback === 'function' ? callback : function () {};
this.userInfo(function (error, result) {
this.getProfile(function (error, result) {
if (error) return callback(error);
if (result.language !== '' && $translate.use() !== result.language) {
console.log('Changing users language from ' + $translate.use() + ' to ', result.language);
$translate.use(result.language);
}
that.setUserInfo(result);
callback(null);
});
@@ -3574,10 +3603,14 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
var ACTION_DASHBOARD_DOMAIN_UPDATE = 'dashboard.domain.update';
var ACTION_DIRECTORY_SERVER_CONFIGURE = 'directoryserver.configure';
var ACTION_DOMAIN_ADD = 'domain.add';
var ACTION_DOMAIN_UPDATE = 'domain.update';
var ACTION_DOMAIN_REMOVE = 'domain.remove';
var ACTION_EXTERNAL_LDAP_CONFIGURE = 'externalldap.configure';
var ACTION_INSTALL_FINISH = 'cloudron.install.finish';
var ACTION_START = 'cloudron.start';
@@ -3828,6 +3861,13 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
case ACTION_DASHBOARD_DOMAIN_UPDATE:
return 'Dashboard domain set to ' + data.fqdn || (data.subdomain + '.' + data.domain);
case ACTION_DIRECTORY_SERVER_CONFIGURE:
if (data.fromEnabled !== data.toEnabled) {
return 'Directory server was ' + (data.toEnabled ? 'enabled' : 'disabled');
} else {
return 'Directory server configuration was changed';
}
case ACTION_DOMAIN_ADD:
return 'Domain ' + data.domain + ' with ' + data.provider + ' provider was added';
@@ -3837,6 +3877,13 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
case ACTION_DOMAIN_REMOVE:
return 'Domain ' + data.domain + ' was removed';
case ACTION_EXTERNAL_LDAP_CONFIGURE:
if (data.config.provider === 'noop') {
return 'External Directory disabled';
} else {
return 'External Directory set to ' + data.config.url + ' (' + data.config.provider + ')';
}
case ACTION_INSTALL_FINISH:
return 'Cloudron version ' + data.version + ' installed';
@@ -3906,8 +3953,12 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
return 'Apps of ' + data.oldOwnerId + ' was transferred to ' + data.newOwnerId;
case ACTION_USER_LOGIN:
app = this.getCachedAppSync(data.appId);
return 'User ' + (data.user ? data.user.username : data.userId) + ' logged in to ' + (app ? app.fqdn : data.appId);
if (data.mailboxId) {
return 'User ' + (data.user ? data.user.username : data.userId) + ' logged in to mailbox ' + data.mailboxId;
} else {
app = this.getCachedAppSync(data.appId);
return 'User ' + (data.user ? data.user.username : data.userId) + ' logged in to ' + (app ? app.fqdn : data.appId);
}
case ACTION_USER_LOGIN_GHOST:
return 'User ' + (data.user ? data.user.username : data.userId) + ' was impersonated';
+26 -32
View File
@@ -2,6 +2,7 @@
/* global angular:false */
/* global $:false */
/* global async */
/* global ERROR,ISTATES,HSTATES,RSTATES,APP_TYPES,NOTIFICATION_TYPES */
// deal with accessToken in the query, this is passed for example on password reset and account setup upon invite
@@ -118,7 +119,7 @@ app.config(['$routeProvider', function ($routeProvider) {
}).otherwise({ redirectTo: '/'});
}]);
app.filter('notificadtionTypeToColor', function () {
app.filter('notificationTypeToColor', function () {
return function (n) {
switch (n.type) {
case NOTIFICATION_TYPES.ALERT_REBOOT:
@@ -785,47 +786,40 @@ app.controller('MainController', ['$scope', '$route', '$timeout', '$location', '
console.log('Running dashboard version ', localStorage.version);
// get user profile as the first thing. this populates the "scope" and affects subsequent API calls
Client.refreshUserInfo(function (error) {
async.series([
Client.refreshProfile.bind(Client),
Client.refreshConfig.bind(Client),
Client.refreshAvailableLanguages.bind(Client),
Client.refreshInstalledApps.bind(Client)
], function (error) {
if (error) return Client.initError(error, init);
Client.refreshConfig(function (error) {
if (error) return Client.initError(error, init);
// now mark the Client to be ready
Client.setReady();
Client.refreshAvailableLanguages(function (error) {
if (error) return Client.initError(error, init);
$scope.config = Client.getConfig();
Client.refreshInstalledApps(function (error) {
if (error) return Client.initError(error, init);
if (Client.getUserInfo().hasBackgroundImage) {
document.getElementById('mainContentContainer').style.backgroundImage = 'url("' + Client.getBackgroundImageUrl() + '")';
document.getElementById('mainContentContainer').classList.add('has-background');
}
// now mark the Client to be ready
Client.setReady();
$scope.initialized = true;
$scope.config = Client.getConfig();
redirectOnMandatory2FA();
if (Client.getUserInfo().hasBackgroundImage) {
document.getElementById('mainContentContainer').style.backgroundImage = 'url("' + Client.getBackgroundImageUrl() + '")';
document.getElementById('mainContentContainer').classList.add('has-background');
}
$interval(refreshNotifications, 60 * 1000);
refreshNotifications();
$scope.initialized = true;
Client.getSubscription(function (error, subscription) {
if (error && error.statusCode === 412) return; // not yet registered
if (error && error.statusCode === 402) return; // invalid appstore token
if (error) return console.error(error);
redirectOnMandatory2FA();
$scope.subscription = subscription;
$interval(refreshNotifications, 60 * 1000);
refreshNotifications();
Client.getSubscription(function (error, subscription) {
if (error && error.statusCode === 412) return; // not yet registered
if (error && error.statusCode === 402) return; // invalid appstore token
if (error) return console.error(error);
$scope.subscription = subscription;
// only track platform status if we are registered
trackPlatformStatus();
});
});
});
// only track platform status if we are registered
trackPlatformStatus();
});
});
});
+2 -1
View File
@@ -81,7 +81,8 @@ app.controller('PasswordResetController', ['$scope', '$translate', '$http', func
identifier: $scope.passwordResetIdentifier
};
function done() {
function done(error) {
if (error) $scope.error = error.message;
$scope.busy = false;
$scope.mode = 'passwordResetDone';
}
+1 -1
View File
@@ -212,7 +212,7 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f
} else if (provider === 'linode') {
config.token = $scope.dnsCredentials.linodeToken;
} else if (provider === 'bunny') {
config.token = $scope.dnsCredentials.bunnyAccessKey;
config.accessKey = $scope.dnsCredentials.bunnyAccessKey;
} else if (provider === 'dnsimple') {
config.accessToken = $scope.dnsCredentials.dnsimpleAccessToken;
} else if (provider === 'hetzner') {
+2 -1
View File
@@ -89,7 +89,8 @@
<div class="col-md-12" style="text-align: center;">
<img width="128" height="128" style="margin-top: -84px" src="<%= apiOrigin %>/api/v1/cloudron/avatar"/>
<br/>
<h2>{{ 'passwordReset.emailSent.title' | tr }}</h2>
<h2 ng-hide="error">{{ 'passwordReset.emailSent.title' | tr }}</h2>
<h4 ng-show="error" class="has-error">{{ error }}</h4>
<br/>
<a href="/" class="btn btn-primary">{{ 'passwordReset.backToLoginAction' | tr }}</a>
</div>
+2 -2
View File
@@ -274,9 +274,9 @@
</div>
<div class="form-group" ng-class="{ 'has-error': error.remotePath }">
<label class="control-label" for="inputConfigureRemotePath">Backup Path</label>
<label class="control-label" for="inputConfigureRemotePath">Backup Path<sup><a ng-href="https://docs.cloudron.io/backups/#restore-cloudron" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="text" class="form-control" ng-model="remotePath" name="inputConfigureBackupId" placeholder="Backup Path" required ng-disabled="busy">
<input type="text" class="form-control" ng-model="remotePath" name="inputConfigureBackupId" placeholder="e.g. 2024-02-20-130007-637/box_v7.4.3.tar.gz" required ng-disabled="busy">
</div>
<div class="form-group" ng-class="{ 'has-error': error.key }">
+4 -1
View File
@@ -50,7 +50,7 @@
<label class="control-label" for="inputTotpToken">{{ login.2faToken }}</label>
<input type="text" class="form-control" name="totpToken" id="inputTotpToken" value="">
</div>
<button class="btn btn-primary btn-outline pull-right" type="submit" id="login">{{ login.signInAction }}</button>
<button class="btn btn-primary btn-outline pull-right" type="submit" id="login"><i id="busyIndicator" class="hide fa fa-circle-notch fa-spin"></i> {{ login.signInAction }}</button>
</form>
<!-- <a ng-href="" class="hand" ng-click="showPasswordReset()">Reset password</a> -->
</div>
@@ -67,6 +67,8 @@
var password = document.getElementById('inputPassword').value;
var totpToken = document.getElementById('inputTotpToken').value;
document.getElementById('busyIndicator').classList.remove('hide');
fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -75,6 +77,7 @@
}).then(function (response) {
if (response.status === 401 || response.status === 403) {
document.getElementById('message').innerText = "{{ login.errorIncorrectCredentials }}"; // FIXME this needs proper escaping for translated strings, single quotes break easily!
document.getElementById('busyIndicator').classList.add('hide');
return;
}
+6
View File
@@ -65,6 +65,12 @@ $state-danger-border: $brand-danger;
src: url(3rdparty/Roboto-Light.ttf);
}
@font-face {
font-family: Roboto;
font-weight: 700;
src: url(3rdparty/Roboto-Bold.ttf);
}
// ----------------------------
// Bootstrap extension
// ----------------------------
-8
View File
@@ -182,7 +182,6 @@
"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.",
"bindUsername": "Bind DN/Benyttelsesnavn (valgfrit)",
"subscriptionRequired": "Denne funktion er kun tilgængelig i de betalte abonnementer.",
"subscriptionRequiredAction": "Oprettelse af abonnement nu",
"noopInfo": "LDAP-godkendelse er ikke konfigureret.",
"provider": "Udbyder",
@@ -271,12 +270,6 @@
"failed": "Følgende brugere blev ikke importeret:",
"sendInviteCheckbox": "Send en e-mail med invitation til importerede brugere"
},
"makeLocalDialog": {
"description": "Dette vil migrere brugeren fra den eksterne mappe til Cloudron.",
"title": "Gør denne bruger lokal",
"warning": "En nulstilling af adgangskode vil blive iværksat for at indstille en lokal adgangskode for denne bruger.",
"submitAction": "Gør lokale"
},
"title": "Brugerkatalog",
"newUserAction": "Ny bruger",
"users": {
@@ -296,7 +289,6 @@
"invitationTooltip": "Inviter bruger",
"mailmanagerTooltip": "Denne bruger kan administrere brugere og postkasser",
"count": "Antal brugere i alt: {{ count }}",
"makeLocalTooltip": "Gør brugeren lokal",
"setGhostTooltip": "Udgive sig for at være"
},
"groups": {
+2 -10
View File
@@ -230,7 +230,6 @@
"provider": "Anbieter",
"noopInfo": "LDAP Authentifizierung ist nicht konfiguriert.",
"subscriptionRequiredAction": "Abonnenement jetzt abschließen",
"subscriptionRequired": "Diese Funktion ist nur im Abo enthalten.",
"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.",
"title": "Verbinde ein externes Verzeichnis",
"providerOther": "Sonstige",
@@ -269,8 +268,7 @@
"invitationTooltip": "User einladen",
"mailmanagerTooltip": "Dieser User kann Benutzer und Postfächer verwalten.",
"setGhostTooltip": "Als anderer User ausgeben",
"count": "User insgesamt: {{ count }}",
"makeLocalTooltip": "Mache user lokal"
"count": "User insgesamt: {{ count }}"
},
"newUserAction": "Neuer User",
"role": {
@@ -421,12 +419,6 @@
"all": "Alle User",
"active": "Aktive User",
"inactive": "Inaktive User"
},
"makeLocalDialog": {
"description": "Dies migriert den User vom externen Verzeichnis zum Cloudron.",
"warning": "Das Passwort wird zurückgesetzt um dem User ein lokale Passwort zu geben.",
"title": "Mache den Benutzer lokal",
"submitAction": "Änderungen lokal speichern"
}
},
"profile": {
@@ -1074,7 +1066,7 @@
"welcomeEmail": {
"welcomeTo": "Willkommen bei <%= cloudronName %>!",
"subject": "Willkommen bei <%= cloudron %>",
"inviteLinkActionText": "Öffnen den folgenden Link um dich anzumelden: <%- inviteLink %>",
"inviteLinkActionText": "Öffne den folgenden Link, um dich anzumelden: <%- inviteLink %>",
"expireNote": "Dieser Link ist 7 Tage gültig.",
"invitor": "Diese Email wurde geschickt, weil Du von <%= invitor %> eingeladen wurdest.",
"inviteLinkAction": "Starte hier",
+19 -19
View File
@@ -201,8 +201,7 @@
"invitationTooltip": "Invite User",
"setGhostTooltip": "Impersonate",
"mailmanagerTooltip": "This user can manage users and mailboxes",
"count": "Total users: {{ count }}",
"makeLocalTooltip": "Make user local"
"count": "Total users: {{ count }}"
},
"groups": {
"title": "Groups",
@@ -222,8 +221,7 @@
},
"externalLdap": {
"title": "Connect an External Directory",
"description": "Cloudron will synchronize users and groups from an external LDAP or ActiveDirectory server. Password verification for authenticating those users is done against the external server. The synchronization is not run automatically but needs to be triggered manually.",
"subscriptionRequired": "This feature is only available in the paid plans.",
"description": "This setting will synchronize and authenticate users and groups from an external LDAP or Active Directory server. The synchronization is run periodically but can also be triggered manually.",
"subscriptionRequiredAction": "Set up Subscription Now",
"noopInfo": "LDAP authentication is not configured.",
"provider": "Provider",
@@ -237,15 +235,16 @@
"groupFilter": "Group Filter",
"groupnameField": "Groupname Field",
"auth": "Auth",
"autocreateUsersOnLogin": "Automatically create users when they login to Cloudron",
"autocreateUsersOnLogin": "Automatically create users on login",
"showLogsAction": "Show Logs",
"syncAction": "Synchronize",
"syncAction": "Sync",
"configureAction": "Configure",
"bindUsername": "Bind DN/Username (optional)",
"bindPassword": "Bind Password (optional)",
"errorSelfSignedCert": "Server is using an invalid or self-signed certificate.",
"providerOther": "Other",
"providerDisabled": "Disabled"
"providerDisabled": "Disabled",
"disableWarning": "The authentication source of all existing users will be reset to authenticate against the local password database."
},
"subscriptionDialog": {
"title": "Subscription required",
@@ -274,7 +273,9 @@
"errorDisplayNameRequired": "Name is required",
"activeCheckbox": "User is active",
"displayNamePlaceholder": "Optional. If not provided, user can provide during sign up",
"fallbackEmailPlaceholder": "Optional. If not specified, primary email will be used"
"fallbackEmailPlaceholder": "Optional. If not specified, primary email will be used",
"external2FA": "2FA setup is managed by external authentication source",
"ldapGroups": "LDAP Groups"
},
"deleteUserDialog": {
"title": "Delete user {{ username }}",
@@ -363,7 +364,7 @@
"description": "Cloudron can act as a central user directory server for external applications.",
"enabled": "Enabled",
"ipRestriction": {
"description": "The directory server can be limited to specific IPs or ranges.",
"description": "Limit Directory Server access to specific IPs or ranges. Lines starting with <code>#</code> are treated as comments.",
"placeholder": "Line separated IP address or Subnet",
"label": "Restrict Access"
},
@@ -371,7 +372,8 @@
"label": "Bind Password",
"description": "All LDAP queries have to be authenticated with this secret and the user DN <i>{{ userDN }}</i>",
"url": "Server URL"
}
},
"cloudflarePortWarning": "Cloudflare proxying must be disabled on the dashboard domain to access the LDAP server"
},
"userImportDialog": {
"title": "Import Users",
@@ -395,12 +397,6 @@
"all": "All Users",
"active": "Active Users",
"inactive": "Inactive Users"
},
"makeLocalDialog": {
"title": "Make this user local",
"description": "This will migrate the user from the external directory to the Cloudron.",
"warning": "A password reset will be initiated to set a local password for this user.",
"submitAction": "Make local"
}
},
"profile": {
@@ -467,7 +463,10 @@
"changeEmail": {
"title": "Change primary email address",
"errorEmailInvalid": "The Email address is not valid",
"errorEmailRequired": "A valid email address is required"
"errorEmailRequired": "A valid email address is required",
"email": "New Email Address",
"password": "Password for confirmation",
"errorWrongPassword": "Wrong password"
},
"changeFallbackEmail": {
"title": "Change password recovery email address",
@@ -859,7 +858,7 @@
"cloudronId": "Cloudron ID",
"subscriptionEndsAt": "Canceled and ends on",
"subscriptionSetupAction": "Upgrade to Premium",
"subscriptionChangeAction": "Change Subscription",
"subscriptionChangeAction": "Manage Subscription",
"subscriptionReactivateAction": "Reactivate Subscription",
"emailNotVerified": "Email not yet verified"
},
@@ -1155,7 +1154,8 @@
"renameDialog": {
"title": "Rename {{ fileName }}",
"newName": "New Name",
"rename": "Rename"
"rename": "Rename",
"reallyOverwrite": "A file with that name already exists. Overwrite existing file?"
},
"chownDialog": {
"title": "Change ownership",
+31 -14
View File
@@ -72,7 +72,10 @@
"email": "Email",
"description": "Esta cuenta se usa para acceder a la App Store y administrar tu suscripción",
"titleLogin": "Iniciar sesión en Cloudron.io",
"titleSignUp": "Regístrate en Cloudron.io"
"titleSignUp": "Regístrate en Cloudron.io",
"setupWithTokenAction": "Ajustes",
"setupToken": "Configurar Token",
"titleToken": "Registrarse con el token de configuración"
},
"appNotFoundDialog": {
"description": "No hay aplicación <b>{{ appId }}</b> con versión <b>{{ version }}</b>.",
@@ -163,7 +166,7 @@
"description": "¿Qué te parece si instalas algunas? Echa un vistazo a la <a href=\"{{ appStoreLink }}\"> Tienda de Aplicaciones</a>",
"title": "¡No hay aplicaciones instaladas todavía!"
},
"title": "Mis aplicaciones",
"title": "Mis Aplicaciones",
"groupsFilterHeader": "Todos los Grupos",
"auth": {
"nosso": "Inicia sesión con una cuenta dedicada",
@@ -208,7 +211,6 @@
"provider": "Proveedor",
"noopInfo": "La autentificación LDAP no está configurada.",
"subscriptionRequiredAction": "Configura tu Suscripción Ahora",
"subscriptionRequired": "Esta característica solo está habilitada en planes de pago.",
"description": "Cloudron sincronizará usuarios y grupos desde un servidor LDAP o ActiveDirectory externo. La verificación de la contraseña para autentificar a esos usuarios se realiza en el servidor externo. La sincronización no se ejecuta automáticamente, sino que debe activarse manualmente.",
"title": "Conectar un directorio externo",
"auth": "Auth",
@@ -248,7 +250,6 @@
"setGhostTooltip": "Suplantar",
"invitationTooltip": "Invitar Usuario",
"mailmanagerTooltip": "Este usuario puede administrar usuarios y buzones de correo",
"makeLocalTooltip": "Hacer que el usuario sea local",
"count": "Total usuarios: {{ count }}"
},
"newUserAction": "Nuevo Usuario",
@@ -392,12 +393,6 @@
"all": "Todos los Usuarios",
"active": "Usuarios Activos",
"inactive": "Usuarios Inactivos"
},
"makeLocalDialog": {
"title": "Hacer este usuario local",
"description": "Esto migrará el usuario desde un directorio externo a Cloudron.",
"submitAction": "Hacer local",
"warning": "Se iniciará un restablecimiento de contraseña para establecer una contraseña local para este usuario."
}
},
"backups": {
@@ -975,7 +970,12 @@
"bunnyAccessKey": "Clave de acceso Bunny",
"cloudflareDefaultProxyStatus": "Habilitar proxy para nuevos registros DNS",
"porkbunApikey": "Clave API",
"porkbunSecretapikey": "Clave API secreta"
"porkbunSecretapikey": "Clave API secreta",
"dnsimpleAccessToken": "Token de acceso",
"ovhEndpoint": "Punto final",
"ovhConsumerKey": "Clave del consumidor",
"ovhAppKey": "Clave de Aplicación",
"ovhAppSecret": "Clave Secreta Aplicación"
},
"subscriptionRequired": {
"setupAction": "Configura tu suscripción",
@@ -1389,7 +1389,19 @@
"volumeContent": "Este disco es el volumen <code>{{ name }}</code>",
"diskSpeed": "Velocidad: {{ speed }} MB/seg"
},
"selectPeriodLabel": "Seleccionar Periodo"
"selectPeriodLabel": "Seleccionar Periodo",
"info": {
"title": "Información",
"memory": "Memoria",
"uptime": "Tiempo de actividad",
"activationTime": "Tiempo de creación de Cloudron",
"platformVersion": "Versión de plataforma",
"product": "Producto",
"vendor": "Vendedor"
},
"graphs": {
"title": "Gráficos"
}
},
"support": {
"remoteSupport": {
@@ -1421,7 +1433,11 @@
"emailNotVerified": "El correo electrónico de su cuenta cloudron.io {{email}} no está verificado. Verifíquelo para abrir tickets de soporte.",
"typeBilling": "Problema de facturación"
},
"title": "Soporte"
"title": "Soporte",
"help": {
"title": "Ayuda",
"description": "Utiliza los siguientes recursos para obtener ayuda y soporte:\n* [Foro de Cloudron]({{ forumLink }}) - Utiliza las categorías específicas de Soporte y Aplicación si tiene preguntas.\n* [Base de conocimientos y documentos de Cloudron]({{ docsLink }})\n* [API y empaquetado de aplicaciones personalizadas]({{ packagingLink }})\n"
}
},
"volumes": {
"removeVolumeDialog": {
@@ -1496,7 +1512,8 @@
"renameDialog": {
"title": "Renombrar {{ fileName }}",
"newName": "Nuevo Nombre",
"rename": "Renombrar"
"rename": "Renombrar",
"reallyOverwrite": "Ya existe un archivo con ese nombre. ¿Sobrescribir el archivo existente?"
},
"chownDialog": {
"newOwner": "Nuevo propietario",
+21 -18
View File
@@ -22,7 +22,8 @@
"auth": {
"nosso": "Se connecter avec un compte dédié",
"email": "Se connecter avec une adresse email",
"sso": "Se connecter avec vos identifiants Cloudron"
"sso": "Se connecter avec vos identifiants Cloudron",
"openid": "Se connecter avec Cloudron OpenID"
},
"addAppAction": "Ajouter Application",
"addAppproxyAction": "Ajouter Proxy d'application",
@@ -39,7 +40,8 @@
"cancel": "Annuler",
"save": "Sauvegarder",
"no": "Non",
"yes": "Oui"
"yes": "Oui",
"delete": "Supprimer"
},
"username": "Nom d'utilisateur",
"actions": "Actions",
@@ -55,7 +57,8 @@
},
"action": {
"logs": "Journaux",
"reboot": "Redémarrer"
"reboot": "Redémarrer",
"showLogs": "Afficher Journaux"
},
"rebootDialog": {
"rebootAction": "Redémarrer maintenant",
@@ -87,7 +90,9 @@
},
"disableAction": "Désactiver",
"enableAction": "Activer",
"loadingPlaceholder": "Chargement"
"loadingPlaceholder": "Chargement",
"settings": "Paramètres",
"saveAction": "Sauvegarde"
},
"users": {
"title": "Annuaire des utilisateurs",
@@ -108,8 +113,7 @@
"setGhostTooltip": "Emprunter l'identité",
"invitationTooltip": "Envoyer une invitation à l'utilisateur",
"mailmanagerTooltip": "Cet utilisateur peut gérer les utilisateurs et les boîtes mail",
"count": "Total des utilisateurs : {{ count }}",
"makeLocalTooltip": "Rendre l'utilisateur local"
"count": "Total des utilisateurs : {{ count }}"
},
"newUserAction": "Nouvel utilisateur",
"groups": {
@@ -120,7 +124,7 @@
"externalLdapTooltip": "Depuis un annuaire LDAP externe"
},
"settings": {
"title": "Paramètres",
"title": "Paramètres Utilisateur",
"allowProfileEditCheckbox": "Autoriser les utilisateurs à modifier leur nom et leur adresse email",
"saveAction": "Enregistrer",
"subscriptionRequired": "Ces fonctionnalités sont uniquement disponibles dans la version payante.",
@@ -140,7 +144,6 @@
"groupnameField": "Champ nom du groupe",
"syncGroups": "Groupes synchronisés",
"filter": "Filtre",
"subscriptionRequired": "Cette fonctionnalité est disponible uniquement dans la version payante.",
"acceptSelfSignedCert": "Accepter le certificat auto-signé",
"usernameField": "Champ nom d'utilisateur",
"groupFilter": "Filtre des groupes",
@@ -266,12 +269,6 @@
"title": "Lien d'invitation envoyé",
"body": "Email envoyé à {{ email }}"
},
"makeLocalDialog": {
"description": "Cela migrera l'utilisateur du répertoire externe vers le Cloudron.",
"submitAction": "Rendre local",
"title": "Rendre cet utilisateur local",
"warning": "Une réinitialisation du mot de passe sera initiée pour définir un mot de passe local pour cet utilisateur."
},
"exposedLdap": {
"secret": {
"label": "Mot de passe de liaison",
@@ -502,7 +499,8 @@
"port": "Port",
"cifsSealSupport": "Utilisez le cryptage du sceau. Nécessite au moins SMB v3",
"chown": "Le système de fichiers distant prend en charge chown",
"encryptedFilenames": "Crypter les noms de fichiers"
"encryptedFilenames": "Crypter les noms de fichiers",
"encryptFilenames": "Fichiers Cryptés"
},
"backupDetails": {
"title": "Informations sur la sauvegarde",
@@ -544,7 +542,8 @@
"preserved": {
"description": "Sauvegarde persistante quelle que soit la politique de rétention",
"tooltip": "Cela préservera également le courrier et les sauvegardes d'application {{ appsLength }}."
}
},
"remotePath": "Répertoire Distant"
}
},
"emails": {
@@ -600,7 +599,8 @@
"solrEnabled": "Activé",
"solrRunning": "Actif",
"acl": "Adresse ACL (liste de contrôle d'accès)",
"aclOverview": "{{ dnsblZonesCount }} liste(s) DNSBL"
"aclOverview": "{{ dnsblZonesCount }} liste(s) DNSBL",
"virtualAllMail": "Dossier \"Tout les Emails\""
},
"domains": {
"disabled": "Désactivé",
@@ -859,7 +859,10 @@
"titleLogin": "Se connecter à Cloudron.io",
"description": "Ce compte permet d'accéder à l'App Store et de gérer votre abonnement",
"2faToken": "Jeton 2FA (si activé)",
"intendedUse": "Type d'usage"
"intendedUse": "Type d'usage",
"setupWithTokenAction": "Configuration",
"setupToken": "Configuration Jeton",
"titleToken": "Se connecter avec un Jeton"
},
"title": "App Store",
"appNotFoundDialog": {
+22
View File
@@ -0,0 +1,22 @@
{
"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"
}
}
}
-1
View File
@@ -932,7 +932,6 @@
"server": "URL del Server",
"noopInfo": "L'autenticazione LDAP non è configurata.",
"subscriptionRequiredAction": "Attiva un piano a pagamento",
"subscriptionRequired": "Questa funzionalità è disponibile solo nei piani a pagamento.",
"description": "Cloudron sincronizzerà utenti e gruppi da un server LDAP o ActiveDirectory esterni. La verifica della password per l'autenticazione di tali utenti viene eseguita sul server esterno. La sincronizzazione non viene eseguita automaticamente ma deve essere attivata manualmente.",
"auth": "Auth",
"groupnameField": "Campo Groupname",
+22 -23
View File
@@ -201,8 +201,7 @@
"invitationTooltip": "Gebruiker uitnodigen",
"setGhostTooltip": "Nabootsen",
"mailmanagerTooltip": "Deze gebruiker kan gebruikers en mailboxen beheren",
"count": "Totaal gebruikers: {{ count }}",
"makeLocalTooltip": "Maak gebruiker lokaal"
"count": "Totaal gebruikers: {{ count }}"
},
"groups": {
"title": "Groepen",
@@ -222,7 +221,6 @@
},
"externalLdap": {
"title": "Verbind met een externe lijst",
"subscriptionRequired": "Deze functie is alleen beschikbaar voor betaalde abonnementen.",
"subscriptionRequiredAction": "Neem nu een abonnement",
"noopInfo": "LDAP authenticatie is niet geconfigureerd.",
"provider": "Aanbieder",
@@ -236,16 +234,17 @@
"groupnameField": "Veld voor groepsnaam",
"server": "Server URL",
"showLogsAction": "Toon logbestanden",
"syncAction": "Synchroniseer",
"syncAction": "Sync",
"configureAction": "Configureer",
"bindUsername": "Bind DN/Username (optioneel)",
"bindPassword": "Bind Password (optioneel)",
"errorSelfSignedCert": "Server gebruikt een ongeldig of zelf-ondertekend certificaat.",
"description": "Cloudron synchroniseert gebruikers en groepen van een extern LDAP of ActiveDirectory server. Wachtwoordverificatie vindt plaats door de externe server. De synchronisatie is niet automatisch en dient handmatig gestart te worden.",
"description": "Deze instelling synchroniseert en authentificeert gebruikers en groepen van een extern LDAP of ActiveDirectory server. De synchronisatie is periodiek maar kan ook handmatig gestart worden.",
"auth": "Authenticatie",
"autocreateUsersOnLogin": "Maak automatisch gebruikers aan na inloggen bij deze Cloudron",
"autocreateUsersOnLogin": "Maak automatisch gebruikers bij inloggen",
"providerOther": "Anders",
"providerDisabled": "Uitgeschakeld"
"providerDisabled": "Uitgeschakeld",
"disableWarning": "De authentificatie-bron van alle bestaande gebruikers zal worden omgezet naar authentificatie via de lokale wachtwoord database."
},
"subscriptionDialog": {
"title": "Abonnement benodigd",
@@ -274,7 +273,8 @@
"errorInvalidUsername": "Dit is geen geldige gebruikersnaam",
"usernamePlaceholder": "Optioneel. Indien niet ingevuld mag de gebruiker bij registratie zelf kiezen",
"fallbackEmailPlaceholder": "Optioneel. Indien niet ingevoerd zal de primaire e-mail gebruikt worden",
"displayNamePlaceholder": "Optioneel. Indien niet ingevoerd kan de gebruiker het kiezen tijdens eerste aanmelding"
"displayNamePlaceholder": "Optioneel. Indien niet ingevoerd kan de gebruiker het kiezen tijdens eerste aanmelding",
"external2FA": "2FA instellingen worden beheerd door een externe authenticatie bron"
},
"deleteUserDialog": {
"deleteAction": "Verwijder",
@@ -361,17 +361,18 @@
"exposedLdap": {
"ipRestriction": {
"placeholder": "Regelgescheiden IP adres of Subnet",
"description": "De lijstserver kan beperkt worden tot specifieke IP's of bereiken.",
"description": "Beperk de toegang tot de Directory Server tot specifieke IP's of bereiken. Regels die starten met <code>#</code> worden beschouwd als commentaar.",
"label": "Beperk toegang"
},
"enabled": "Ingeschakeld",
"title": "Lijst server",
"description": "Cloudron kan ingezet worden als gebruikerslijstserver voor externe applicaties.",
"title": "Directory Server",
"description": "Cloudron kan ingezet worden als gebruikers Directory Server voor externe applicaties.",
"secret": {
"label": "Koppel wachtwoord",
"description": "Alle LDAP verzoeken moeten geauthentiseerd worden met dit geheim en de gebruiker DN <i>{{ userDN }}</i>",
"url": "Server URL"
}
},
"cloudflarePortWarning": "Cloudflare proxy moet uitgeschakeld zijn op het domein van het dashboard om de LDAP server te kunnen bereiken"
},
"userImportDialog": {
"title": "Importeer gebruikers",
@@ -395,12 +396,6 @@
"all": "Alle gebruikers",
"active": "Actieve gebruikers",
"inactive": "Inactieve gebruikers"
},
"makeLocalDialog": {
"title": "Maak deze gebruiker lokaal",
"description": "De gebruiker wordt hiermee gemigreerd van de externe gebruikerslijst naar die van Cloudron.",
"warning": "Een wachtwoord herstel wordt geïnitieerd om een lokaal wachtwoord in te stellen voor deze gebruiker.",
"submitAction": "Maak lokaal"
}
},
"profile": {
@@ -467,7 +462,10 @@
"changeEmail": {
"title": "Primair e-mailadres aanpassen",
"errorEmailInvalid": "Het e-mailadres is niet geldig",
"errorEmailRequired": "Een geldig e-mailadres is verplicht"
"errorEmailRequired": "Een geldig e-mailadres is verplicht",
"email": "Nieuw e-mailadres",
"errorWrongPassword": "Onjuist wachtwoord",
"password": "Wachtwoord ter bevestiging"
},
"changeFallbackEmail": {
"errorEmailRequired": "Een geldig e-mailadres is verplicht",
@@ -1294,7 +1292,7 @@
"cloudronId": "Cloudron ID",
"subscriptionEndsAt": "Opgezegd en eindigt op",
"subscriptionSetupAction": "Upgrade naar Premium",
"subscriptionChangeAction": "Abonnement wijzigen",
"subscriptionChangeAction": "Beheer abonnement",
"subscriptionReactivateAction": "Abonnement heractiveren",
"title": "Cloudron.io Account",
"description": "Een Cloudron.io account wordt gebruikt voor toegang tot de App Store en om je abonnement te beheren.",
@@ -1393,7 +1391,7 @@
},
"help": {
"title": "Hulp",
"description": "Om problemen op te lossen met Cloudron hebben we verschillende bronnen:\n* [Kennisbank & App Docs]({{ docsLink }})\n* [Eigen App Packaging & API]({{ packagingLink }})\n* [Forum]({{ forumLink }})"
"description": "Gebruik de volgende bronnen voor hulp en ondersteuning:\n* [Cloudron Forum]({{ forumLink }}) - Gebruik de Support en App specifieke categorieën voor vragen.\n* [Cloudron Docs & Knowledge Base]({{ docsLink }})\n* [Custom App Packaging & API]({{ packagingLink }})\n"
}
},
"system": {
@@ -1497,7 +1495,8 @@
"renameDialog": {
"title": "Hernoem {{ fileName }}",
"newName": "Nieuwe naam",
"rename": "Hernoem"
"rename": "Hernoem",
"reallyOverwrite": "Een bestand met die naam bestaat al. Wil je het bestaande bestand overschrijven?"
},
"chownDialog": {
"newOwner": "Nieuwe eigenaar",
@@ -1729,7 +1728,7 @@
},
"addMailinglistDialog": {
"title": "Maillijst toevoegen",
"members": "Lijst leden",
"members": "Ledenlijst",
"membersInfo": "Plaats meerdere e-mailadressen elk op een nieuwe regel",
"membersOnlyCheckbox": "Het versturen van e-mail aan deze lijst beperken tot de leden",
"name": "Naam"
+1 -9
View File
@@ -196,8 +196,7 @@
"invitationTooltip": "Пригласить пользователя",
"setGhostTooltip": "Обезличить",
"mailmanagerTooltip": "Этот пользователь может управлять другими пользователями и почтовыми ящиками",
"count": "Всего пользователей: {{ count }}",
"makeLocalTooltip": "Сделать пользователя локальным"
"count": "Всего пользователей: {{ count }}"
},
"title": "Каталог пользователей",
"newUserAction": "Новый пользователь",
@@ -222,7 +221,6 @@
"bindPassword": "Привязать пароль (необязательно)",
"bindUsername": "Привязать Уникальное имя (DN)/Имя пользователя (необязательно)",
"title": "Подключиться к удалённому каталогу",
"subscriptionRequired": "Данная функция доступна только в платной подписке.",
"subscriptionRequiredAction": "Настроить подписку сейчас",
"noopInfo": "LDAP аутентификация не настроена.",
"provider": "Поставщик",
@@ -392,12 +390,6 @@
"all": "Все пользователи",
"active": "Активные пользователи",
"inactive": "Неактивные пользователи"
},
"makeLocalDialog": {
"title": "Установить этого пользователя локально",
"description": "Данное действие перенесёт пользователя с внешней директории LDAP в Cloudron.",
"warning": "Для создания локального пароля пользователя его прежний пароль будет сброшен.",
"submitAction": "Сделать локальным"
}
},
"profile": {
+118 -29
View File
@@ -54,7 +54,8 @@
},
"action": {
"reboot": "Khởi động lại",
"logs": "Log"
"logs": "Log",
"showLogs": "Hiển thị log"
},
"clipboard": {
"clickToCopy": "Bấm để copy",
@@ -89,7 +90,8 @@
"enableAction": "Bật",
"disableAction": "Tắt",
"loadingPlaceholder": "Đang tải",
"settings": "Cài đặt"
"settings": "Cài đặt",
"saveAction": "Lưu"
},
"appstore": {
"title": "Cửa hàng App",
@@ -237,7 +239,6 @@
"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.",
"title": "Kết nối thư mục ngoài",
"subscriptionRequired": "Tính năng này chỉ có trong gói trả phí.",
"providerOther": "Khác",
"providerDisabled": "Đã tắt"
},
@@ -258,8 +259,7 @@
"invitationTooltip": "Mời Người dùng",
"setGhostTooltip": "Nhập vai",
"count": "Tổng ng dùng: {{ count }}",
"mailmanagerTooltip": "Người dùng này có thể quản lý những ng dùng khác và cả những hộp thư",
"makeLocalTooltip": "Người dùng địa phương"
"mailmanagerTooltip": "Người dùng này có thể quản lý những ng dùng khác và cả những hộp thư"
},
"settings": {
"saveAction": "Lưu",
@@ -364,12 +364,6 @@
"all": "Tất cả Người dùng",
"active": "Những người dùng đang hoạt động"
},
"makeLocalDialog": {
"description": "Chức năng này sẽ di chuyển người dùng từ chỉ mục ngoài vào trong Cloudron.",
"title": "Người dùng địa phương",
"warning": "Phần đặt lại mật khẩu sẽ được kích hoạt để đặt một mật khẩu địa phương cho người dùng này.",
"submitAction": "Địa phương hoá"
},
"setGhostDialog": {
"generatePassword": "Tạo mật khẩu",
"title": "Tạo mật khẩu để nhập vai người dùng {{ username }}",
@@ -641,7 +635,9 @@
"password": "Mật khẩu",
"username": "Tên đăng nhập",
"errorIncorrectCredentials": "Không đúng tên đăng nhập hoặc mật khẩu",
"loginTo": "Đăng nhập vào"
"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"
},
"setupAccount": {
"username": "Tên đăng nhập",
@@ -780,7 +776,8 @@
"noAliases": "Không có tên gọi khác nào được chỉnh.",
"aliases": "Tên gọi khác",
"owner": "Chủ hộp thư",
"title": "Chỉnh sửa hộp thư {{ name }}@{{ domain }}"
"title": "Chỉnh sửa hộp thư {{ name }}@{{ domain }}",
"enableStorageQuota": "Bật giới hạn lưu trữ"
},
"addMailboxDialog": {
"owner": "Chủ hộp thư",
@@ -863,7 +860,8 @@
},
"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à.",
"title": "DNS động"
"title": "DNS động",
"showLogsAction": "Hiển thị log"
},
"firewall": {
"configure": {
@@ -1219,7 +1217,8 @@
"download": "Tải xuống",
"extract": "Giải nén tại đây",
"chown": "Đổi quyền sở hữu",
"rename": "Đổi tên"
"rename": "Đổi tên",
"open": "Mở"
},
"name": "Tên",
"symlink": "Liên kết symlink đến {{ target }}",
@@ -1277,7 +1276,19 @@
},
"removeDialog": {
"reallyDelete": "Chắc chắn xoá?"
}
},
"uploader": {
"exitWarning": "Vẫn đang tải lên. Bạn có chắc muốn đóng trang này?",
"uploading": "Đang tải lên"
},
"textEditor": {
"undo": "Hoàn tác",
"redo": "Xóa hoàn tác",
"save": "Lưu"
},
"extractionInProgress": "Đang giải nén",
"pasteInProgress": "Đang dán",
"deleteInProgress": "Đang xoá"
},
"terminal": {
"contextmenu": {
@@ -1474,7 +1485,8 @@
"time": "Tạo ra lúc",
"packageVersion": "Phiên bản đóng gói",
"description": "Bản sao lưu là những bản chụp snapshot hoàn chỉnh của app. Bạn có thể dùng các bản sao lưu để khôi phục hoặc nhân bản app này.",
"title": "Bản sao lưu"
"title": "Bản sao lưu",
"downloadBackupTooltip": "Tải bản sao lưu"
}
},
"updates": {
@@ -1494,8 +1506,10 @@
"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"
}
"title": "Thông tin app",
"repository": "Repo của bản đống gói"
},
"noUpdates": "Không có phiên bản mới"
},
"security": {
"robots": {
@@ -1507,7 +1521,8 @@
"saveAction": "Lưu",
"title": "Chính sách an ninh nội dung",
"description": "Cài đặt lựa chọn này sẽ ghi chèn lên những CSP header gửi từ app này ra"
}
},
"hstsPreload": "Bật HSTS preload cho trang web này và tất cả tên miền phụ"
},
"email": {
"csp": {
@@ -1541,7 +1556,10 @@
"24h": "24 tiếng trước",
"12h": "12 tiếng trước",
"6h": "6 tiếng"
}
},
"diskTitle": "Dung lượng ổ đĩa",
"diskIOTotal": "tổng: đọc {{ read }} / ghi {{ write }}",
"networkIOTotal": "tổng: vào {{ inbound }} / ra {{ outbound }}"
},
"storage": {
"mounts": {
@@ -1550,13 +1568,20 @@
"noMounts": "Không có volume được gắn thêm.",
"volume": "Volume",
"title": "Thư mục mount thêm",
"readOnly": "Chỉ cho phép đọc"
"readOnly": "Chỉ cho phép đọc",
"permissions": {
"readOnly": "Chỉ cho phép đọc",
"readWrite": "Đọc và ghi",
"label": "Quyền cấp phép"
}
},
"appdata": {
"moveAction": "Chuyển dữ liệu",
"dataDirPlaceholder": "Để trống để dùng giá trị mặc định của hệ thống",
"description": "Nếu hệ thống đang chạy sắp hết dung lượng ổ đĩa, hãy dùng chức năng này để dời những dữ liệu của app sang qua <a href=\"/#/volumes\">volume</a>. Bất cứ dữ liệu nào trong đây đều được sao lưu như một phần trong tổng thể app.",
"title": "Thư mục Dữ liệu"
"title": "Thư mục Dữ liệu",
"diskUsage": "App hiện đang dùng {{ size }} trong bộ lưu trữ (tính đến ngày{{ date }}).",
"mountTypeWarning": "Hệ thống tập tin điểm cuối phải hỗ trợ quyền cấp phép và sở hữu cho tập tin để có thể di chuyển dữ liệu"
}
},
"resources": {
@@ -1665,7 +1690,7 @@
"setupSubscriptionAction": "Cài đặt gói đăng ký",
"skipBackupCheckbox": "Bỏ qua sao lưu",
"subscriptionExpired": "Gói đăng ký Cloudron của bạn đã hết hạn. Xin cài đặt một gói đăng ký để cập nhật app.",
"changelogHeader": "Những thay đổi trong phiên bản mới {{ version}}:",
"changelogHeader": "Những thay đổi trong phiên bản dóng gói mới {{ version}}:",
"unstableWarning": "Bản cập nhật này là phiên bản ra mắt sớm và chưa được ổn định. Xin lưu ý rủi ro khi cập nhật.",
"title": "Cập nhật {{ app }}"
},
@@ -1673,7 +1698,8 @@
"importAction": "Nhập vào",
"uploadAction": "Tải lên cấu hình bản sao lưu",
"description": "Những dữ liệu được tạo ra tính từ thời điểm này và lần sao lưu cuối cùng sẽ bị mất vĩnh viễn. Bạn nên tạo một bản sao lưu của những dữ liệu hiện tại trước khi thực hiện việc nhập vào.",
"title": "Nhập bản sao lưu vào"
"title": "Nhập bản sao lưu vào",
"remotePath": "Đường dẫn bản sao lưu"
},
"repairDialog": {
"retryAction": "Thử lại {{ task }}",
@@ -1712,7 +1738,30 @@
"eventlogTabTitle": "Log sự kiện",
"sftpInfoAction": "Quyền truy cập SFPT",
"cronTabTitle": "Tác vụ lặp lai cron",
"forumUrlAction": "Cần trợ giúp? Hãy hỏi thử trên diễn đàn nhé"
"forumUrlAction": "Cần trợ giúp? Hãy hỏi thử trên diễn đàn nhé",
"servicesTabTitle": "Dịch vụ",
"turn": {
"title": "Cài đặt TURN",
"enable": "Thiết lập app để sử dụng máy chủ TURN được cài sẵn",
"disable": "Không thiết lập TURN cho app này. Các cài đặt TURN cho app được giữ nguyên. Bạn có thể tuỳ chỉnh thêm trong app."
},
"redis": {
"title": "Thiết lập Redis",
"enable": "Thiết lập app sử dụng Redis"
},
"addApplinkDialog": {
"title": "Thêm link app bên ngoài"
},
"editApplinkDialog": {
"deleteAction": "Xoá",
"title": "Chỉnh sửa link app"
},
"applinks": {
"clearIconDescription": "Hệ thống sẽ lấy favicon của app sau khi bạn bấm lưu.",
"upstreamUri": "Đường dẫn bên ngoài",
"label": "Nhãn",
"clearIconAction": "Xoá biểu tượng"
}
},
"volumes": {
"name": "Tên volume",
@@ -1739,7 +1788,7 @@
},
"removeVolumeActionTooltip": "Xoá volume",
"openFileManagerActionTooltip": "Mở Quản lý tập tin",
"hostPath": ường dẫn mount",
"hostPath": iểm đến",
"addVolumeAction": "Thêm volume",
"updateVolumeDialog": {
"title": "Cập nhật Volume {{ volume }}"
@@ -1771,7 +1820,9 @@
"de": "Tiếng Đức",
"en": "Tiếng Anh",
"es": "Tiếng Tây Ban Nha",
"ru": "Tiếng Nga"
"ru": "Tiếng Nga",
"da": "Tiếng Đan Mạch",
"pt": "Tiếng Bồ Đào Nha"
},
"passwordResetEmail": {
"subject": "[<%= cloudron %>] Đặt lại mật khẩu",
@@ -1818,5 +1869,43 @@
"mounts": {
"description": "Các app có thể truy cập vào <a href=\"/#/volumes\">những volume</a> được mount lên thông qua thư mục <code>/media/{volume name}</code>. Dữ liệu này không được bao gồm trong phần bản sao lưu của app."
}
}
},
"oidc": {
"newClientDialog": {
"title": "Thêm client",
"description": "Thêm cài đặt client kết nối OpenID mới.",
"createAction": "Tạo"
},
"client": {
"loginRedirectUri": "Đường dẫn callback khi đăng nhập (viết cách ra bởi dấu phẩy nếu có nhiều hơn một)",
"name": "Tên",
"id": "ID client",
"secret": "Mật khẩu client",
"signingAlgorithm": "Thuật toán ký mã hoá",
"logoutRedirectUri": "Đường dẫn callback khi đăng nhập (không bắt buộc)"
},
"description": "Cloudron có thể làm nhà cung cấp kết nối OpenID cho các app trong và ngoài hệ thống.",
"clients": {
"title": "Client",
"newClient": "Thêm client mới",
"empty": "Chưa có client"
},
"title": "Nhà cung cấp kết nối OpenID",
"editClientDialog": {
"title": "Chỉnh sửa client {{ client }}"
},
"deleteClientDialog": {
"title": "Chắc chắn muốn xoá client {{ client }}?",
"description": "Thao tác này sẽ ngắt kết nối tất cả app OpenID bên ngoài có trong Cloudron sử dụng ID client này."
},
"env": {
"discoveryUrl": "Đường dẫn Tìm kiếm",
"logoutUrl": "Đường dẫn đăng xuất",
"profileEndpoint": "Điểm cuối hồ sơ",
"keysEndpoint": "Điểm cuối mật mã",
"authEndpoint": "Điểm cuối Auth",
"tokenEndpoint": "Điểm cuối token"
}
},
"automation": "Tự động hoá"
}
-8
View File
@@ -405,7 +405,6 @@
"empty": "没有用户",
"resetPasswordTooltip": "重设密码",
"transferOwnershipTooltip": "转让所有权",
"makeLocalTooltip": "设为本地用户",
"invitationTooltip": "邀请用户",
"setGhostTooltip": "模拟该用户",
"mailmanagerTooltip": "该用户可以管理用户和邮箱",
@@ -429,7 +428,6 @@
},
"externalLdap": {
"title": "连接外部用户目录",
"subscriptionRequired": "这个功能仅在付费订阅后可用。",
"subscriptionRequiredAction": "现在就设置订阅",
"noopInfo": "LDAP 认证未配置。",
"provider": "Provider",
@@ -549,12 +547,6 @@
"setPassword": "设置密码",
"generatePassword": "生成密码"
},
"makeLocalDialog": {
"title": "将该用户改为本地用户",
"warning": "会为该用户触发一次密码重置来设置本地密码。",
"description": "该操作将会将用户从外部用户目录迁移到 Cloudron。",
"submitAction": "设为本地用户"
},
"exposedLdap": {
"secret": {
"label": "密钥",
+34 -4
View File
@@ -12,12 +12,41 @@
<div ng-bind-html="app.manifest.postInstallMessage | markdown2html"></div>
</div>
<div class="modal-footer">
<div class="form-group pull-left" ng-show="postInstallMessage.openApp">
<input type="checkbox" id="appPostInstallConfirmCheckbox" ng-model="postInstallMessage.confirmed">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<a class="btn btn-success" ng-href="{{ postInstallMessage.confirmed ? ('https://' + app.fqdn) : '' }}" target="_blank" ng-disabled="!postInstallMessage.confirmed" ng-click="postInstallMessage.submit()" ng-show="postInstallMessage.openApp">{{ 'app.appInfo.openAction' | tr:{ app: app.manifest.title } }}</a>
</div>
</div>
</div>
</div>
<!-- Modal postinstall confirm -->
<div class="modal fade" id="appPostInstallConfirmModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<img ng-src="{{appPostInstallConfirm.app.iconUrl}}" onerror="this.onerror=null;this.src='img/appicon_fallback.png'" class="app-info-icon"/>
<h5 class="app-info-title">
{{ appPostInstallConfirm.app.manifest.title }}
<span class="app-info-meta text-small">{{ 'app.appInfo.package' | tr }} <a ng-href="/#/appstore/{{appPostInstallConfirm.app.manifest.id}}?version={{appPostInstallConfirm.app.manifest.version}}">v{{ appPostInstallConfirm.app.manifest.version }}</a> </span>
<br/>
<span ng-show="appPostInstallConfirm.app.manifest.documentationUrl"><a target="_blank" ng-href="{{appPostInstallConfirm.app.manifest.documentationUrl}}">{{ 'app.docsAction' | tr }}</a> </span>
<br/>
</h5>
</div>
<div class="modal-body">
<p ng-show="appPostInstallConfirm.app.manifest.addons.email">{{ 'app.appInfo.ssoEmail' | tr }}</p>
<p ng-show="appPostInstallConfirm.app.sso && !appPostInstallConfirm.app.manifest.addons.email">{{ 'app.appInfo.sso' | tr }}</p>
<div ng-bind-html="appPostInstallConfirm.app.manifest.postInstallMessage | 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>
<div class="modal-footer">
<div class="form-group pull-left">
<input type="checkbox" id="appPostInstallConfirmCheckbox" ng-model="appPostInstallConfirm.confirmed">
<label class="control-label" for="appPostInstallConfirmCheckbox">{{ 'app.appInfo.postInstallConfirmCheckbox' | tr }}</label>
</div>
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<a class="btn btn-success" ng-href="{{ postInstallMessage.confirmed ? ('https://' + app.fqdn) : '' }}" target="_blank" ng-disabled="!postInstallMessage.confirmed" ng-click="postInstallMessage.submit()" ng-show="postInstallMessage.openApp">{{ 'app.appInfo.openAction' | tr:{ app: app.manifest.title } }}</a>
<a class="btn btn-success" ng-href="{{ appPostInstallConfirm.confirmed ? ('https://' + appPostInstallConfirm.app.fqdn) : '' }}" target="_blank" ng-disabled="!appPostInstallConfirm.confirmed" ng-click="appPostInstallConfirm.submit()">{{ 'app.appInfo.openAction' | tr:{ app: appPostInstallConfirm.app.manifest.title } }}</a>
</div>
</div>
</div>
@@ -793,12 +822,13 @@
<div ng-repeat="(env, info) in location.portBindingsInfo">
<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" 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.portBindingsEnabled[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>
</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>
</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>
<p class="text-small text-warning text-bold" ng-show="location.domain.provider === 'cloudflare'">{{ 'appstore.installDialog.cloudflarePortWarning' | tr }} </p>
+27 -1
View File
@@ -10,7 +10,8 @@
/* global Clipboard */
/* global SECRET_PLACEHOLDER */
/* global APP_TYPES, STORAGE_PROVIDERS, BACKUP_FORMATS */
/* global REGIONS_S3, REGIONS_WASABI, REGIONS_DIGITALOCEAN, REGIONS_EXOSCALE, REGIONS_SCALEWAY, REGIONS_LINODE, REGIONS_OVH, REGIONS_IONOS, REGIONS_UPCLOUD, REGIONS_VULTR, REGIONS_CONTABO */
/* global REGIONS_S3, REGIONS_WASABI, REGIONS_DIGITALOCEAN, REGIONS_EXOSCALE, REGIONS_SCALEWAY, REGIONS_LINODE, REGIONS_OVH, REGIONS_IONOS, REGIONS_UPCLOUD, REGIONS_VULTR */
/* global onAppClick */
angular.module('Application').controller('AppController', ['$scope', '$location', '$translate', '$timeout', '$interval', '$route', '$routeParams', 'Client', function ($scope, $location, $translate, $timeout, $interval, $route, $routeParams, Client) {
$scope.s3Regions = REGIONS_S3;
@@ -76,6 +77,31 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
});
};
$scope.appPostInstallConfirm = {
app: {},
message: '',
confirmed: false,
show: function (app) {
$scope.appPostInstallConfirm.app = app;
$scope.appPostInstallConfirm.message = app.manifest.postInstallMessage;
$scope.appPostInstallConfirm.confirmed = false;
$('#appPostInstallConfirmModal').modal('show');
return false; // prevent propagation and default
},
submit: function () {
if (!$scope.appPostInstallConfirm.confirmed) return;
$scope.appPostInstallConfirm.app.pendingPostInstallConfirmation = false;
delete localStorage['confirmPostInstall_' + $scope.appPostInstallConfirm.app.id];
$('#appPostInstallConfirmModal').modal('hide');
}
};
$scope.postInstallMessage = {
confirmed: false,
openApp: false,
+4 -4
View File
@@ -1,5 +1,5 @@
<!-- Modal postinstall confirm -->
<div class="modal fade" id="appPostInstallConfirmModal" tabindex="-1" role="dialog">
<div class="modal fade" id="appsPostInstallConfirmModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
@@ -21,8 +21,8 @@
</div>
<div class="modal-footer">
<div class="form-group pull-left">
<input type="checkbox" id="appPostInstallConfirmCheckbox" ng-model="appPostInstallConfirm.confirmed">
<label class="control-label" for="appPostInstallConfirmCheckbox">{{ 'app.appInfo.postInstallConfirmCheckbox' | tr }}</label>
<input type="checkbox" id="appsPostInstallConfirmCheckbox" ng-model="appPostInstallConfirm.confirmed">
<label class="control-label" for="appsPostInstallConfirmCheckbox">{{ 'app.appInfo.postInstallConfirmCheckbox' | tr }}</label>
</div>
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<a class="btn btn-success" ng-href="{{ appPostInstallConfirm.confirmed ? ('https://' + appPostInstallConfirm.app.fqdn) : '' }}" target="_blank" ng-disabled="!appPostInstallConfirm.confirmed" ng-click="appPostInstallConfirm.submit()">{{ 'app.appInfo.openAction' | tr:{ app: appPostInstallConfirm.app.manifest.title } }}</a>
@@ -155,7 +155,7 @@
<div class="animateMeOpacity ng-hide" ng-show="installedApps.length > 0">
<div class="app-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:'fqdn'">
<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:labelOrFQDN">
<div class="grid-item-content" uib-tooltip="{{ app.fqdn }}">
<a ng-show="app.type !== APP_TYPES.LINK && isOperator(app)" ng-href="#/app/{{ app.id}}/display" 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>
+7 -2
View File
@@ -45,6 +45,11 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
if (tr['app.states.updateAvailable']) $scope.states[3].label = tr['app.states.updateAvailable'];
});
// for sorting of the app grid items
$scope.labelOrFQDN = function (item) {
return item.label || item.fqdn;
};
$scope.$watch('selectedTags', function (newVal, oldVal) {
if (newVal === oldVal) return;
@@ -91,7 +96,7 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
$scope.appPostInstallConfirm.message = app.manifest.postInstallMessage;
$scope.appPostInstallConfirm.confirmed = false;
$('#appPostInstallConfirmModal').modal('show');
$('#appsPostInstallConfirmModal').modal('show');
return false; // prevent propagation and default
},
@@ -102,7 +107,7 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
$scope.appPostInstallConfirm.app.pendingPostInstallConfirmation = false;
delete localStorage['confirmPostInstall_' + $scope.appPostInstallConfirm.app.id];
$('#appPostInstallConfirmModal').modal('hide');
$('#appsPostInstallConfirmModal').modal('hide');
}
};
+2 -2
View File
@@ -391,10 +391,10 @@
<div uib-collapse="!configureBackup.advancedVisible">
<div class="form-group">
<label class="control-label">{{ 'backups.configureBackupStorage.memoryLimit' | tr }}: <b>{{ configureBackup.memoryLimit | prettyBinarySize:'800 MB' }}</b></label>
<label class="control-label">{{ 'backups.configureBackupStorage.memoryLimit' | tr }}: <b>{{ configureBackup.memoryLimit | prettyBinarySize:'1024 MB' }}</b></label>
<p class="small">{{ 'backups.configureBackupStorage.memoryLimitDescription' | tr }}</p>
<div style="padding: 0 10px;">
<slider id="sliderConfigureBackupMemoryLimit" ng-model="configureBackup.memoryLimit" step="134217728" tooltip="hide" ticks="configureBackup.memoryTicks" ticks-snap-bounds="67108864"></slider>
<slider id="sliderConfigureBackupMemoryLimit" ng-model="configureBackup.memoryLimit" tooltip="hide" step="268435456" ticks="configureBackup.memoryTicks"></slider>
</div>
</div>
+2 -2
View File
@@ -7,7 +7,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
$scope.SECRET_PLACEHOLDER = SECRET_PLACEHOLDER;
$scope.MIN_MEMORY_LIMIT = 800 * 1024 * 1024;
$scope.MIN_MEMORY_LIMIT = 1024 * 1024 * 1024; // 1 GB
$scope.config = Client.getConfig();
$scope.user = Client.getUserInfo();
@@ -541,7 +541,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.configureBackup.chown = $scope.backupConfig.chown;
var limits = $scope.backupConfig.limits || {};
$scope.configureBackup.memoryLimit = limits.memoryLimit;
$scope.configureBackup.memoryLimit = Math.max(limits.memoryLimit, $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);
+21 -11
View File
@@ -156,8 +156,8 @@
{{ 'emails.title' | tr }}
<div class="pull-right">
<a class="btn btn-default" ng-show="user.isAtLeastOwner" href="#/emails-queue">{{ 'emails.action.queue' | tr }}</a>
<a class="btn btn-default" ng-show="user.isAtLeastOwner" href="#/emails-eventlog">{{ 'eventlog.title' | tr }}</a>
<a class="btn btn-default" ng-show="user.isAtLeastAdmin" href="#/emails-queue">{{ 'emails.action.queue' | tr }}</a>
<a class="btn btn-default" ng-show="user.isAtLeastAdmin" href="#/emails-eventlog">{{ 'eventlog.title' | tr }}</a>
</div>
</h1>
</div>
@@ -195,10 +195,20 @@
</td>
<td class="elide-table-cell no-padding">
<a href="/#/email/{{ domain.domain }}" class="email-domain-list-item">
<span ng-show="domain.inbound && domain.outbound && domain.usage === null">{{ 'main.loadingPlaceholder' | tr }} ...</span>
<span ng-show="domain.inbound && domain.outbound && domain.usage !== null">{{ 'emails.domains.stats' | tr:{ mailboxCount: domain.mailboxCount, usage: (domain.usage | prettyDecimalSize) } }}</span>
<span ng-show="!domain.inbound && domain.outbound">{{ 'emails.domains.outbound' | tr }}</span>
<span ng-show="!domain.inbound && !domain.outbound">{{ 'emails.domains.disabled' | tr }}</span>
<span ng-switch on="domain.loading">
<span ng-switch-when="true">{{ 'main.loadingPlaceholder' | tr }} ...</span>
<span ng-switch-default>
<span ng-switch on="domain.inbound">
<span ng-switch-when="true">
<span ng-show="domain.loadingUsage">{{ 'emails.domains.stats' | tr:{ mailboxCount: domain.mailboxCount } }} {{ 'main.loadingPlaceholder' | tr }} ... </span>
<span ng-show="!domain.loadingUsage">{{ 'emails.domains.stats' | tr:{ mailboxCount: domain.mailboxCount, usage: (domain.usage | prettyDecimalSize) } }}</span>
</span>
<span ng-switch-default>
<span ng-show="domain.outbound">{{ 'emails.domains.outbound' | tr }}</span>
<span ng-show="!domain.outbound">{{ 'emails.domains.disabled' | tr }}</span>
</span>
</span>
</span>
</a>
</td>
<td class="text-right no-wrap">
@@ -213,11 +223,11 @@
</div>
<!-- mailbox sharing -->
<div class="text-left section-header" ng-show="user.isAtLeastOwner">
<div class="text-left section-header" ng-show="user.isAtLeastAdmin">
<h3>{{ 'emails.mailboxSharing.title' | tr }}</h3>
</div>
<div class="card" ng-show="user.isAtLeastOwner" style="margin-bottom: 15px;">
<div class="card" ng-show="user.isAtLeastAdmin" style="margin-bottom: 15px;">
<div class="row">
<div class="col-md-12">
<p>{{ 'emails.mailboxSharing.description' | tr }}</p>
@@ -234,7 +244,7 @@
</div>
<!-- server location -->
<div class="text-left section-header" ng-show="user.isAtLeastOwner">
<div class="text-left section-header" ng-show="user.isAtLeastAdmin">
<h3>
{{ 'emails.settings.location' | tr }}
<div class="btn-group btn-group-sm pull-right">
@@ -302,11 +312,11 @@
</div>
<!-- settings -->
<div class="text-left section-header" ng-show="user.isAtLeastOwner">
<div class="text-left section-header" ng-show="user.isAtLeastAdmin">
<h3>{{ 'emails.settings.title' | tr }}</h3>
</div>
<div class="card" ng-show="user.isAtLeastOwner" style="margin-bottom: 15px;">
<div class="card" ng-show="user.isAtLeastAdmin" style="margin-bottom: 15px;">
<p ng-bind-html=" 'emails.settings.info' | tr "></p>
<div class="row">
<div class="col-xs-6">
+70 -28
View File
@@ -1,6 +1,7 @@
'use strict';
/* global $, angular, TASK_TYPES */
/* global async */
angular.module('Application').controller('EmailsController', ['$scope', '$location', '$translate', '$timeout', 'Client', function ($scope, $location, $translate, $timeout, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastMailManager) $location.path('/'); });
@@ -404,44 +405,83 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
}
};
function refreshDomainStatuses() {
$scope.domains.forEach(function (domain) {
domain.usage = null; // used by ui to show 'loading'
function refreshMailStatus(domain, done) {
Client.getMailStatusForDomain(domain.domain, function (error, result) {
if (error) {
console.error('Failed to fetch mail status for domain', domain.domain, error);
return done();
}
Client.getMailStatusForDomain(domain.domain, function (error, result) {
if (error) return console.error('Failed to fetch mail status for domain', domain.domain, error);
domain.status = result;
domain.status = result;
domain.statusOk = Object.keys(result).every(function (k) {
if (k === 'dns') return Object.keys(result.dns).every(function (k) { return result.dns[k].status; });
domain.statusOk = Object.keys(result).every(function (k) {
if (k === 'dns') return Object.keys(result.dns).every(function (k) { return result.dns[k].status; });
if (!('status' in result[k])) return true; // if status is not present, the test was not run
if (!('status' in result[k])) return true; // if status is not present, the test was not run
return result[k].status;
});
return result[k].status;
});
Client.getMailConfigForDomain(domain.domain, function (error, mailConfig) {
if (error) return console.error('Failed to fetch mail config for domain', domain.domain, error);
done();
});
}
domain.inbound = mailConfig.enabled;
domain.outbound = mailConfig.relay.provider !== 'noop';
function refreshMailConfig(domain, done) {
Client.getMailConfigForDomain(domain.domain, function (error, mailConfig) {
if (error) {
console.error('Failed to fetch mail config for domain', domain.domain, error);
return done();
}
// do this even if no outbound since people forget to remove mailboxes
Client.getMailboxCount(domain.domain, function (error, count) {
if (error) return console.error('Failed to fetch mailboxes for domain', domain.domain, error);
domain.inbound = mailConfig.enabled;
domain.outbound = mailConfig.relay.provider !== 'noop';
domain.mailboxCount = count;
// do this even if no outbound since people forget to remove mailboxes
Client.getMailboxCount(domain.domain, function (error, count) {
if (error) {
console.error('Failed to fetch mailboxes for domain', domain.domain, error);
return done();
}
Client.getMailUsage(domain.domain, function (error, usage) {
if (error) return console.error('Failed to fetch usage for domain', domain.domain, error);
domain.mailboxCount = count;
domain.usage = 0;
// we used to use quotaValue here but it's quite different wrt diskSize. so choose diskSize consistently
// also, quotaValue can be missing for deleted mailboxes that are on disk but removed from dovecot/ldap itself
Object.keys(usage).forEach(function (m) { domain.usage += usage[m].diskSize; });
});
done();
});
});
}
function refreshMailUsage(domain, done) {
Client.getMailUsage(domain.domain, function (error, usage) {
if (error) {
console.error('Failed to fetch usage for domain', domain.domain, error);
return done();
}
domain.usage = 0;
// we used to use quotaValue here but it's quite different wrt diskSize. so choose diskSize consistently
// also, quotaValue can be missing for deleted mailboxes that are on disk but removed from dovecot/ldap itself
Object.keys(usage).forEach(function (m) { domain.usage += usage[m].diskSize; });
done();
});
}
function refreshDomainStatuses() {
async.each($scope.domains, function (domain, iteratorDone) {
async.series([
refreshMailStatus.bind(null, domain),
refreshMailConfig.bind(null, domain),
], function () {
domain.loading = false;
iteratorDone();
});
}, function () {
// mail usage is loaded separately with a cancellation check. when there are a lot of domains, it runs a long time in background and slows down loading of new views
async.eachLimit($scope.domains, 5, function (domain, itemDone) {
if ($scope.$$destroyed) return itemDone(); // abort!
refreshMailUsage(domain, function () {
domain.loadingUsage = false;
itemDone();
});
});
});
@@ -451,10 +491,12 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
Client.getDomains(function (error, domains) {
if (error) return console.error('Unable to get domain listing.', error);
domains.forEach(function (domain) { domain.loading = true; domain.loadingUsage = true; }); // used by ui to show 'loading'
$scope.domains = domains;
$scope.ready = true;
if ($scope.user.isAtLeastOwner) {
if ($scope.user.isAtLeastAdmin) {
$scope.mailLocation.refresh();
$scope.maxEmailSize.refresh();
$scope.virtualAllMail.refresh();
+2
View File
@@ -46,10 +46,12 @@ angular.module('Application').controller('EventLogController', ['$scope', '$loca
{ name: 'cloudron.update', value: 'cloudron.update' },
{ name: 'cloudron.update.finish', value: 'cloudron.update.finish' },
{ name: 'dashboard.domain.update', value: 'dashboard.domain.update' },
{ name: 'directoryserver.configure', value: 'directoryserver.configure' },
{ name: 'dyndns.update', value: 'dyndns.update' },
{ name: 'domain.add', value: 'domain.add' },
{ name: 'domain.update', value: 'domain.update' },
{ name: 'domain.remove', value: 'domain.remove' },
{ name: 'externalldap.configure', value: 'externalldap.configure' },
{ name: 'mail.location', value: 'mail.location' },
{ name: 'mail.enabled', value: 'mail.enabled' },
{ name: 'mail.box.add', value: 'mail.box.add' },
+1 -1
View File
@@ -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 | notificadtionTypeToColor) }">
<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="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 -9
View File
@@ -115,13 +115,21 @@
<h4 class="modal-title">{{ 'profile.changeEmail.title' | tr }}</h4>
</div>
<div class="modal-body">
<form name="emailChangeForm" role="form" novalidate ng-submit="emailchange.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (emailChangeForm.email.$dirty && emailChangeForm.email.$invalid) || (!emailChangeForm.email.$dirty && emailchange.error.email)}">
<input type="email" class="form-control" ng-model="emailchange.email" id="inputEmailChangeEmail" name="email" required autofocus>
<div class="control-label" ng-show="(!emailChangeForm.email.$dirty && emailchange.error.email) || (emailChangeForm.email.$dirty && emailChangeForm.email.$invalid)">
<form name="emailChangeForm" role="form" novalidate ng-submit="emailChange.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (emailChangeForm.email.$dirty && emailChangeForm.email.$invalid) || (!emailChangeForm.email.$dirty && emailChange.error.email)}">
<label class="control-label" for="inputEmailChangeEmail">{{ 'profile.changeEmail.email' | tr }}</label>
<input type="email" class="form-control" ng-model="emailChange.email" id="inputEmailChangeEmail" name="email" required autofocus>
<div class="control-label" ng-show="(!emailChangeForm.email.$dirty && emailChange.error.email) || (emailChangeForm.email.$dirty && emailChangeForm.email.$invalid)">
<small ng-show="emailChangeForm.email.$error.required">{{ 'profile.changeEmail.errorEmailRequired' | tr }}</small>
<small ng-show="(emailChangeForm.email.$dirty && emailChangeForm.email.$invalid) && !emailChangeForm.email.$error.required">{{ 'profile.changeEmail.errorEmailInvalid' | tr }}</small>
<small ng-show="!emailChangeForm.email.$dirty && emailchange.error.email">{{ emailchange.error.email }}</small>
<small ng-show="!emailChangeForm.email.$dirty && emailChange.error.email">{{ emailChange.error.email }}</small>
</div>
</div>
<div class="form-group" ng-class="{ 'has-error': (emailChange.error.password && !emailChangeForm.password.$dirty) }">
<label class="control-label" for="inputEmailChangePassword">{{ 'profile.changeEmail.password' | tr }}</label>
<input type="password" class="form-control" ng-model="emailChange.password" id="inputEmailChangePassword" name="password" required autofocus password-reveal>
<div class="control-label" ng-show="emailChange.error.password && !emailChangeForm.password.$dirty">
<small ng-show="emailChange.error.password">{{ 'profile.changeEmail.errorWrongPassword' | tr }}</small>
</div>
</div>
<input class="ng-hide" type="submit" ng-disabled="emailChangeForm.$invalid"/>
@@ -129,7 +137,7 @@
</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="emailchange.submit()" ng-disabled="emailChangeForm.$invalid || emailchange.busy"><i class="fa fa-circle-notch fa-spin" ng-show="emailchange.busy"></i> {{ 'main.dialog.save' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="emailChange.submit()" ng-disabled="emailChangeForm.$invalid || emailChange.busy"><i class="fa fa-circle-notch fa-spin" ng-show="emailChange.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
@@ -408,7 +416,7 @@
<tr>
<td class="text-muted" style="vertical-align: top;">{{ 'profile.primaryEmail' | tr }}</td>
<td class="text-right" style="vertical-align: top; white-space: nowrap;">
{{ user.email }} <a href="" ng-click="emailchange.show()" ng-hide="user.source || config.profileLocked"><i class="fa fa-edit text-small"></i></a>
{{ user.email }} <a href="" ng-click="emailChange.show()" ng-hide="user.source || config.profileLocked"><i class="fa fa-edit text-small"></i></a>
</td>
</tr>
<tr>
@@ -417,7 +425,7 @@
{{ user.fallbackEmail }} <a href="" ng-click="fallbackEmailChange.show()" ng-hide="user.source || config.profileLocked"><i class="fa fa-edit text-small"></i></a>
</td>
</tr>
<tr>
<tr ng-hide="user.source">
<td colspan="2" class="text-right">
<a href="" ng-click="sendPasswordReset()">{{ 'profile.passwordResetAction' | tr }}</a>
</td>
@@ -437,7 +445,7 @@
<br/>
<button class="btn btn-default" ng-click="backgroundImageChange.show()">Set Background Image</button>
<button class="btn btn-primary pull-right" ng-click="passwordchange.show()" ng-hide="user.source">{{ 'profile.changePasswordAction' | tr }}</button>
<button class="btn pull-right" uib-tooltip="{{ user.source ? ('profile.enable2FANotAvailable' | tr) : '' }}" ng-disabled="user.source" ng-class="user.twoFactorAuthenticationEnabled ? 'btn-danger' : 'btn-success'" ng-click="twoFactorAuthentication.show()">{{ user.twoFactorAuthenticationEnabled ? 'profile.disable2FAAction' : 'profile.enable2FAAction' | tr }}</button> </div>
<button class="btn pull-right" uib-tooltip="{{ (user.source && config.external2FA) ? ('profile.enable2FANotAvailable' | tr) : '' }}" ng-disabled="user.source && config.external2FA" ng-class="user.twoFactorAuthenticationEnabled ? 'btn-danger' : 'btn-success'" ng-click="twoFactorAuthentication.show()">{{ user.twoFactorAuthenticationEnabled ? 'profile.disable2FAAction' : 'profile.enable2FAAction' | tr }}</button> </div>
</div>
</div>
</div>
+39 -46
View File
@@ -15,7 +15,12 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
$scope.$watch('language', function (newVal, oldVal) {
if (newVal === oldVal) return;
$translate.use(newVal.id);
Client.setProfileLanguage(newVal.id, function (error) {
if (error) return console.error('Failed to reset password:', error);
});
$translate.use(newVal.id); // this switches the language and saves locally in localStorage['NG_TRANSLATE_LANG_KEY']
});
$scope.sendPasswordReset = function () {
@@ -101,7 +106,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
return;
}
Client.refreshUserInfo();
Client.refreshProfile();
$('#twoFactorAuthenticationEnableModal').modal('hide');
});
@@ -123,7 +128,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
return;
}
Client.refreshUserInfo();
Client.refreshProfile();
$('#twoFactorAuthenticationDisableModal').modal('hide');
});
@@ -180,7 +185,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
function done(error) {
if (error) return console.error('Unable to change avatar.', error);
Client.refreshUserInfo(function (error) {
Client.refreshProfile(function (error) {
if (error) return console.error(error);
$('#avatarChangeModal').modal('hide');
@@ -207,15 +212,10 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
avatarChangeReset: function () {
$scope.avatarChange.error.avatar = null;
if ($scope.user.avatarUrl.indexOf('/api/v1/profile/avatar') !== -1) {
$scope.avatarChange.type = 'custom';
} else if ($scope.user.avatarUrl.indexOf('https://www.gravatar.com') === 0) {
$scope.avatarChange.type = 'gravatar';
} else {
$scope.avatarChange.type = '';
}
console.log($scope.user)
$scope.avatarChange.type = $scope.user.avatarType;
$scope.avatarChange.typeOrig = $scope.avatarChange.type;
document.getElementById('previewAvatar').src = $scope.avatarChange.type === 'custom' ? $scope.user.avatarUrl : '';
$scope.avatarChange.pictureChanged = false;
$scope.avatarChange.avatar = null;
@@ -354,42 +354,44 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
}
};
$scope.emailchange = {
$scope.emailChange = {
busy: false,
error: {},
email: '',
password: '',
reset: function () {
$scope.emailchange.busy = false;
$scope.emailchange.error.email = null;
$scope.emailchange.email = '';
$scope.emailChange.busy = false;
$scope.emailChange.error = {};
$scope.emailChange.email = '';
$scope.emailChange.password = '';
$scope.emailChangeForm.$setUntouched();
$scope.emailChangeForm.$setPristine();
},
show: function () {
$scope.emailchange.reset();
$scope.emailChange.reset();
$('#emailChangeModal').modal('show');
},
submit: function () {
$scope.emailchange.error.email = null;
$scope.emailchange.busy = true;
$scope.emailChange.error.email = null;
$scope.emailChange.busy = true;
var data = {
email: $scope.emailchange.email
};
Client.updateProfile(data, function (error) {
$scope.emailchange.busy = false;
Client.setProfileEmail($scope.emailChange.email, $scope.emailChange.password, function (error) {
$scope.emailChange.busy = false;
if (error) {
if (error.statusCode === 409) $scope.emailchange.error.email = 'Email already taken';
else if (error.statusCode === 400) $scope.emailchange.error.email = error.message;
else console.error('Unable to change email.', error);
$('#inputEmailChangeEmail').focus();
if (error.statusCode === 412) {
$scope.emailChange.error.password = true;
$scope.emailChange.password = '';
$scope.emailChangeForm.password.$setPristine();
$('#inputFallbackEmailChangePassword').focus();
} else {
$scope.emailChange.error.email = error.message;
$('#inputEmailChangeEmail').focus();
}
$scope.emailChangeForm.$setUntouched();
$scope.emailChangeForm.$setPristine();
@@ -397,9 +399,9 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
return;
}
Client.refreshUserInfo();
Client.refreshProfile();
$scope.emailchange.reset();
$scope.emailChange.reset();
$('#emailChangeModal').modal('hide');
});
}
@@ -436,12 +438,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
$scope.fallbackEmailChange.error.generic = null;
$scope.fallbackEmailChange.busy = true;
var data = {
fallbackEmail: $scope.fallbackEmailChange.email,
password: $scope.fallbackEmailChange.password
};
Client.updateProfile(data, function (error) {
Client.setProfileFallbackEmail($scope.fallbackEmailChange.email, $scope.fallbackEmailChange.password, function (error) {
$scope.fallbackEmailChange.busy = false;
if (error) {
@@ -460,7 +457,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
}
// update user info in the background
Client.refreshUserInfo();
Client.refreshProfile();
$scope.fallbackEmailChange.reset();
$('#fallbackEmailChangeModal').modal('hide');
@@ -592,11 +589,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
$scope.displayNameChange.error.displayName = null;
$scope.displayNameChange.busy = true;
var user = {
displayName: $scope.displayNameChange.displayName
};
Client.updateProfile(user, function (error) {
Client.setProfileDisplayName($scope.displayNameChange.displayName, function (error) {
$scope.displayNameChange.busy = false;
if (error) {
@@ -612,7 +605,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
}
// update user info in the background
Client.refreshUserInfo();
Client.refreshProfile();
$scope.displayNameChange.reset();
$('#displayNameChangeModal').modal('hide');
@@ -714,7 +707,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
Client.onReady(function () {
$scope.appPassword.refresh();
$scope.tokens.refresh();
Client.refreshUserInfo(); // 2fa status might have changed by admin
Client.refreshProfile(); // 2fa status might have changed by admin
$translate.onReady(function () {
var usedLang = $translate.use() || $translate.fallbackLanguage();
+1 -1
View File
@@ -96,8 +96,8 @@ angular.module('Application').controller('SystemController', ['$scope', '$locati
if (content.type === 'app') {
content.app = Client.getInstalledAppsByAppId()[content.id];
content.label = content.app.label || content.app.fqdn;
if (!content.app) content.uninstalled = true;
else content.label = content.app.label || content.app.fqdn;
} else if (content.type === 'volume') {
content.volume = $scope.volumesById[content.id];
content.label = content.volume.name;
+150 -143
View File
@@ -9,10 +9,14 @@
<p class="has-error text-center" ng-show="externalLdap.error.generic">{{ externalLdap.error.generic }}</p>
<div class="form-group">
<label class="control-label" for="ldapProvider">{{ 'users.externalLdap.provider' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-management/#external-directory" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<label class="control-label" for="ldapProvider">{{ 'users.externalLdap.provider' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-directory/#external-directory" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" id="ldapProvider" ng-model="externalLdap.provider" ng-options="a.value as a.name for a in ldapProvider"></select>
</div>
<p class="text-small text-warning" ng-show="externalLdap.provider === 'noop' && externalLdap.currentConfig.provider !== 'noop'">
{{ 'users.externalLdap.disableWarning' | tr }}
</p>
<div uib-collapse="externalLdap.provider === 'noop'">
<form name="externalLdapConfigForm" role="form" novalidate ng-submit="externalLdap.submit()" autocomplete="off">
<fieldset>
@@ -93,7 +97,7 @@
</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-primary" ng-click="externalLdap.submit()" ng-disabled="externalLdapConfigForm.$invalid || externalLdap.busy"><i class="fa fa-circle-notch fa-spin" ng-show="externalLdap.busy"></i> {{ 'main.dialog.save' | tr }}</button>
<button type="button" class="btn btn-primary" ng-click="externalLdap.submit()" ng-disabled="externalLdapConfigForm.$invalid || externalLdap.saveBusy"><i class="fa fa-circle-notch fa-spin" ng-show="externalLdap.saveBusy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
@@ -237,7 +241,7 @@
<fieldset ng-disabled="profileConfig.busy">
<div class="checkbox">
<label>
<input type="checkbox" ng-model="profileConfig.editableUserProfiles"> {{ 'users.settings.allowProfileEditCheckbox' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-management/#lock-profile" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
<input type="checkbox" ng-model="profileConfig.editableUserProfiles"> {{ 'users.settings.allowProfileEditCheckbox' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-directory/#lock-profile" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
</label>
</div>
<div class="checkbox">
@@ -263,7 +267,21 @@
</div>
<div class="text-left section-header">
<h3>{{ 'users.externalLdap.title' | tr }}</h3>
<h3>
{{ 'users.externalLdap.title' | tr }}
<div class="btn-group btn-group-sm pull-right">
<button type="button" class="btn btn-small btn-default dropdown-toggle" ng-show="externalLdap.tasks.length" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" uib-tooltip="{{ 'domains.renewCerts.showLogsAction' | tr }}">
<i class="fas fa-align-left"></i> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li ng-repeat="task in externalLdap.tasks">
<a ng-href="/frontend/logs.html?taskId={{task.id}}" target="_blank" class="text-right">
{{ task.ts | prettyLongDate }} <i class="fa" style="margin-left: 20px" ng-class="{ 'status-active fa-check-circle': !task.active && task.success, 'fa-circle-notch fa-spin': task.active, 'status-error fa-times-circle': !task.active && !task.success }"></i>
</a>
</li>
</ul>
</div>
</h3>
</div>
<div class="card card-large">
@@ -273,150 +291,138 @@
<br/>
<div class="row" ng-hide="config.features.externalLdap">
<div class="col-md-12">
{{ 'users.externalLdap.subscriptionRequired' | tr }} <a href="" class="pull-right" ng-click="openSubscriptionSetup()">{{ 'users.externalLdap.subscriptionRequiredAction' | tr }}</a>
<div class="row" ng-show="externalLdap.currentConfig.provider === 'noop'">
<div class="col-xs-12">
<span class="text-muted">{{ 'users.externalLdap.noopInfo' | tr }}</span>
</div>
</div>
<div ng-show="config.features.externalLdap">
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.provider' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.provider }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider === 'noop'">
<div class="col-xs-12">
<span class="text-muted">{{ 'users.externalLdap.noopInfo' | tr }}</span>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.server' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.url }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.acceptSelfSignedCert' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.acceptSelfSignedCerts ? 'Yes' : 'No' }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.baseDn' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.baseDn }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.filter' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.filter }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.usernameField' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.usernameField || 'uid' }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.syncGroups' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.syncGroups ? 'Yes' : 'No' }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron' && externalLdap.currentConfig.syncGroups">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.groupBaseDn' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.groupBaseDn }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron' && externalLdap.currentConfig.syncGroups">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.groupFilter' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.groupFilter }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron' && externalLdap.currentConfig.syncGroups">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.groupnameField' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.groupnameField }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.auth' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.bindDn ? 'Yes' : 'No' }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.autocreateUsersOnLogin' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.autoCreate ? 'Yes' : 'No' }}</span>
</div>
</div>
<div class="row">
<br/>
<div class="col-md-12" style="margin-bottom: 10px;">
<div ng-show="externalLdap.busy" class="progress progress-striped active animateMe">
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ externalLdap.percent }}%"></div>
</div>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.provider' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.provider }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.server' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.url }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.acceptSelfSignedCert' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.acceptSelfSignedCerts ? 'Yes' : 'No' }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.baseDn' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.baseDn }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.filter' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.filter }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.usernameField' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.usernameField || 'uid' }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.syncGroups' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.syncGroups ? 'Yes' : 'No' }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron' && externalLdap.currentConfig.syncGroups">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.groupBaseDn' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.groupBaseDn }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron' && externalLdap.currentConfig.syncGroups">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.groupFilter' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.groupFilter }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron' && externalLdap.currentConfig.syncGroups">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.groupnameField' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.groupnameField }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.auth' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.bindDn ? 'Yes' : 'No' }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.autocreateUsersOnLogin' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.autoCreate ? 'Yes' : 'No' }}</span>
</div>
</div>
<div class="row">
<br/>
<div class="col-md-12" style="margin-bottom: 10px;">
<div ng-show="externalLdap.syncBusy" class="progress progress-striped active animateMe">
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ externalLdap.percent }}%"></div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<p ng-show="externalLdap.syncBusy">{{ externalLdap.message }}</p>
<p ng-hide="externalLdap.syncBusy">
<div class="has-error" ng-show="!externalLdap.active">{{ externalLdap.errorMessage }}</div>
</p>
</div>
<div class="col-md-6 text-right">
<button class="btn btn-primary pull-right" ng-click="externalLdap.show()">{{ 'users.externalLdap.configureAction' | tr }}</button>
<button class="btn btn-success pull-right" ng-disabled="externalLdap.currentConfig.provider === 'noop'" ng-click="externalLdap.sync()"><i class="fa fa-circle-notch fa-spin" ng-show="externalLdap.syncBusy"></i> {{ 'users.externalLdap.syncAction' | tr }}</button>
<a class="btn btn-primary pull-right" ng-show="externalLdap.taskId" ng-href="/frontend/logs.html?taskId={{ externalLdap.taskId }}" target="_blank">{{ 'users.externalLdap.showLogsAction' | tr }}</a>
</div>
<div class="row">
<div class="col-md-12">
<p ng-show="externalLdap.busy">{{ externalLdap.message }}</p>
<p ng-hide="externalLdap.busy">
<div class="has-error" ng-show="!externalLdap.active">{{ externalLdap.errorMessage }}</div>
</p>
<button class="btn btn-primary pull-right" ng-click="externalLdap.show()">{{ 'users.externalLdap.configureAction' | tr }}</button>
<button class="btn btn-success pull-right" ng-disabled="externalLdap.currentConfig.provider === 'noop'" ng-click="externalLdap.sync()"><i class="fa fa-circle-notch fa-spin" ng-show="externalLdap.syncBusy"></i> {{ 'users.externalLdap.syncAction' | tr }}</button>
<a class="btn btn-primary pull-right" ng-show="externalLdap.taskId" ng-href="/frontend/logs.html?taskId={{ externalLdap.taskId }}" target="_blank">{{ 'users.externalLdap.showLogsAction' | tr }}</a>
</div>
</div>
</div>
@@ -436,7 +442,7 @@
<fieldset>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="userDirectoryConfig.enabled" ng-disabled="userDirectoryConfig.busy"> {{ 'users.exposedLdap.enabled' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-management/#directory-server" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
<input type="checkbox" ng-model="userDirectoryConfig.enabled" ng-disabled="userDirectoryConfig.busy"> {{ 'users.exposedLdap.enabled' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-directory/#directory-server" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
</label>
</div>
<div class="form-group">
@@ -447,6 +453,7 @@
<button class="btn btn-default" type="button" id="userDirectoryUrlClipboardButton" data-clipboard-target="#userDirectoryUrlInput"><i class="fa fa-clipboard"></i></button>
</span>
</div>
<p class="text-small text-warning text-bold" ng-show="adminDomain.provider === 'cloudflare'">{{ 'users.exposedLdap.cloudflarePortWarning' | tr }} </p>
</div>
<div class="form-group">
<label class="control-label">{{ 'users.exposedLdap.secret.label' | tr }}</label>
@@ -456,7 +463,7 @@
</div>
<div class="form-group">
<label class="control-label">{{ 'users.exposedLdap.ipRestriction.label' | tr }}</label>
<p class="small">{{ 'users.exposedLdap.ipRestriction.description' | tr }}</p>
<p class="small" ng-bind-html=" 'users.exposedLdap.ipRestriction.description' | tr "></p>
<textarea ng-model="userDirectoryConfig.allowlist" ng-disabled="!userDirectoryConfig.enabled || userDirectoryConfig.busy" placeholder="{{ 'users.exposedLdap.ipRestriction.placeholder' | tr }}" name="allowlist" class="form-control" ng-class="{ 'has-error': !userDirectoryConfigForm.allowlist.$dirty && userDirectoryConfig.error.allowlist }" rows="4"></textarea>
<div class="has-error" ng-show="userDirectoryConfig.error.allowlist">{{ userDirectoryConfig.error.allowlist }}</div>
</div>
+52 -42
View File
@@ -2,7 +2,7 @@
/* global angular */
/* global Clipboard */
/* global $ */
/* global $, TASK_TYPES */
angular.module('Application').controller('UserSettingsController', ['$scope', '$location', '$translate', '$timeout', 'Client', function ($scope, $location, $translate, $timeout, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
@@ -25,6 +25,7 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
$scope.ready = false;
$scope.config = Client.getConfig();
$scope.userInfo = Client.getUserInfo();
$scope.adminDomain = null;
$scope.oidcClients = [];
$scope.profileConfig = {
@@ -119,11 +120,11 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
busy: false,
percent: 0,
message: '',
errorMessage: '',
error: {},
taskId: 0,
errorMessage: '', // last task error
tasks: [],
syncBusy: false,
error: {}, // save error
saveBusy: false,
// fields
provider: 'noop',
@@ -139,57 +140,43 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
currentConfig: {},
checkStatus: function () {
Client.getLatestTaskByType('syncExternalLdap', function (error, task) {
if (error) return console.error(error);
if (!task) return;
$scope.externalLdap.taskId = task.id;
$scope.externalLdap.updateStatus();
});
},
sync: function () {
$scope.externalLdap.syncBusy = true;
Client.startExternalLdapSync(function (error, taskId) {
if (error) {
$scope.externalLdap.syncBusy = false;
console.error('Unable to start ldap syncer task.', error);
return;
}
$scope.externalLdap.taskId = taskId;
$scope.externalLdap.updateStatus();
});
},
refresh: function() {
init: function () {
Client.getExternalLdapConfig(function (error, result) {
if (error) return console.error('Unable to get external ldap config.', error);
$scope.externalLdap.currentConfig = result;
$scope.externalLdap.checkStatus();
$scope.externalLdap.refreshTasks();
});
},
refreshTasks: function () {
Client.getTasksByType(TASK_TYPES.TASK_SYNC_EXTERNAL_LDAP, function (error, tasks) {
if (error) return console.error(error);
$scope.externalLdap.tasks = tasks.slice(0, 10);
if ($scope.externalLdap.tasks.length && $scope.externalLdap.tasks[0].active) $scope.externalLdap.updateStatus();
});
},
updateStatus: function () {
Client.getTask($scope.externalLdap.taskId, function (error, data) {
var taskId = $scope.externalLdap.tasks[0].id;
Client.getTask(taskId, function (error, data) {
if (error) return window.setTimeout($scope.externalLdap.updateStatus, 5000);
if (!data.active) {
$scope.externalLdap.syncBusy = false;
$scope.externalLdap.busy = false;
$scope.externalLdap.message = '';
$scope.externalLdap.percent = 100; // indicates that 'result' is valid
$scope.externalLdap.errorMessage = data.success ? '' : data.error.message;
$scope.externalLdap.refreshTasks(); // update the tasks list dropdown
return;
}
$scope.externalLdap.syncBusy = true;
$scope.externalLdap.busy = true;
$scope.externalLdap.percent = data.percent;
$scope.externalLdap.message = data.message;
window.setTimeout($scope.externalLdap.updateStatus, 3000);
window.setTimeout($scope.externalLdap.updateStatus, 500);
});
},
@@ -214,8 +201,25 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
$('#externalLdapModal').modal('show');
},
submit: function () {
sync: function () {
$scope.externalLdap.busy = true;
$scope.externalLdap.percent = 0;
$scope.externalLdap.message = '';
$scope.externalLdap.errorMessage = '';
Client.startExternalLdapSync(function (error) {
if (error) {
console.error(error);
$scope.externalLdap.errorMessage = error.message;
$scope.externalLdap.busy = false;
} else {
$scope.externalLdap.refreshTasks();
}
});
},
submit: function () {
$scope.externalLdap.saveBusy = true;
$scope.externalLdap.error = {};
var config = {
@@ -256,12 +260,13 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
}
Client.setExternalLdapConfig(config, function (error) {
$scope.externalLdap.busy = false;
$scope.externalLdap.saveBusy = false;
if (error) {
if (error.statusCode === 424) {
if (error.code === 'SELF_SIGNED_CERT_IN_CHAIN') $scope.externalLdap.error.acceptSelfSignedCerts = true;
else $scope.externalLdap.error.url = true;
$scope.externalLdap.error.generic = error.message;
} else if (error.statusCode === 400 && error.message === 'invalid baseDn') {
$scope.externalLdap.error.baseDn = true;
} else if (error.statusCode === 400 && error.message === 'invalid filter') {
@@ -282,7 +287,7 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
}
} else {
$('#externalLdapModal').modal('hide');
$scope.externalLdap.refresh();
$scope.externalLdap.init();
}
});
}
@@ -402,10 +407,15 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
};
Client.onReady(function () {
$scope.externalLdap.refresh();
$scope.externalLdap.init();
$scope.profileConfig.refresh();
$scope.userDirectoryConfig.refresh();
$scope.refreshOIDCClients();
Client.getDomains(function (error, result) {
if (error) return console.error('Unable to list domains.', error);
$scope.adminDomain = result.filter(function (d) { return d.domain === $scope.config.adminDomain; })[0];
});
});
// setup all the dialog focus handling
+83 -89
View File
@@ -1,22 +1,3 @@
<!-- Modal make user local -->
<div class="modal fade" id="makeLocalModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'users.makeLocalDialog.title' | tr }}</h4>
</div>
<div class="modal-body">
<p>{{ 'users.makeLocalDialog.description' | tr }}</p>
<p class="text-warning">{{ 'users.makeLocalDialog.warning' | tr }}</p>
</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="makeLocal.submit()" ng-disabled="makeLocal.busy"><i class="fa fa-circle-notch fa-spin" ng-show="makeLocal.busy"></i> {{ 'users.makeLocalDialog.submitAction' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal add user -->
<div class="modal fade" id="userAddModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
@@ -25,49 +6,49 @@
<h4 class="modal-title">{{ 'users.addUserDialog.title' | tr }}</h4>
</div>
<div class="modal-body">
<form name="useraddForm" role="form" ng-submit="useradd.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (useraddForm.displayName.$dirty && useraddForm.displayName.$invalid) || (!useraddForm.displayName.$dirty && useradd.error.displayName) }">
<form name="useraddForm" role="form" ng-submit="userAdd.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (useraddForm.displayName.$dirty && useraddForm.displayName.$invalid) || (!useraddForm.displayName.$dirty && userAdd.error.displayName) }">
<label class="control-label">{{ 'users.user.fullName' | tr }}</label>
<input type="text" class="form-control" ng-model="useradd.displayName" name="displayName" id="inputUserAddDisplayName" autofocus autocomplete="off" placeholder="{{ 'users.user.displayNamePlaceholder' | tr }}">
<div class="control-label" ng-show="(!useraddForm.displayName.$dirty && useradd.error.displayName) || (useraddForm.displayName.$dirty && useraddForm.displayName.$invalid) || (!useraddForm.displayName.$dirty && useradd.error.displayName)">
<input type="text" class="form-control" ng-model="userAdd.displayName" name="displayName" id="inputUserAddDisplayName" autofocus autocomplete="off" placeholder="{{ 'users.user.displayNamePlaceholder' | tr }}">
<div class="control-label" ng-show="(!useraddForm.displayName.$dirty && userAdd.error.displayName) || (useraddForm.displayName.$dirty && useraddForm.displayName.$invalid) || (!useraddForm.displayName.$dirty && userAdd.error.displayName)">
<small ng-show="useraddForm.displayName.$error.displayName">{{ 'users.user.errorNotValidFullName' | tr }}</small>
<small ng-show="!useraddForm.displayName.$dirty && useradd.error.displayName">{{ useradd.error.displayName }}</small>
<small ng-show="!useraddForm.displayName.$dirty && userAdd.error.displayName">{{ userAdd.error.displayName }}</small>
</div>
</div>
<div class="form-group" ng-class="{ 'has-error': (useraddForm.email.$dirty && useraddForm.email.$invalid) || (!useraddForm.email.$dirty && useradd.error.email) }">
<div class="form-group" ng-class="{ 'has-error': (useraddForm.email.$dirty && useraddForm.email.$invalid) || (!useraddForm.email.$dirty && userAdd.error.email) }">
<label class="control-label">{{ 'users.user.primaryEmail' | tr }} <sup><a ng-href="https://docs.cloudron.io/profile/#primary-email" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></label>
<input type="email" class="form-control" ng-model="useradd.email" name="email" id="inputUserAddEmail" required>
<div class="control-label" ng-show="(!useraddForm.email.$dirty && useradd.error.email) || (useraddForm.email.$dirty && useraddForm.email.$invalid) || (!useraddForm.email.$dirty && useradd.error.email)">
<input type="email" class="form-control" ng-model="userAdd.email" name="email" id="inputUserAddEmail" required>
<div class="control-label" ng-show="(!useraddForm.email.$dirty && userAdd.error.email) || (useraddForm.email.$dirty && useraddForm.email.$invalid) || (!useraddForm.email.$dirty && userAdd.error.email)">
<small ng-show="useraddForm.email.$error.required">{{ 'users.user.errorEmailRequired' | tr }}</small>
<small ng-show="useraddForm.email.$error.email">{{ 'users.user.errorInvalidEmail' | tr }}</small>
<small ng-show="!useraddForm.email.$dirty && useradd.error.email">{{ useradd.error.email }}</small>
<small ng-show="!useraddForm.email.$dirty && userAdd.error.email">{{ userAdd.error.email }}</small>
</div>
</div>
<div class="form-group" ng-class="{ 'has-error': (useraddForm.fallbackEmail.$dirty && useraddForm.fallbackEmail.$invalid) || (!useraddForm.fallbackEmail.$dirty && useradd.error.fallbackEmail) }">
<div class="form-group" ng-class="{ 'has-error': (useraddForm.fallbackEmail.$dirty && useraddForm.fallbackEmail.$invalid) || (!useraddForm.fallbackEmail.$dirty && userAdd.error.fallbackEmail) }">
<label class="control-label">{{ 'users.user.recoveryEmail' | tr }} <sup><a ng-href="https://docs.cloudron.io/profile/#password-recovery-email" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></label>
<input type="email" class="form-control" ng-model="useradd.fallbackEmail" name="fallbackEmail" id="inputUserAddFallbackEmail" placeholder="{{ 'users.user.fallbackEmailPlaceholder' | tr }}">
<div class="control-label" ng-show="(!useraddForm.fallbackEmail.$dirty && useradd.error.fallbackEmail) || (useraddForm.fallbackEmail.$dirty && useraddForm.fallbackEmail.$invalid) || (!useraddForm.fallbackEmail.$dirty && useradd.error.fallbackEmail)">
<input type="email" class="form-control" ng-model="userAdd.fallbackEmail" name="fallbackEmail" id="inputUserAddFallbackEmail" placeholder="{{ 'users.user.fallbackEmailPlaceholder' | tr }}">
<div class="control-label" ng-show="(!useraddForm.fallbackEmail.$dirty && userAdd.error.fallbackEmail) || (useraddForm.fallbackEmail.$dirty && useraddForm.fallbackEmail.$invalid) || (!useraddForm.fallbackEmail.$dirty && userAdd.error.fallbackEmail)">
<small ng-show="useraddForm.fallbackEmail.$error.required">{{ 'users.user.errorEmailRequired' | tr }}</small>
<small ng-show="useraddForm.fallbackEmail.$error.fallbackEmail">{{ 'users.user.errorInvalidEmail' | tr }}</small>
<small ng-show="!useraddForm.fallbackEmail.$dirty && useradd.error.fallbackEmail">{{ useradd.error.fallbackEmail }}</small>
<small ng-show="!useraddForm.fallbackEmail.$dirty && userAdd.error.fallbackEmail">{{ userAdd.error.fallbackEmail }}</small>
</div>
</div>
<div class="form-group" ng-class="{ 'has-error': (useraddForm.username.$dirty && useraddForm.username.$invalid) || (!useraddForm.username.$dirty && useradd.error.username) }">
<div class="form-group" ng-class="{ 'has-error': (useraddForm.username.$dirty && useraddForm.username.$invalid) || (!useraddForm.username.$dirty && userAdd.error.username) }">
<label class="control-label">{{ 'users.user.username' | tr }}</label>
<input type="text" class="form-control" ng-model="useradd.username" name="username" id="inputUserAddUsername" ng-required="config.profileLocked" placeholder="{{ config.profileLocked ? '' : ('users.user.usernamePlaceholder' | tr) }}">
<div class="control-label" ng-show="(!useraddForm.username.$dirty && useradd.error.username) || (useraddForm.username.$dirty && useraddForm.username.$invalid) || (!useraddForm.username.$dirty && useradd.error.username)">
<input type="text" class="form-control" ng-model="userAdd.username" name="username" id="inputUserAddUsername" ng-required="config.profileLocked" placeholder="{{ config.profileLocked ? '' : ('users.user.usernamePlaceholder' | tr) }}">
<div class="control-label" ng-show="(!useraddForm.username.$dirty && userAdd.error.username) || (useraddForm.username.$dirty && useraddForm.username.$invalid) || (!useraddForm.username.$dirty && userAdd.error.username)">
<small ng-show="useraddForm.username.$error.username">{{ 'users.user.errorInvalidUsername' | tr }}</small>
<small ng-show="!useraddForm.username.$dirty && useradd.error.username">{{ useradd.error.username }}</small>
<small ng-show="!useraddForm.username.$dirty && userAdd.error.username">{{ userAdd.error.username }}</small>
</div>
</div>
<div class="form-group" ng-show="userInfo.isAtLeastAdmin">
<label class="control-label">{{ 'users.user.role' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-management/#roles" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div class="control-label">
<select class="form-control" ng-model="useradd.role" ng-options="role.id as role.name disable when role.disabled for role in roles"></select>
<select class="form-control" ng-model="userAdd.role" ng-options="role.id as role.name disable when role.disabled for role in roles"></select>
</div>
</div>
@@ -75,22 +56,23 @@
<label class="control-label">{{ 'users.user.groups' | tr }}</label>
<div class="control-label">
<div ng-show="groups.length === 0">{{ 'users.user.noGroups' | tr }}</div>
<multiselect ng-show="groups.length !== 0" ng-model="useradd.selectedGroups" options="group.name for group in groups" data-compare-by="name" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<!-- local groups. they can have local and external users . angular cannot filter empty strings - https://github.com/angular/angular.js/issues/7890 -->
<multiselect ng-show="groups.length !== 0" ng-model="userAdd.selectedLocalGroups" options="group.name for group in groups | filter:{ source: '!ldap' }" data-compare-by="name" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
</div>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="useradd.sendInvite" id="inputUserAddSendInvite"> {{ 'users.addUserDialog.sendInviteCheckbox' | tr }}
<input type="checkbox" ng-model="userAdd.sendInvite" id="inputUserAddSendInvite"> {{ 'users.addUserDialog.sendInviteCheckbox' | tr }}
</label>
</div>
<input class="ng-hide" type="submit" ng-disabled="useraddForm.$invalid || useradd.busy"/>
<input class="ng-hide" type="submit" ng-disabled="useraddForm.$invalid || userAdd.busy"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="useradd.submit()" ng-disabled="useraddForm.$invalid || useradd.busy"><i class="fa fa-circle-notch fa-spin" ng-show="useradd.busy"></i> {{ 'users.addUserDialog.addUserAction' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="userAdd.submit()" ng-disabled="useraddForm.$invalid || userAdd.busy"><i class="fa fa-circle-notch fa-spin" ng-show="userAdd.busy"></i> {{ 'users.addUserDialog.addUserAction' | tr }}</button>
</div>
</div>
</div>
@@ -101,15 +83,15 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'users.deleteUserDialog.title' | tr:{ username: (userremove.userInfo.username || userremove.userInfo.email) } }}</h4>
<h4 class="modal-title">{{ 'users.deleteUserDialog.title' | tr:{ username: (userRemove.userInfo.username || userRemove.userInfo.email) } }}</h4>
</div>
<div class="modal-body">
<p class="text-bold text-danger" ng-show="userremove.error">{{ userremove.error }}</p>
<p ng-hide="userremove.error">{{ 'users.deleteUserDialog.description' | tr }}</p>
<p class="text-bold text-danger" ng-show="userRemove.error">{{ userRemove.error }}</p>
<p ng-hide="userRemove.error">{{ 'users.deleteUserDialog.description' | tr }}</p>
</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-danger" ng-click="userremove.submit()" ng-hide="userremove.error" ng-disabled="userremove.busy"><i class="fa fa-circle-notch fa-spin" ng-show="userremove.busy"></i> {{ 'users.deleteUserDialog.deleteAction' | tr }}</button>
<button type="button" class="btn btn-danger" ng-click="userRemove.submit()" ng-hide="userRemove.error" ng-disabled="userRemove.busy"><i class="fa fa-circle-notch fa-spin" ng-show="userRemove.busy"></i> {{ 'users.deleteUserDialog.deleteAction' | tr }}</button>
</div>
</div>
</div>
@@ -120,83 +102,94 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'users.editUserDialog.title' | tr:{ username: (useredit.userInfo.username || useredit.userInfo.email) } }}</h4>
<h4 class="modal-title">{{ 'users.editUserDialog.title' | tr:{ username: (userEdit.userInfo.username || userEdit.userInfo.email) } }}</h4>
</div>
<div class="modal-body">
<div ng-show="useredit.source">
<div ng-show="userEdit.source">
<p class="text-warning">{{ 'users.editUserDialog.externalLdapWarning' | tr }}</p>
<p><label>{{ 'users.user.displayName' | tr }}</label><br/><input type="text" class="form-control" ng-disabled="true" ng-model="useredit.displayName">
<p><label>{{ 'users.user.email' | tr }}</label><br/><input type="text" class="form-control" ng-disabled="true" ng-model="useredit.email"></p>
<p><label>{{ 'users.user.displayName' | tr }}</label><br/><input type="text" class="form-control" ng-disabled="true" ng-model="userEdit.displayName">
<p><label>{{ 'users.user.email' | tr }}</label><br/><input type="text" class="form-control" ng-disabled="true" ng-model="userEdit.email"></p>
</div>
<form name="useredit_form" role="form" ng-submit="useredit.submit()" autocomplete="off">
<p class="has-error text-center" ng-show="useredit.error.generic">{{ useredit.error.generic }}</p>
<form name="useredit_form" role="form" ng-submit="userEdit.submit()" autocomplete="off">
<p class="has-error text-center" ng-show="userEdit.error.generic">{{ userEdit.error.generic }}</p>
<!-- when user profiles are locked, this provides a way for the admin to set the username -->
<div class="form-group" ng-hide="useredit.source || useredit.userInfo.username" ng-class="{ 'has-error': (useredit_form.username.$dirty && useredit_form.username.$invalid) || (!useredit_form.username.$dirty && useredit.error.username) }">
<div class="form-group" ng-hide="userEdit.source || userEdit.userInfo.username" ng-class="{ 'has-error': (useredit_form.username.$dirty && useredit_form.username.$invalid) || (!useredit_form.username.$dirty && userEdit.error.username) }">
<label class="control-label">{{ 'users.user.username' | tr }}</label>
<div class="control-label" ng-show="(!useredit_form.username.$dirty && useredit.error.username) || (useredit_form.username.$dirty && useredit_form.username.$invalid) || (!useredit_form.username.$dirty && useredit.error.username)">
<small ng-show="!useredit_form.username.$dirty && useredit.error.username">{{ useredit.error.username }}</small>
<div class="control-label" ng-show="(!useredit_form.username.$dirty && userEdit.error.username) || (useredit_form.username.$dirty && useredit_form.username.$invalid) || (!useredit_form.username.$dirty && userEdit.error.username)">
<small ng-show="!useredit_form.username.$dirty && userEdit.error.username">{{ userEdit.error.username }}</small>
</div>
<input type="text" class="form-control" ng-model="useredit.username" name="username" autocomplete="off">
<input type="text" class="form-control" ng-model="userEdit.username" name="username" autocomplete="off">
</div>
<div class="form-group" ng-hide="useredit.source" ng-class="{ 'has-error': (useredit_form.displayName.$dirty && useredit_form.displayName.$invalid) || (!useredit_form.displayName.$dirty && useredit.error.displayName) }">
<div class="form-group" ng-hide="userEdit.source" ng-class="{ 'has-error': (useredit_form.displayName.$dirty && useredit_form.displayName.$invalid) || (!useredit_form.displayName.$dirty && userEdit.error.displayName) }">
<label class="control-label">{{ 'users.user.displayName' | tr }}</label>
<div class="control-label" ng-show="(!useredit_form.displayName.$dirty && useredit.error.displayName) || (useredit_form.displayName.$dirty && useredit_form.displayName.$invalid) || (!useredit_form.displayName.$dirty && useredit.error.displayName)">
<div class="control-label" ng-show="(!useredit_form.displayName.$dirty && userEdit.error.displayName) || (useredit_form.displayName.$dirty && useredit_form.displayName.$invalid) || (!useredit_form.displayName.$dirty && userEdit.error.displayName)">
<small ng-show="useredit_form.displayName.$error.required">{{ 'users.user.errorDisplayNameRequired' | tr }}</small>
<small ng-show="!useredit_form.displayName.$dirty && useredit.error.displayName">{{ useredit.error.displayName }}</small>
<small ng-show="!useredit_form.displayName.$dirty && userEdit.error.displayName">{{ userEdit.error.displayName }}</small>
</div>
<input type="text" class="form-control" ng-model="useredit.displayName" name="displayName" required autofocus autocomplete="off">
<input type="text" class="form-control" ng-model="userEdit.displayName" name="displayName" required autofocus autocomplete="off">
</div>
<div class="form-group" ng-hide="useredit.source" ng-class="{ 'has-error': (useredit_form.email.$dirty && useredit_form.email.$invalid) || (!useredit_form.email.$dirty && useredit.error.email) }">
<div class="form-group" ng-hide="userEdit.source" ng-class="{ 'has-error': (useredit_form.email.$dirty && useredit_form.email.$invalid) || (!useredit_form.email.$dirty && userEdit.error.email) }">
<label class="control-label">{{ 'users.user.primaryEmail' | tr }} <sup><a ng-href="https://docs.cloudron.io/profile/#primary-email" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></label>
<div class="control-label" ng-show="(!useredit_form.email.$dirty && useredit.error.email) || (useredit_form.email.$dirty && useredit_form.email.$invalid) || (!useredit_form.email.$dirty && useredit.error.email)">
<div class="control-label" ng-show="(!useredit_form.email.$dirty && userEdit.error.email) || (useredit_form.email.$dirty && useredit_form.email.$invalid) || (!useredit_form.email.$dirty && userEdit.error.email)">
<small ng-show="useredit_form.email.$error.required">{{ 'users.user.errorEmailRequired' | tr }}</small>
<small ng-show="useredit_form.email.$error.email">{{ 'users.user.errorInvalidEmail' | tr }}</small>
<small ng-show="!useredit_form.email.$dirty && useredit.error.email">{{ useredit.error.email }}</small>
<small ng-show="!useredit_form.email.$dirty && userEdit.error.email">{{ userEdit.error.email }}</small>
</div>
<input type="email" class="form-control" ng-model="useredit.email" name="email" required>
<input type="email" class="form-control" ng-model="userEdit.email" name="email" required>
</div>
<div class="form-group" ng-hide="useredit.source" ng-class="{ 'has-error': (useredit_form.fallbackEmail.$dirty && useredit_form.fallbackEmail.$invalid) || (!useredit_form.fallbackEmail.$dirty && useredit.error.fallbackEmail) }">
<div class="form-group" ng-hide="userEdit.source" ng-class="{ 'has-error': (useredit_form.fallbackEmail.$dirty && useredit_form.fallbackEmail.$invalid) || (!useredit_form.fallbackEmail.$dirty && userEdit.error.fallbackEmail) }">
<label class="control-label">{{ 'users.user.recoveryEmail' | tr }} <sup><a ng-href="https://docs.cloudron.io/profile/#password-recovery-email" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></label>
<div class="control-label" ng-show="(!useredit_form.fallbackEmail.$dirty && useredit.error.fallbackEmail) || (useredit_form.fallbackEmail.$dirty && useredit_form.fallbackEmail.$invalid) || (!useredit_form.fallbackEmail.$dirty && useredit.error.fallbackEmail)">
<div class="control-label" ng-show="(!useredit_form.fallbackEmail.$dirty && userEdit.error.fallbackEmail) || (useredit_form.fallbackEmail.$dirty && useredit_form.fallbackEmail.$invalid) || (!useredit_form.fallbackEmail.$dirty && userEdit.error.fallbackEmail)">
<small ng-show="useredit_form.fallbackEmail.$error.fallbackEmail">{{ 'users.user.errorInvalidEmail' | tr }}</small>
<small ng-show="!useredit_form.fallbackEmail.$dirty && useredit.error.fallbackEmail">{{ useredit.error.fallbackEmail }}</small>
<small ng-show="!useredit_form.fallbackEmail.$dirty && userEdit.error.fallbackEmail">{{ userEdit.error.fallbackEmail }}</small>
</div>
<input type="fallbackEmail" class="form-control" ng-model="useredit.fallbackEmail" name="fallbackEmail">
<input type="fallbackEmail" class="form-control" ng-model="userEdit.fallbackEmail" name="fallbackEmail">
</div>
<div class="form-group" ng-show="!isMe(useredit.userInfo) && userInfo.isAtLeastAdmin">
<div class="form-group" ng-show="!isMe(userEdit.userInfo) && userInfo.isAtLeastAdmin">
<label class="control-label">{{ 'users.user.role' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-management/#roles" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div class="control-label">
<select class="form-control" ng-model="useredit.role" ng-options="role.id as role.name disable when role.disabled for role in roles"></select>
<select class="form-control" ng-model="userEdit.role" ng-options="role.id as role.name disable when role.disabled for role in roles"></select>
</div>
</div>
<div class="form-group">
<label class="control-label">{{ 'users.user.groups' | tr }}</label>
<div class="control-label">
<div ng-show="groups.length === 0">{{ 'users.user.noGroups' | tr }}</div>
<multiselect ng-show="groups.length !== 0" ng-model="useredit.selectedGroups" options="group.name for group in groups" data-compare-by="id" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<div ng-switch on="groups.length">
<div ng-switch-when="0">{{ 'users.user.noGroups' | tr }}</div>
<div ng-switch-default>
<!-- local groups. they can have local and external users . angular cannot filter empty strings - https://github.com/angular/angular.js/issues/7890 -->
<multiselect ng-show="hasLocalGroups" ng-model="userEdit.selectedLocalGroups" options="group.name for group in groups | filter:{ source: '!ldap' }" data-compare-by="id" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
</div>
</div>
</div>
</div>
<div class="form-group" ng-hide="isMe(useredit.userInfo)">
<div class="form-group" ng-show="userEdit.externalGroups.length">
<!-- remote groups. cannot be edited -->
<label class="control-label">{{ 'users.user.ldapGroups' | tr }}</label>
<div><span ng-repeat="group in userEdit.externalGroups">{{ group.name }}</span></div>
</div>
<div class="form-group" ng-hide="isMe(userEdit.userInfo)">
<div class="checkbox">
<label>
<input type="checkbox" ng-model="useredit.active"> {{ 'users.user.activeCheckbox' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-management/#disable-user" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
<input type="checkbox" ng-model="userEdit.active"> {{ 'users.user.activeCheckbox' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-management/#disable-user" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
</label>
</div>
</div>
<input class="hide" type="submit" ng-disabled="useredit_form.$invalid || useredit.busy"/>
<input class="hide" type="submit" ng-disabled="useredit_form.$invalid || userEdit.busy"/>
</form>
<hr/>
<div>
<p ng-hide="useredit.userInfo.twoFactorAuthenticationEnabled">{{ 'users.passwordResetDialog.no2FASetup' | tr }}</p>
<p ng-show="useredit.userInfo.twoFactorAuthenticationEnabled">{{ 'users.passwordResetDialog.2FAIsSetup' | tr }}</p>
<button type="button" class="btn btn-danger" ng-click="useredit.reset2FA()" ng-disabled="!useredit.userInfo.twoFactorAuthenticationEnabled || useredit.reset2FABusy"><i class="fa fa-circle-notch fa-spin" ng-show="useredit.reset2FABusy"></i> {{ 'users.passwordResetDialog.reset2FAAction' | tr }}</button>
<div ng-hide="userEdit.source && config.external2FA">
<p ng-hide="userEdit.userInfo.twoFactorAuthenticationEnabled">{{ 'users.passwordResetDialog.no2FASetup' | tr }}</p>
<p ng-show="userEdit.userInfo.twoFactorAuthenticationEnabled">{{ 'users.passwordResetDialog.2FAIsSetup' | tr }}</p>
<button type="button" class="btn btn-danger" ng-click="userEdit.reset2FA()" ng-disabled="!userEdit.userInfo.twoFactorAuthenticationEnabled || userEdit.reset2FABusy"><i class="fa fa-circle-notch fa-spin" ng-show="userEdit.reset2FABusy"></i> {{ 'users.passwordResetDialog.reset2FAAction' | tr }}</button>
</div>
<div ng-show="userEdit.source && config.external2FA"> {{ 'users.user.external2FA' | tr }}</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="useredit.submit()" ng-disabled="useredit_form.$invalid || useredit.busy"><i class="fa fa-circle-notch fa-spin" ng-show="useredit.busy"></i> {{ 'main.dialog.save' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="userEdit.submit()" ng-disabled="useredit_form.$invalid || userEdit.busy"><i class="fa fa-circle-notch fa-spin" ng-show="userEdit.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
@@ -256,7 +249,8 @@
<div class="form-group">
<label class="control-label">{{ 'users.group.users' | tr }}</label>
<div class="control-label">
<multiselect ng-model="groupEdit.selectedUsers" options="(user.username || user.email) for user in allUsers" data-compare-by="email" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<multiselect ng-hide="groupEdit.source" ng-model="groupEdit.selectedUsers" ng-disabled="groupEdit.busy" options="(user.username || user.email) for user in allUsers" data-compare-by="email" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<div ng-show="groupEdit.source"><span ng-repeat="user in groupEdit.selectedUsers"> {{ (user.username || user.email) }}</span></div>
</div>
</div>
<div class="form-group">
@@ -265,7 +259,7 @@
<multiselect ng-model="groupEdit.selectedApps" options="(app.label || app.fqdn) for app in groupEdit.apps" data-compare-by="fqdn" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
</div>
</div>
<input class="hide" type="submit" ng-disabled="groupEdit_form.$invalid || useredit.busy"/>
<input class="hide" type="submit" ng-disabled="groupEdit_form.$invalid || groupEdit.busy"/>
</form>
</div>
<div class="modal-footer">
@@ -460,7 +454,8 @@
<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>
<div class="btn-group">
<!-- 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">
@@ -472,7 +467,7 @@
</ul>
</div>
</div>
<button class="btn btn-primary btn-outline" ng-click="useradd.show()">
<button class="btn btn-primary btn-outline" ng-click="userAdd.show()">
<i class="fa fa-user-plus"></i> {{ 'users.newUserAction' | tr }}
</button>
</div>
@@ -506,13 +501,13 @@
<i class="fas fa-mail-bulk arrow" ng-show="user.active && user.role === 'mailmanager'" uib-tooltip="{{ 'users.users.mailmanagerTooltip' | tr }}"></i>
<i class="fa fa-ban" ng-show="!user.active" uib-tooltip="{{ 'users.users.inactiveTooltip' | tr }}"></i>
</td>
<td class="hand elide-table-cell" ng-click="canEdit(user) && useredit.show(user)" ng-show="user.username">
<td class="hand elide-table-cell" ng-click="canEdit(user) && userEdit.show(user)" ng-show="user.username">
{{ user.displayName }} &nbsp; <span class="text-muted">{{ user.username }}</span> &nbsp; <i ng-show="user.source" class="far fa-address-book" uib-tooltip="{{ 'users.users.externalLdapTooltip' | tr }}"></i>
</td>
<td class="hand elide-table-cell" ng-click="canEdit(user) && useredit.show(user)" ng-hide="user.username">
<td class="hand elide-table-cell" ng-click="canEdit(user) && userEdit.show(user)" ng-hide="user.username">
<span class="text-muted" uib-tooltip="{{ 'users.users.notActivatedYetTooltip' | tr }}">{{ user.email }}</span>
</td>
<td class="text-left hand elide-table-cell hidden-xs hidden-sm" ng-click="canEdit(user) && useredit.show(user)">
<td class="text-left hand elide-table-cell hidden-xs hidden-sm" ng-click="canEdit(user) && userEdit.show(user)">
<span class="group-badge" ng-repeat="groupId in user.groupIds">
{{ groupsById[groupId].name }}
</span>
@@ -520,11 +515,10 @@
<td class="text-right no-wrap" style="vertical-align: bottom">
<button ng-disabled="!canEdit(user)" ng-show="!user.inviteAccepted && !isMe(user) && !user.source" class="btn btn-xs btn-default" ng-click="invitation.show(user)" uib-tooltip="{{ 'users.users.invitationTooltip' | tr }}"><i class="fas fa-paper-plane"></i></button>
<button ng-show="user.source" class="btn btn-xs btn-default" ng-click="makeLocal.show(user)" uib-tooltip="{{ 'users.users.makeLocalTooltip' | tr }}"><i class="fas fa-thumbtack" style="width: 10.5px;"></i></button>
<button ng-disabled="!canEdit(user)" ng-show="user.inviteAccepted && !user.source" class="btn btn-xs btn-default" ng-click="passwordReset.show(user)" uib-tooltip="{{ 'users.users.resetPasswordTooltip' | tr }}"><i class="fas fa-key"></i></button>
<button ng-disabled="!canImpersonate(user)" class="btn btn-xs btn-default" ng-click="setGhost.show(user)" uib-tooltip="{{ 'users.users.setGhostTooltip' | tr }}"><i class="fas fa-user-secret"></i></button>
<button ng-disabled="!canEdit(user)" class="btn btn-xs btn-default" ng-click="useredit.show(user)" uib-tooltip="{{ 'users.users.editUserTooltip' | tr }}"><i class="fa fa-pencil-alt"></i></button>
<button ng-disabled="!canEdit(user) || isMe(user)" class="btn btn-xs btn-danger" ng-click="userremove.show(user)" uib-tooltip="{{ 'users.users.removeUserTooltip' | tr }}"><i class="far fa-trash-alt"></i></button>
<button ng-disabled="!canEdit(user)" class="btn btn-xs btn-default" ng-click="userEdit.show(user)" uib-tooltip="{{ 'users.users.editUserTooltip' | tr }}"><i class="fa fa-pencil-alt"></i></button>
<button ng-disabled="!canEdit(user) || isMe(user)" class="btn btn-xs btn-danger" ng-click="userRemove.show(user)" uib-tooltip="{{ 'users.users.removeUserTooltip' | tr }}"><i class="far fa-trash-alt"></i></button>
</td>
</tr>
</tbody>
+160 -178
View File
@@ -13,6 +13,7 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
$scope.users = []; // users of current page
$scope.allUsersById = [];
$scope.groups = [];
$scope.hasLocalGroups = false;
$scope.groupsById = { };
$scope.config = Client.getConfig();
$scope.userInfo = Client.getUserInfo();
@@ -186,7 +187,7 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
$scope.userImport.busy = false;
$scope.userImport.done = true;
if ($scope.userImport.success) {
refresh();
refreshCurrentPage();
refreshAllUsers();
}
});
@@ -231,30 +232,30 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
});
};
$scope.userremove = {
$scope.userRemove = {
busy: false,
error: null,
userInfo: {},
show: function (userInfo) {
$scope.userremove.error = null;
$scope.userremove.userInfo = userInfo;
$scope.userRemove.error = null;
$scope.userRemove.userInfo = userInfo;
$('#userRemoveModal').modal('show');
},
submit: function () {
$scope.userremove.busy = true;
$scope.userRemove.busy = true;
Client.removeUser($scope.userremove.userInfo.id, function (error) {
$scope.userremove.busy = false;
Client.removeUser($scope.userRemove.userInfo.id, function (error) {
$scope.userRemove.busy = false;
if (error && error.statusCode === 403) return $scope.userremove.error = error.message;
if (error && error.statusCode === 403) return $scope.userRemove.error = error.message;
else if (error) return console.error('Unable to delete user.', error);
$scope.userremove.userInfo = {};
$scope.userRemove.userInfo = {};
refresh();
refreshCurrentPage();
refreshAllUsers();
$('#userRemoveModal').modal('hide');
@@ -262,7 +263,7 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
}
};
$scope.useradd = {
$scope.userAdd = {
busy: false,
alreadyTaken: false,
error: {},
@@ -270,19 +271,19 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
fallbackEmail: '',
username: '',
displayName: '',
selectedGroups: [],
selectedLocalGroups: [],
role: 'user',
sendInvite: false,
show: function () {
$scope.useradd.error = {};
$scope.useradd.email = '';
$scope.useradd.fallbackEmail = '';
$scope.useradd.username = '';
$scope.useradd.displayName = '';
$scope.useradd.selectedGroups = [];
$scope.useradd.role = 'user';
$scope.useradd.sendInvite = false;
$scope.userAdd.error = {};
$scope.userAdd.email = '';
$scope.userAdd.fallbackEmail = '';
$scope.userAdd.username = '';
$scope.userAdd.displayName = '';
$scope.userAdd.selectedLocalGroups = [];
$scope.userAdd.role = 'user';
$scope.userAdd.sendInvite = false;
$scope.useraddForm.$setUntouched();
$scope.useraddForm.$setPristine();
@@ -291,33 +292,33 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
},
submit: function () {
$scope.useradd.busy = true;
$scope.userAdd.busy = true;
$scope.useradd.alreadyTaken = false;
$scope.useradd.error.email = null;
$scope.useradd.error.fallbackEmail = null;
$scope.useradd.error.username = null;
$scope.useradd.error.displayName = null;
$scope.userAdd.alreadyTaken = false;
$scope.userAdd.error.email = null;
$scope.userAdd.error.fallbackEmail = null;
$scope.userAdd.error.username = null;
$scope.userAdd.error.displayName = null;
var user = {
username: $scope.useradd.username || null,
email: $scope.useradd.email,
fallbackEmail: $scope.useradd.fallbackEmail,
displayName: $scope.useradd.displayName,
role: $scope.useradd.role
username: $scope.userAdd.username || null,
email: $scope.userAdd.email,
fallbackEmail: $scope.userAdd.fallbackEmail,
displayName: $scope.userAdd.displayName,
role: $scope.userAdd.role
};
Client.addUser(user, function (error, userId) {
if (error) {
$scope.useradd.busy = false;
$scope.userAdd.busy = false;
if (error.statusCode === 409) {
if (error.message.toLowerCase().indexOf('email') !== -1) {
$scope.useradd.error.email = 'Email already taken';
$scope.userAdd.error.email = 'Email already taken';
$scope.useraddForm.email.$setPristine();
$('#inputUserAddEmail').focus();
} else if (error.message.toLowerCase().indexOf('username') !== -1 || error.message.toLowerCase().indexOf('mailbox') !== -1) {
$scope.useradd.error.username = 'Username already taken';
$scope.userAdd.error.username = 'Username already taken';
$scope.useraddForm.username.$setPristine();
$('#inputUserAddUsername').focus();
} else {
@@ -327,12 +328,12 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
return;
} else if (error.statusCode === 400) {
if (error.message.toLowerCase().indexOf('email') !== -1) {
$scope.useradd.error.email = 'Invalid Email';
$scope.useradd.error.emailAttempted = $scope.useradd.email;
$scope.userAdd.error.email = 'Invalid Email';
$scope.userAdd.error.emailAttempted = $scope.userAdd.email;
$scope.useraddForm.email.$setPristine();
$('#inputUserAddEmail').focus();
} else if (error.message.toLowerCase().indexOf('username') !== -1) {
$scope.useradd.error.username = error.message;
$scope.userAdd.error.username = error.message;
$scope.useraddForm.username.$setPristine();
$('#inputUserAddUsername').focus();
} else {
@@ -344,16 +345,16 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
}
}
var groupIds = $scope.useradd.selectedGroups.map(function (g) { return g.id; });
var localGroupIds = $scope.userAdd.selectedLocalGroups.map(function (g) { return g.id; });
Client.setGroups(userId, groupIds, function (error) {
$scope.useradd.busy = false;
Client.setLocalGroups(userId, localGroupIds, function (error) {
$scope.userAdd.busy = false;
if (error) return console.error(error);
if ($scope.useradd.sendInvite) Client.sendInviteEmail(userId, user.email, function (error) { if (error) console.error('Failed to send invite.', error); });
if ($scope.userAdd.sendInvite) Client.sendInviteEmail(userId, user.email, function (error) { if (error) console.error('Failed to send invite.', error); });
refresh();
refreshCurrentPage();
refreshAllUsers();
$('#userAddModal').modal('hide');
@@ -362,7 +363,7 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
}
};
$scope.useredit = {
$scope.userEdit = {
busy: false,
reset2FABusy: false,
error: {},
@@ -376,20 +377,22 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
displayName: '',
active: false,
source: '',
selectedGroups: [],
selectedLocalGroups: [],
externalGroups: [],
role: '',
show: function (userInfo) {
$scope.useredit.error = {};
$scope.useredit.username = userInfo.username;
$scope.useredit.email = userInfo.email;
$scope.useredit.displayName = userInfo.displayName;
$scope.useredit.fallbackEmail = userInfo.fallbackEmail;
$scope.useredit.userInfo = userInfo;
$scope.useredit.selectedGroups = userInfo.groupIds.map(function (gid) { return $scope.groupsById[gid]; });
$scope.useredit.active = userInfo.active;
$scope.useredit.source = userInfo.source;
$scope.useredit.role = userInfo.role;
$scope.userEdit.error = {};
$scope.userEdit.username = userInfo.username;
$scope.userEdit.email = userInfo.email;
$scope.userEdit.displayName = userInfo.displayName;
$scope.userEdit.fallbackEmail = userInfo.fallbackEmail;
$scope.userEdit.userInfo = userInfo;
$scope.userEdit.selectedLocalGroups = userInfo.groupIds.map(function (gid) { return $scope.groupsById[gid]; }).filter(function (g) { return g.source === ''; });
$scope.userEdit.externalGroups = userInfo.groupIds.map(function (gid) { return $scope.groupsById[gid]; }).filter(function (g) { return g.source !== ''; });
$scope.userEdit.active = userInfo.active;
$scope.userEdit.source = userInfo.source;
$scope.userEdit.role = userInfo.role;
$scope.useredit_form.$setPristine();
$scope.useredit_form.$setUntouched();
@@ -398,72 +401,69 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
},
submit: function () {
$scope.useredit.error = {};
$scope.useredit.busy = true;
$scope.userEdit.error = {};
$scope.userEdit.busy = true;
var userId = $scope.useredit.userInfo.id;
var data = {
id: userId
};
var userId = $scope.userEdit.userInfo.id;
// only send if not the current active user
if (userId !== $scope.userInfo.id) {
data.active = $scope.useredit.active;
data.role = $scope.useredit.role;
}
async.series([
function setRole(next) {
if (userId === $scope.userInfo.id) return next(); // cannot set role on self
Client.setRole(userId, $scope.userEdit.role, next);
},
function setActive(next) {
if (userId === $scope.userInfo.id) return next(); // cannot set role on self
Client.setActive(userId, $scope.userEdit.active, next);
},
function updateUserProfile(next) {
if ($scope.userEdit.source) return next(); // cannot update profile of external user
// username is settable only if it was empty previously. it's editable for the "lock" profiles feature
var data = {};
if (!$scope.userEdit.userInfo.username) data.username = $scope.userEdit.username;
data.email = $scope.userEdit.email;
data.displayName = $scope.userEdit.displayName;
data.fallbackEmail = $scope.userEdit.fallbackEmail;
Client.updateUserProfile(userId, data, next);
},
function setLocalGroups(next) {
var localGroupIds = $scope.userEdit.selectedLocalGroups.map(function (g) { return g.id; });
Client.setLocalGroups(userId, localGroupIds, next);
}
], function (error) {
$scope.userEdit.busy = false;
// only change those if it is a local user
if (!$scope.useredit.source) {
// username is settable only if it was empty previously. it's editable for the "lock" profiles feature
if (!$scope.useredit.userInfo.username) data.username = $scope.useredit.username;
data.email = $scope.useredit.email;
data.displayName = $scope.useredit.displayName;
data.fallbackEmail = $scope.useredit.fallbackEmail;
}
Client.updateUser(data, function (error) {
if (error) {
$scope.useredit.busy = false;
if (error.statusCode === 409) {
if (error.message.toLowerCase().indexOf('email') !== -1) {
$scope.useredit.error.email = 'Email already taken';
$scope.userEdit.error.email = 'Email already taken';
} else if (error.message.toLowerCase().indexOf('username') !== -1) {
$scope.useredit.error.username = 'Username already taken';
$scope.userEdit.error.username = 'Username already taken';
}
$scope.useredit_form.email.$setPristine();
$('#inputUserEditEmail').focus();
} else {
$scope.useredit.error.generic = error.message;
$scope.userEdit.error.generic = error.message;
console.error('Unable to update user:', error);
}
return;
}
var groupIds = $scope.useredit.selectedGroups.map(function (g) { return g.id; });
Client.setGroups(data.id, groupIds, function (error) {
$scope.useredit.busy = false;
if (error) return console.error('Unable to update groups for user:', error);
refreshUsers(false);
$('#userEditModal').modal('hide');
});
refreshUsersCurrentPage(false /* busy indicator */);
refreshGroups();
$('#userEditModal').modal('hide');
});
},
reset2FA: function () {
$scope.useredit.reset2FABusy = true;
$scope.userEdit.reset2FABusy = true;
Client.disableTwoFactorAuthenticationByUserId($scope.useredit.userInfo.id, function (error) {
Client.disableTwoFactorAuthenticationByUserId($scope.userEdit.userInfo.id, function (error) {
if (error) return console.error(error);
$timeout(function () {
$scope.useredit.userInfo.twoFactorAuthenticationEnabled = false;
$scope.useredit.reset2FABusy = false;
$scope.userEdit.userInfo.twoFactorAuthenticationEnabled = false;
$scope.userEdit.reset2FABusy = false;
}, 3000);
});
}
@@ -517,7 +517,7 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
if (error) return console.error('Unable to add memebers.', error.statusCode, error.message);
refresh();
refreshCurrentPage();
$('#groupAddModal').modal('hide');
});
@@ -556,11 +556,61 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
$('#groupEditModal').modal('show');
},
updateAccessRestriction: function () {
// find apps where ACL has changed
var addedApps = $scope.groupEdit.selectedApps.filter(function (a) {
return !$scope.groupEdit.selectedAppsOriginal.find(function (b) { return b.id === a.id; });
});
var removedApps = $scope.groupEdit.selectedAppsOriginal.filter(function (a) {
return !$scope.groupEdit.selectedApps.find(function (b) { return b.id === a.id; });
});
async.eachSeries(addedApps, function (app, callback) {
var accessRestriction = app.accessRestriction;
if (!accessRestriction) accessRestriction = { users: [], groups: [] };
if (!Array.isArray(accessRestriction.groups)) accessRestriction.groups = [];
accessRestriction.groups.push($scope.groupEdit.groupInfo.id);
Client.configureApp(app.id, 'access_restriction', { accessRestriction: accessRestriction }, callback);
}, function (error) {
if (error) {
$scope.groupEdit.busy = false;
return console.error('Unable to set added app access.', error.statusCode, error.message);
}
async.eachSeries(removedApps, function (app, callback) {
var accessRestriction = app.accessRestriction;
if (!accessRestriction) accessRestriction = { users: [], groups: [] };
if (!Array.isArray(accessRestriction.groups)) accessRestriction.groups = [];
var deleted = accessRestriction.groups.splice(accessRestriction.groups.indexOf($scope.groupEdit.groupInfo.id), 1);
// if not found return early
if (deleted.length === 0) return callback();
Client.configureApp(app.id, 'access_restriction', { accessRestriction: accessRestriction }, callback);
}, function (error) {
$scope.groupEdit.busy = false;
if (error) return console.error('Unable to set removed app access.', error.statusCode, error.message);
refreshCurrentPage();
// refresh apps to reflect change
Client.refreshInstalledApps();
$('#groupEditModal').modal('hide');
});
});
},
submit: function () {
$scope.groupEdit.busy = true;
$scope.groupEdit.error = {};
Client.updateGroup($scope.groupEdit.groupInfo.id, $scope.groupEdit.name, function (error) {
if ($scope.groupEdit.source) return $scope.groupEdit.updateAccessRestriction(); // cannot update name or members of external groups
Client.setGroupName($scope.groupEdit.groupInfo.id, $scope.groupEdit.name, function (error) {
if (error) {
$scope.groupEdit.busy = false;
@@ -587,51 +637,7 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
return console.error('Unable to set group members.', error.statusCode, error.message);
}
// find apps where ACL has changed
var addedApps = $scope.groupEdit.selectedApps.filter(function (a) {
return !$scope.groupEdit.selectedAppsOriginal.find(function (b) { return b.id === a.id; });
});
var removedApps = $scope.groupEdit.selectedAppsOriginal.filter(function (a) {
return !$scope.groupEdit.selectedApps.find(function (b) { return b.id === a.id; });
});
async.eachSeries(addedApps, function (app, callback) {
var accessRestriction = app.accessRestriction;
if (!accessRestriction) accessRestriction = { users: [], groups: [] };
if (!Array.isArray(accessRestriction.groups)) accessRestriction.groups = [];
accessRestriction.groups.push($scope.groupEdit.groupInfo.id);
Client.configureApp(app.id, 'access_restriction', { accessRestriction: accessRestriction }, callback);
}, function (error) {
if (error) {
$scope.groupEdit.busy = false;
return console.error('Unable to set added app access.', error.statusCode, error.message);
}
async.eachSeries(removedApps, function (app, callback) {
var accessRestriction = app.accessRestriction;
if (!accessRestriction) accessRestriction = { users: [], groups: [] };
if (!Array.isArray(accessRestriction.groups)) accessRestriction.groups = [];
var deleted = accessRestriction.groups.splice(accessRestriction.groups.indexOf($scope.groupEdit.groupInfo.id), 1);
// if not found return early
if (deleted.length === 0) return callback();
Client.configureApp(app.id, 'access_restriction', { accessRestriction: accessRestriction }, callback);
}, function (error) {
$scope.groupEdit.busy = false;
if (error) return console.error('Unable to set removed app access.', error.statusCode, error.message);
refresh();
// refresh apps to reflect change
Client.refreshInstalledApps();
$('#groupEditModal').modal('hide');
});
});
$scope.groupEdit.updateAccessRestriction();
});
});
}
@@ -668,7 +674,7 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
if (error) return console.error('Unable to remove group.', error.statusCode, error.message);
refresh();
refreshCurrentPage();
$('#groupRemoveModal').modal('hide');
});
}
@@ -717,32 +723,6 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
}
};
$scope.makeLocal = {
busy: false,
user: null,
show: function (user) {
$scope.makeLocal.busy = false;
$scope.makeLocal.user = user;
$('#makeLocalModal').modal('show');
},
submit: function () {
$scope.makeLocal.busy = false;
Client.makeUserLocal($scope.makeLocal.user.id, function (error) {
if (error) return console.error('Failed to make user local.', error);
$scope.makeLocal.busy = false;
refreshUsers();
$('#makeLocalModal').modal('hide');
});
}
};
$scope.invitation = {
busy: false,
inviteLink: '',
@@ -825,7 +805,7 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
}
};
function getUsers(callback) {
function getUsersCurrentPage(callback) {
var users = [];
Client.getUsers($scope.userSearchString, $scope.userStateFilter.value, $scope.currentPage, $scope.pageItems, function (error, results) {
@@ -845,10 +825,10 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
});
}
function refreshUsers(showBusy) { // loads users on current page only
function refreshUsersCurrentPage(showBusy) { // loads users on current page only
if (showBusy) $scope.userRefreshBusy = true;
getUsers(function (error, result) {
getUsersCurrentPage(function (error, result) {
if (error) return console.error('Unable to get user listing.', error);
angular.copy(result, $scope.users);
@@ -867,34 +847,36 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
angular.copy(result, $scope.groups);
$scope.groupsById = { };
$scope.hasLocalGroups = false;
for (var i = 0; i < result.length; i++) {
$scope.groupsById[result[i].id] = result[i];
if (result[i].source === '') $scope.hasLocalGroups = true;
}
if (callback) callback();
});
}
function refresh() {
function refreshCurrentPage() {
refreshGroups(function (error) {
if (error) return console.error('Unable to get group listing.', error);
refreshUsers(true);
refreshUsersCurrentPage(true /* busy indicator */);
});
}
$scope.showNextPage = function () {
$scope.currentPage++;
refreshUsers();
refreshUsersCurrentPage(false /* no busy indicator */);
};
$scope.showPrevPage = function () {
if ($scope.currentPage > 1) $scope.currentPage--;
else $scope.currentPage = 1;
refreshUsers();
refreshUsersCurrentPage(false /* no busy indicator */);
};
$scope.updateFilter = function () {
refreshUsers();
refreshUsersCurrentPage(false /* no busy indicator */);
};
function refreshAllUsers() { // this loads all users on Cloudron, not just current page
@@ -911,7 +893,7 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
}
Client.onReady(function () {
refresh();
refreshCurrentPage();
refreshAllUsers();
// Order matters for permissions used in canEdit
+172 -173
View File
@@ -8,32 +8,32 @@
"name": "my-vue-app",
"version": "0.0.0",
"dependencies": {
"@fontsource/noto-sans": "^5.0.17",
"@fontsource/noto-sans": "^5.0.19",
"@xterm/addon-attach": "^0.10.0",
"@xterm/addon-fit": "^0.9.0",
"@xterm/xterm": "^5.4.0",
"anser": "^2.1.1",
"combokeys": "^3.0.1",
"filesize": "^10.1.0",
"marked": "^10.0.0",
"moment": "^2.29.4",
"pankow": "^1.1.8",
"marked": "^12.0.1",
"moment": "^2.30.1",
"pankow": "^1.2.1",
"primeicons": "^6.0.1",
"primevue": "^3.41.1",
"primevue": "^3.49.1",
"superagent": "^8.1.2",
"vue": "^3.3.9",
"vue-i18n": "^9.7.1",
"vue-router": "^4.2.5",
"xterm": "^5.3.0",
"xterm-addon-attach": "^0.9.0",
"xterm-addon-fit": "^0.8.0"
"vue": "^3.4.21",
"vue-i18n": "^9.10.1",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.0",
"vite": "^5.0.2"
"@vitejs/plugin-vue": "^5.0.4",
"vite": "^5.1.5"
}
},
"node_modules/@babel/parser": {
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.4.tgz",
"integrity": "sha512-vf3Xna6UEprW+7t6EtOmFpHNAuxw3xqPZghy+brsnusscJRW5BMUzzHZc5ICjULee81WeUV2jjakG09MDglJXQ==",
"version": "7.24.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.0.tgz",
"integrity": "sha512-QuP/FxEAzMSjXygs8v4N9dvdXzEHN4W1oF3PxuWAtPo08UdM17u89RDMgjLn/mlc56iM0HlLmVkO/wgR+rDgHg==",
"bin": {
"parser": "bin/babel-parser.js"
},
@@ -394,17 +394,17 @@
}
},
"node_modules/@fontsource/noto-sans": {
"version": "5.0.17",
"resolved": "https://registry.npmjs.org/@fontsource/noto-sans/-/noto-sans-5.0.17.tgz",
"integrity": "sha512-VcnKA99cE8OgRiy6O3T6xCKirsguD5+MYrGrbBWYA3m3fqDArCr66eEvR3iuTngGLbTODJq4bzc6yfaiGZu/pQ=="
"version": "5.0.19",
"resolved": "https://registry.npmjs.org/@fontsource/noto-sans/-/noto-sans-5.0.19.tgz",
"integrity": "sha512-5PmyWnplHmjuUwkaSpOljOcZ2GrdV4H2fDQS/OpmcgpgvgRM+8YYAoEI4xOlXb15kwWy/nHAFQYw3EYu7gPeng=="
},
"node_modules/@intlify/core-base": {
"version": "9.7.1",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.7.1.tgz",
"integrity": "sha512-jPJTeECEhqQ7g//8g3Fb79j5SzSSRqlFCWD6pcX94uMLXU+L1m07gVZnnvzoJBnaMyJHiiwxOqZVfvu6rQfLvw==",
"version": "9.10.1",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.10.1.tgz",
"integrity": "sha512-0+Wtjj04GIyglh5KKiNjRwgjpHrhqqGZhaKY/QVjjogWKZq5WHROrTi84pNVsRN18QynyPmjtsVUWqFKPQ45xQ==",
"dependencies": {
"@intlify/message-compiler": "9.7.1",
"@intlify/shared": "9.7.1"
"@intlify/message-compiler": "9.10.1",
"@intlify/shared": "9.10.1"
},
"engines": {
"node": ">= 16"
@@ -414,11 +414,11 @@
}
},
"node_modules/@intlify/message-compiler": {
"version": "9.7.1",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.7.1.tgz",
"integrity": "sha512-HfIr2Hn/K7b0Zv4kGqkxAxwtipyxAwhI9a3krN5cuhH/G9gkaik7of1PdzjR3Mix43t2onBiKYQyaU7mo7e0aA==",
"version": "9.10.1",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.10.1.tgz",
"integrity": "sha512-b68UTmRhgZfswJZI7VAgW6BXZK5JOpoi5swMLGr4j6ss2XbFY13kiw+Hu+xYAfulMPSapcHzdWHnq21VGnMCnA==",
"dependencies": {
"@intlify/shared": "9.7.1",
"@intlify/shared": "9.10.1",
"source-map-js": "^1.0.2"
},
"engines": {
@@ -429,9 +429,9 @@
}
},
"node_modules/@intlify/shared": {
"version": "9.7.1",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.7.1.tgz",
"integrity": "sha512-CBKnHzlUYGrk5QII9q4nElAQKO5cX1rRx8VmSWXltyOZjbkGHXYQTHULn6KwRi+CypuBCfmPkyPBHMzosypIeg==",
"version": "9.10.1",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.10.1.tgz",
"integrity": "sha512-liyH3UMoglHBUn70iCYcy9CQlInx/lp50W2aeSxqqrvmG+LDj/Jj7tBJhBoQL4fECkldGhbmW0g2ommHfL6Wmw==",
"engines": {
"node": ">= 16"
},
@@ -629,124 +629,133 @@
"peer": true
},
"node_modules/@vitejs/plugin-vue": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.5.0.tgz",
"integrity": "sha512-a2WSpP8X8HTEww/U00bU4mX1QpLINNuz/2KMNpLsdu3BzOpak3AGI1CJYBTXcc4SPhaD0eNRUp7IyQK405L5dQ==",
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.0.4.tgz",
"integrity": "sha512-WS3hevEszI6CEVEx28F8RjTX97k3KsrcY6kvTg7+Whm5y3oYvcqzVeGCU3hxSAn4uY2CLCkeokkGKpoctccilQ==",
"dev": true,
"engines": {
"node": "^14.18.0 || >=16.0.0"
"node": "^18.0.0 || >=20.0.0"
},
"peerDependencies": {
"vite": "^4.0.0 || ^5.0.0",
"vite": "^5.0.0",
"vue": "^3.2.25"
}
},
"node_modules/@vue/compiler-core": {
"version": "3.3.9",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.3.9.tgz",
"integrity": "sha512-+/Lf68Vr/nFBA6ol4xOtJrW+BQWv3QWKfRwGSm70jtXwfhZNF4R/eRgyVJYoxFRhdCTk/F6g99BP0ffPgZihfQ==",
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.21.tgz",
"integrity": "sha512-MjXawxZf2SbZszLPYxaFCjxfibYrzr3eYbKxwpLR9EQN+oaziSu3qKVbwBERj1IFIB8OLUewxB5m/BFzi613og==",
"dependencies": {
"@babel/parser": "^7.23.3",
"@vue/shared": "3.3.9",
"@babel/parser": "^7.23.9",
"@vue/shared": "3.4.21",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.0.2"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.3.9",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.3.9.tgz",
"integrity": "sha512-nfWubTtLXuT4iBeDSZ5J3m218MjOy42Vp2pmKVuBKo2/BLcrFUX8nCSr/bKRFiJ32R8qbdnnnBgRn9AdU5v0Sg==",
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.21.tgz",
"integrity": "sha512-IZC6FKowtT1sl0CR5DpXSiEB5ayw75oT2bma1BEhV7RRR1+cfwLrxc2Z8Zq/RGFzJ8w5r9QtCOvTjQgdn0IKmA==",
"dependencies": {
"@vue/compiler-core": "3.3.9",
"@vue/shared": "3.3.9"
"@vue/compiler-core": "3.4.21",
"@vue/shared": "3.4.21"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.3.9",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.3.9.tgz",
"integrity": "sha512-wy0CNc8z4ihoDzjASCOCsQuzW0A/HP27+0MDSSICMjVIFzk/rFViezkR3dzH+miS2NDEz8ywMdbjO5ylhOLI2A==",
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.21.tgz",
"integrity": "sha512-me7epoTxYlY+2CUM7hy9PCDdpMPfIwrOvAXud2Upk10g4YLv9UBW7kL798TvMeDhPthkZ0CONNrK2GoeI1ODiQ==",
"dependencies": {
"@babel/parser": "^7.23.3",
"@vue/compiler-core": "3.3.9",
"@vue/compiler-dom": "3.3.9",
"@vue/compiler-ssr": "3.3.9",
"@vue/reactivity-transform": "3.3.9",
"@vue/shared": "3.3.9",
"@babel/parser": "^7.23.9",
"@vue/compiler-core": "3.4.21",
"@vue/compiler-dom": "3.4.21",
"@vue/compiler-ssr": "3.4.21",
"@vue/shared": "3.4.21",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.5",
"postcss": "^8.4.31",
"magic-string": "^0.30.7",
"postcss": "^8.4.35",
"source-map-js": "^1.0.2"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.3.9",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.3.9.tgz",
"integrity": "sha512-NO5oobAw78R0G4SODY5A502MGnDNiDjf6qvhn7zD7TJGc8XDeIEw4fg6JU705jZ/YhuokBKz0A5a/FL/XZU73g==",
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.21.tgz",
"integrity": "sha512-M5+9nI2lPpAsgXOGQobnIueVqc9sisBFexh5yMIMRAPYLa7+5wEJs8iqOZc1WAa9WQbx9GR2twgznU8LTIiZ4Q==",
"dependencies": {
"@vue/compiler-dom": "3.3.9",
"@vue/shared": "3.3.9"
"@vue/compiler-dom": "3.4.21",
"@vue/shared": "3.4.21"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.5.0.tgz",
"integrity": "sha512-o9KfBeaBmCKl10usN4crU53fYtC1r7jJwdGKjPT24t348rHxgfpZ0xL3Xm/gLUYnc0oTp8LAmrxOeLyu6tbk2Q=="
"version": "6.6.1",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.1.tgz",
"integrity": "sha512-LgPscpE3Vs0x96PzSSB4IGVSZXZBZHpfxs+ZA1d+VEPwHdOXowy/Y2CsvCAIFrf+ssVU1pD1jidj505EpUnfbA=="
},
"node_modules/@vue/reactivity": {
"version": "3.3.9",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.3.9.tgz",
"integrity": "sha512-VmpIqlNp+aYDg2X0xQhJqHx9YguOmz2UxuUJDckBdQCNkipJvfk9yA75woLWElCa0Jtyec3lAAt49GO0izsphw==",
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.21.tgz",
"integrity": "sha512-UhenImdc0L0/4ahGCyEzc/pZNwVgcglGy9HVzJ1Bq2Mm9qXOpP8RyNTjookw/gOCUlXSEtuZ2fUg5nrHcoqJcw==",
"dependencies": {
"@vue/shared": "3.3.9"
}
},
"node_modules/@vue/reactivity-transform": {
"version": "3.3.9",
"resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.3.9.tgz",
"integrity": "sha512-HnUFm7Ry6dFa4Lp63DAxTixUp8opMtQr6RxQCpDI1vlh12rkGIeYqMvJtK+IKyEfEOa2I9oCkD1mmsPdaGpdVg==",
"dependencies": {
"@babel/parser": "^7.23.3",
"@vue/compiler-core": "3.3.9",
"@vue/shared": "3.3.9",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.5"
"@vue/shared": "3.4.21"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.3.9",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.3.9.tgz",
"integrity": "sha512-xxaG9KvPm3GTRuM4ZyU8Tc+pMVzcu6eeoSRQJ9IE7NmCcClW6z4B3Ij6L4EDl80sxe/arTtQ6YmgiO4UZqRc+w==",
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.21.tgz",
"integrity": "sha512-pQthsuYzE1XcGZznTKn73G0s14eCJcjaLvp3/DKeYWoFacD9glJoqlNBxt3W2c5S40t6CCcpPf+jG01N3ULyrA==",
"dependencies": {
"@vue/reactivity": "3.3.9",
"@vue/shared": "3.3.9"
"@vue/reactivity": "3.4.21",
"@vue/shared": "3.4.21"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.3.9",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.3.9.tgz",
"integrity": "sha512-e7LIfcxYSWbV6BK1wQv9qJyxprC75EvSqF/kQKe6bdZEDNValzeRXEVgiX7AHI6hZ59HA4h7WT5CGvm69vzJTQ==",
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.21.tgz",
"integrity": "sha512-gvf+C9cFpevsQxbkRBS1NpU8CqxKw0ebqMvLwcGQrNpx6gqRDodqKqA+A2VZZpQ9RpK2f9yfg8VbW/EpdFUOJw==",
"dependencies": {
"@vue/runtime-core": "3.3.9",
"@vue/shared": "3.3.9",
"csstype": "^3.1.2"
"@vue/runtime-core": "3.4.21",
"@vue/shared": "3.4.21",
"csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.3.9",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.3.9.tgz",
"integrity": "sha512-w0zT/s5l3Oa3ZjtLW88eO4uV6AQFqU8X5GOgzq7SkQQu6vVr+8tfm+OI2kDBplS/W/XgCBuFXiPw6T5EdwXP0A==",
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.21.tgz",
"integrity": "sha512-aV1gXyKSN6Rz+6kZ6kr5+Ll14YzmIbeuWe7ryJl5muJ4uwSwY/aStXTixx76TwkZFJLm1aAlA/HSWEJ4EyiMkg==",
"dependencies": {
"@vue/compiler-ssr": "3.3.9",
"@vue/shared": "3.3.9"
"@vue/compiler-ssr": "3.4.21",
"@vue/shared": "3.4.21"
},
"peerDependencies": {
"vue": "3.3.9"
"vue": "3.4.21"
}
},
"node_modules/@vue/shared": {
"version": "3.3.9",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.3.9.tgz",
"integrity": "sha512-ZE0VTIR0LmYgeyhurPTpy4KzKsuDyQbMSdM49eKkMnT5X4VfFBLysMzjIZhLEFQYjjOVVfbvUDHckwjDFiO2eA=="
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.21.tgz",
"integrity": "sha512-PuJe7vDIi6VYSinuEbUIQgMIRZGgM8e4R+G+/dQTk0X1NEdvgvvgv7m+rfmDH1gZzyA1OjjoWskvHlfRNfQf3g=="
},
"node_modules/@xterm/addon-attach": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-attach/-/addon-attach-0.10.0.tgz",
"integrity": "sha512-ES/XO8pC1tPHSkh4j7qzM8ajFt++u8KMvfRc9vKIbjHTDOxjl9IUVo+vcQgLn3FTCM3w2czTvBss8nMWlD83Cg==",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.9.0.tgz",
"integrity": "sha512-hDlPPbTVPYyvwXu/asW8HbJkI/2RMi0cMaJnBZYVeJB0SWP2NeESMCNr+I7CvBlyI0sAxpxOg8Wk4OMkxBz9WA==",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/xterm": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.4.0.tgz",
"integrity": "sha512-GlyzcZZ7LJjhFevthHtikhiDIl8lnTSgol6eTM4aoSNLcuXu3OEhnbqdCVIjtIil3jjabf3gDtb1S8FGahsuEw=="
},
"node_modules/abbrev": {
"version": "1.1.1",
@@ -909,9 +918,9 @@
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw=="
},
"node_modules/csstype": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/debug": {
"version": "4.3.4",
@@ -979,6 +988,17 @@
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"optional": true
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": {
"version": "0.19.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.7.tgz",
@@ -1261,9 +1281,9 @@
}
},
"node_modules/magic-string": {
"version": "0.30.5",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz",
"integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==",
"version": "0.30.7",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz",
"integrity": "sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
},
@@ -1296,9 +1316,9 @@
}
},
"node_modules/marked": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-10.0.0.tgz",
"integrity": "sha512-YiGcYcWj50YrwBgNzFoYhQ1hT6GmQbFG8SksnYJX1z4BXTHSOrz1GB5/Jm2yQvMg4nN1FHP4M6r03R10KrVUiA==",
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-12.0.1.tgz",
"integrity": "sha512-Y1/V2yafOcOdWQCX0XpAKXzDakPOpn6U0YLxTJs3cww6VxOzZV1BTOOYWLvH3gX38cq+iLwljHHTnMtlDfg01Q==",
"bin": {
"marked": "bin/marked.js"
},
@@ -1415,17 +1435,17 @@
}
},
"node_modules/moment": {
"version": "2.29.4",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
"integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==",
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"engines": {
"node": "*"
}
},
"node_modules/monaco-editor": {
"version": "0.44.0",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.44.0.tgz",
"integrity": "sha512-5SmjNStN6bSuSE5WPT2ZV+iYn1/yI9sd4Igtk23ChvqB7kDk9lZbB9F5frsuvpB+2njdIeGGFf2G4gbE6rCC9Q=="
"version": "0.46.0",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.46.0.tgz",
"integrity": "sha512-ADwtLIIww+9FKybWscd7OCfm9odsFYHImBRI1v9AviGce55QY8raT+9ihH8jX/E/e6QVSGM+pKj4jSUSRmALNQ=="
},
"node_modules/ms": {
"version": "2.1.2",
@@ -1439,9 +1459,9 @@
"optional": true
},
"node_modules/nanoid": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
"integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"funding": [
{
"type": "github",
@@ -1528,14 +1548,14 @@
}
},
"node_modules/pankow": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pankow/-/pankow-1.1.8.tgz",
"integrity": "sha512-XJkiE4GolbbNuXstc+tHoIP1jrRV6I6XLbgypEzr2hBxdr7wJsKjZF2rIpBDdTiK4l9KCRSA5vbqThD6ymA2GQ==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/pankow/-/pankow-1.2.1.tgz",
"integrity": "sha512-tlPxzyAOVCV40k3yLCJltqde30/mcCMfn80zWU5f/4t4oTgdPwL/e9vkv7lAxdNpJx3tkfu/UIvDzAnZP7h/Ig==",
"dependencies": {
"filesize": "^10.1.0",
"monaco-editor": "^0.44.0",
"monaco-editor": "^0.46.0",
"pdfjs-dist": "^3.11.174",
"primevue": "^3.41.1",
"primevue": "^3.49.1",
"superagent": "^8.1.2"
}
},
@@ -1575,9 +1595,9 @@
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="
},
"node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"version": "8.4.35",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",
"integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==",
"funding": [
{
"type": "opencollective",
@@ -1593,7 +1613,7 @@
}
],
"dependencies": {
"nanoid": "^3.3.6",
"nanoid": "^3.3.7",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
@@ -1607,9 +1627,9 @@
"integrity": "sha512-KDeO94CbWI4pKsPnYpA1FPjo79EsY9I+M8ywoPBSf9XMXoe/0crjbUK7jcQEDHuc0ZMRIZsxH3TYLv4TUtHmAA=="
},
"node_modules/primevue": {
"version": "3.41.1",
"resolved": "https://registry.npmjs.org/primevue/-/primevue-3.41.1.tgz",
"integrity": "sha512-+RDGLsw7ktS2Jz/mptPwwbW/YI5E0u1tWYustlG745FFdX3o1/Dxs47hdnvo22wysPUQr1mdh/uznsKJZMV7fw==",
"version": "3.49.1",
"resolved": "https://registry.npmjs.org/primevue/-/primevue-3.49.1.tgz",
"integrity": "sha512-OmUTqbKbPB63Zqf7uA49cipDi+Qh+/13AYJPwgvsVsI4QmAKIkeibBwkOgj1CNIFlopfF79YmyBshFUAPqlw9A==",
"peerDependencies": {
"vue": "^3.0.0"
}
@@ -1868,13 +1888,13 @@
"optional": true
},
"node_modules/vite": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.0.2.tgz",
"integrity": "sha512-6CCq1CAJCNM1ya2ZZA7+jS2KgnhbzvxakmlIjN24cF/PXhRMzpM/z8QgsVJA/Dm5fWUWnVEsmtBoMhmerPxT0g==",
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.5.tgz",
"integrity": "sha512-BdN1xh0Of/oQafhU+FvopafUp6WaYenLU/NFoL5WyJL++GxkNfieKzBhM24H3HVsPQrlAqB7iJYTHabzaRed5Q==",
"dev": true,
"dependencies": {
"esbuild": "^0.19.3",
"postcss": "^8.4.31",
"postcss": "^8.4.35",
"rollup": "^4.2.0"
},
"bin": {
@@ -1923,15 +1943,15 @@
}
},
"node_modules/vue": {
"version": "3.3.9",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.3.9.tgz",
"integrity": "sha512-sy5sLCTR8m6tvUk1/ijri3Yqzgpdsmxgj6n6yl7GXXCXqVbmW2RCXe9atE4cEI6Iv7L89v5f35fZRRr5dChP9w==",
"version": "3.4.21",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.4.21.tgz",
"integrity": "sha512-5hjyV/jLEIKD/jYl4cavMcnzKwjMKohureP8ejn3hhEjwhWIhWeuzL2kJAjzl/WyVsgPY56Sy4Z40C3lVshxXA==",
"dependencies": {
"@vue/compiler-dom": "3.3.9",
"@vue/compiler-sfc": "3.3.9",
"@vue/runtime-dom": "3.3.9",
"@vue/server-renderer": "3.3.9",
"@vue/shared": "3.3.9"
"@vue/compiler-dom": "3.4.21",
"@vue/compiler-sfc": "3.4.21",
"@vue/runtime-dom": "3.4.21",
"@vue/server-renderer": "3.4.21",
"@vue/shared": "3.4.21"
},
"peerDependencies": {
"typescript": "*"
@@ -1943,12 +1963,12 @@
}
},
"node_modules/vue-i18n": {
"version": "9.7.1",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.7.1.tgz",
"integrity": "sha512-A6DzWqJQMdzBj+392+g3zIgGV0FnFC7o/V+txs5yIALANEZzY6ZV8hM2wvZR3nTbQI7dntAmzBHMeoEteJO0kQ==",
"version": "9.10.1",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.10.1.tgz",
"integrity": "sha512-37HVJQZ/pZaRXGzFmmMomM1u1k7kndv3xCBPYHKEVfv5W3UVK67U/TpBug71ILYLNmjHLHdvTUPRF81pFT5fFg==",
"dependencies": {
"@intlify/core-base": "9.7.1",
"@intlify/shared": "9.7.1",
"@intlify/core-base": "9.10.1",
"@intlify/shared": "9.10.1",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
@@ -1962,11 +1982,11 @@
}
},
"node_modules/vue-router": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.2.5.tgz",
"integrity": "sha512-DIUpKcyg4+PTQKfFPX88UWhlagBEBEfJ5A8XDXRJLUnZOvcpMF8o/dnL90vpVkGaPbjvXazV/rC1qBKrZlFugw==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.3.0.tgz",
"integrity": "sha512-dqUcs8tUeG+ssgWhcPbjHvazML16Oga5w34uCUmsk7i0BcnskoLGwjpa15fqMr2Fa5JgVBrdL2MEgqz6XZ/6IQ==",
"dependencies": {
"@vue/devtools-api": "^6.5.0"
"@vue/devtools-api": "^6.5.1"
},
"funding": {
"url": "https://github.com/sponsors/posva"
@@ -2005,27 +2025,6 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/xterm": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg=="
},
"node_modules/xterm-addon-attach": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/xterm-addon-attach/-/xterm-addon-attach-0.9.0.tgz",
"integrity": "sha512-NykWWOsobVZPPK3P9eFkItrnBK9Lw0f94uey5zhqIVB1bhswdVBfl+uziEzSOhe2h0rT9wD0wOeAYsdSXeavPw==",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/xterm-addon-fit": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz",
"integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+13 -13
View File
@@ -9,25 +9,25 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource/noto-sans": "^5.0.17",
"@fontsource/noto-sans": "^5.0.19",
"anser": "^2.1.1",
"combokeys": "^3.0.1",
"filesize": "^10.1.0",
"marked": "^10.0.0",
"moment": "^2.29.4",
"pankow": "^1.1.8",
"marked": "^12.0.1",
"moment": "^2.30.1",
"pankow": "^1.2.1",
"primeicons": "^6.0.1",
"primevue": "^3.41.1",
"primevue": "^3.49.1",
"superagent": "^8.1.2",
"vue": "^3.3.9",
"vue-i18n": "^9.7.1",
"vue-router": "^4.2.5",
"xterm": "^5.3.0",
"xterm-addon-attach": "^0.9.0",
"xterm-addon-fit": "^0.8.0"
"vue": "^3.4.21",
"vue-i18n": "^9.10.1",
"vue-router": "^4.3.0",
"@xterm/xterm": "^5.4.0",
"@xterm/addon-attach": "^0.10.0",
"@xterm/addon-fit": "^0.9.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.0",
"vite": "^5.0.2"
"@vitejs/plugin-vue": "^5.0.4",
"vite": "^5.1.5"
}
}
+4 -4
View File
@@ -17,8 +17,8 @@
</TopBar>
</template>
<template #body>
<div v-for="line in logLines" :key="line.id" class="log-line">
<span class="time">{{ line.time }}</span><span v-html="line.html"></span>
<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 class="bottom-spacer"></div>
</template>
@@ -137,8 +137,8 @@ export default {
this.downloadUrl = this.logsModel.getDownloadUrl();
this.logsModel.stream((id, time, html) => {
this.logLines.push({ id, time, html});
this.logsModel.stream((time, html) => {
this.logLines.push({ time, html});
const tmp = document.getElementsByClassName('cloudron-layout-body')[0];
if (!tmp) return;
+5 -4
View File
@@ -71,10 +71,10 @@ import ProgressSpinner from 'primevue/progressspinner';
import { TopBar, MainLayout, FileUploader } from 'pankow';
import 'xterm/css/xterm.css';
import { Terminal } from 'xterm';
import { AttachAddon } from 'xterm-addon-attach';
import { FitAddon } from 'xterm-addon-fit';
import '@xterm/xterm/css/xterm.css';
import { Terminal } from '@xterm/xterm';
import { AttachAddon } from '@xterm/addon-attach';
import { FitAddon } from '@xterm/addon-fit';
import { create } from '../models/AppModel.js';
@@ -335,6 +335,7 @@ export default {
body {
background-color: black;
overflow: hidden;
}
.title {
+2 -2
View File
@@ -90,9 +90,9 @@ export function createDirectoryModel(origin, accessToken, api) {
await superagent.del(`${origin}/api/v1/${api}/files/${filePath}`)
.query({ access_token: accessToken });
},
async rename(fromFilePath, toFilePath) {
async rename(fromFilePath, toFilePath, overwrite = false) {
await superagent.put(`${origin}/api/v1/${api}/files/${fromFilePath}`)
.send({ action: 'rename', newFilePath: sanitize(toFilePath) })
.send({ action: 'rename', newFilePath: sanitize(toFilePath), overwrite })
.query({ access_token: accessToken });
},
async copy(fromFilePath, toFilePath) {
+1 -2
View File
@@ -69,11 +69,10 @@ export function create(origin, accessToken, type, id) {
return console.error(e);
}
const id = data.realtimeTimestamp;
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)));
lineHandler(id, time, html);
lineHandler(time, html);
};
},
getDownloadUrl() {
+20 -2
View File
@@ -378,8 +378,26 @@ export default {
},
async renameHandler(file, newName) {
await this.directoryModel.rename(this.directoryModel.buildFilePath(this.cwd, file.name), sanitize(this.cwd + '/' + newName));
await this.loadCwd();
if (file.name === newName) return;
try {
await this.directoryModel.rename(this.directoryModel.buildFilePath(this.cwd, file.name), sanitize(this.cwd + '/' + newName));
await this.loadCwd();
} catch (e) {
if (e.status === 409) {
this.$confirm.require({
message: this.$t('filemanager.renameDialog.reallyOverwrite'),
icon: '',
acceptClass: 'p-button-danger',
accept: async () => {
await this.directoryModel.rename(this.directoryModel.buildFilePath(this.cwd, file.name), sanitize(this.cwd + '/' + newName), true /* overwrite */);
await this.loadCwd();
this.$confirm.close();
}
});
}
else console.error(`Failed to rename ${file} to ${newName}`, e);
}
},
async changeOwnerHandler(files, newOwnerUid) {
if (!files) return;
@@ -0,0 +1,9 @@
'use strict';
exports.up = async function (db) {
await db.runSql('ALTER TABLE appPortBindings ADD COLUMN count INTEGER DEFAULT 1');
};
exports.down = async function (db) {
await db.runSql('ALTER TABLE appPortBindings DROP COLUMN count');
};
@@ -0,0 +1,9 @@
'use strict';
exports.up = async function (db) {
await db.runSql('ALTER TABLE users ADD COLUMN language VARCHAR(8) NOT NULL DEFAULT ""');
};
exports.down = async function (db) {
await db.runSql('ALTER TABLE users DROP COLUMN language');
};
@@ -0,0 +1,20 @@
'use strict';
// ensure the inboxDomain and mailboxDomain are cleared when the addons are missing or disabled
// this allows the domain to be deleted. otherwise, the ui hides these fields and user cannot do anything to delete the domain
exports.up = async function(db) {
const apps = await db.runSql('SELECT * FROM apps', []);
for (const app of apps) {
const manifest = JSON.parse(app.manifestJson);
if (!manifest.addons?.recvmail || !app.enableInbox) {
await db.runSql('UPDATE apps SET enableInbox=?, inboxName=?, inboxDomain=? WHERE id=?', [ false, null, null, app.id ]);
}
if (!manifest.addons?.sendmail || !app.enableMailbox) {
await db.runSql('UPDATE apps SET enableMailbox=?, mailboxName=?, mailboxDomain=? WHERE id=?', [ false, null, null, app.id ]);
}
}
};
exports.down = async function(/* db */) {
};
@@ -0,0 +1,17 @@
'use strict';
exports.up = function(db, callback) {
db.runSql('ALTER TABLE apps ADD CONSTRAINT inbox_domain_constraint FOREIGN KEY(inboxDomain) REFERENCES domains(domain)', function (error)
{
if (error) console.error(error);
callback(error);
});
};
exports.down = function(db, callback) {
db.runSql('ALTER TABLE apps DROP FOREIGN KEY inbox_domain_constraint', function (error) {
if (error) console.error(error);
callback(error);
});
};
+2
View File
@@ -105,6 +105,7 @@ CREATE TABLE IF NOT EXISTS apps(
upstreamUri VARCHAR(256) DEFAULT "",
FOREIGN KEY(mailboxDomain) REFERENCES domains(domain),
FOREIGN KEY(inboxDomain) REFERENCES domains(domain),
FOREIGN KEY(taskId) REFERENCES tasks(id),
FOREIGN KEY(storageVolumeId) REFERENCES volumes(id),
UNIQUE (storageVolumeId, storageVolumePrefix),
@@ -115,6 +116,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,
FOREIGN KEY(appId) REFERENCES apps(id),
PRIMARY KEY(hostPort));
+149 -156
View File
@@ -11,15 +11,15 @@
"@google-cloud/dns": "^3.0.2",
"@google-cloud/storage": "^6.12.0",
"async": "^3.2.5",
"aws-sdk": "^2.1502.0",
"aws-sdk": "^2.1554.0",
"basic-auth": "^2.0.1",
"body-parser": "^1.20.2",
"cloudron-manifestformat": "^5.21.0",
"cloudron-manifestformat": "^5.22.1",
"connect": "^3.7.0",
"connect-lastmile": "^2.2.0",
"connect-timeout": "^1.9.0",
"cookie-parser": "^1.4.6",
"cookie-session": "^2.0.0",
"cookie-session": "^2.1.0",
"cron": "^2.4.4",
"db-migrate": "^0.11.14",
"db-migrate-mysql": "^2.3.2",
@@ -33,18 +33,18 @@
"jsonwebtoken": "^9.0.2",
"ldapjs": "^2.3.3",
"marked": "^7.0.5",
"moment": "^2.29.4",
"moment-timezone": "^0.5.43",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
"multiparty": "^4.2.3",
"mysql": "^2.18.1",
"nodemailer": "^6.9.7",
"nodemailer": "^6.9.9",
"nsyslog-parser": "^0.10.1",
"oidc-provider": "^8.4.1",
"oidc-provider": "^8.4.5",
"ovh": "^2.0.3",
"qrcode": "^1.5.3",
"readdirp": "^3.6.0",
"safetydance": "^2.4.0",
"semver": "^7.5.4",
"semver": "^7.6.0",
"speakeasy": "^2.0.0",
"superagent": "^8.1.2",
"tar-fs": "github:cloudron-io/tar-fs#ignore_stat_error",
@@ -53,7 +53,7 @@
"underscore": "^1.13.6",
"uuid": "^9.0.1",
"validator": "^13.11.0",
"ws": "^8.14.2",
"ws": "^8.16.0",
"xml2js": "^0.6.2"
},
"bin": {
@@ -63,14 +63,14 @@
"devDependencies": {
"commander": "^11.1.0",
"easy-table": "^1.2.0",
"eslint": "^8.54.0",
"eslint": "^8.56.0",
"expect.js": "*",
"hock": "^1.4.1",
"js2xmlparser": "^5.0.0",
"mocha": "^10.2.0",
"mocha": "^10.3.0",
"mock-aws-s3": "git+https://github.com/cloudron-io/mock-aws-s3.git",
"nock": "^13.3.8",
"ssh2": "^1.14.0",
"nock": "^13.5.1",
"ssh2": "^1.15.0",
"yesno": "^0.4.0"
}
},
@@ -112,9 +112,9 @@
}
},
"node_modules/@eslint/eslintrc": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.3.tgz",
"integrity": "sha512-yZzuIG+jnVu6hNSzFEN07e8BxF3uAzYtQb6uDkaYZLo6oYZDCq454c5kB8zxnzfCYyP4MIuyBn10L0DqwujTmA==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
"integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
"dev": true,
"dependencies": {
"ajv": "^6.12.4",
@@ -147,9 +147,9 @@
}
},
"node_modules/@eslint/js": {
"version": "8.54.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.54.0.tgz",
"integrity": "sha512-ut5V+D+fOoWPgGGNj83GGjnntO39xDy6DWxO0wb7Jp3DcMX0TfIqdzHF85VTQkerdyGmuuMD9AKAo5KiNlf/AQ==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz",
"integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==",
"dev": true,
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -323,9 +323,9 @@
"dev": true
},
"node_modules/@koa/cors": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@koa/cors/-/cors-4.0.0.tgz",
"integrity": "sha512-Y4RrbvGTlAaa04DBoPBWJqDR5gPj32OOz827ULXfgB1F7piD1MB/zwn8JR2LAnvdILhxUbXbkXGWuNVsFuVFCQ==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@koa/cors/-/cors-5.0.0.tgz",
"integrity": "sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==",
"dependencies": {
"vary": "^1.1.2"
},
@@ -492,9 +492,9 @@
}
},
"node_modules/acorn": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz",
"integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==",
"version": "8.11.3",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
"integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
"dev": true,
"bin": {
"acorn": "bin/acorn"
@@ -641,9 +641,9 @@
}
},
"node_modules/aws-sdk": {
"version": "2.1502.0",
"resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1502.0.tgz",
"integrity": "sha512-mUXUaWmbIyqE6zyIcbUUQIUgw1evK7gV1vQP7ZZEE0qi6hO2Mw99Nc25Bh+187yvRxamMTsFXvvmBViR0Q75SA==",
"version": "2.1554.0",
"resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1554.0.tgz",
"integrity": "sha512-MmCfg80CKCOFeC8K6UMSmDLPPGVesAglOzmO2IMEugHt10UsK2szOa+C31IHO2PEnjhn+l4WoVlaBAN/YQX+tQ==",
"dependencies": {
"buffer": "4.9.2",
"events": "1.1.1",
@@ -654,7 +654,7 @@
"url": "0.10.3",
"util": "^0.12.4",
"uuid": "8.0.0",
"xml2js": "0.5.0"
"xml2js": "0.6.2"
},
"engines": {
"node": ">= 10.0.0"
@@ -667,18 +667,6 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/aws-sdk/node_modules/xml2js": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/backoff": {
"version": "2.5.0",
"license": "MIT",
@@ -1003,25 +991,34 @@
}
},
"node_modules/cloudron-manifestformat": {
"version": "5.21.0",
"resolved": "https://registry.npmjs.org/cloudron-manifestformat/-/cloudron-manifestformat-5.21.0.tgz",
"integrity": "sha512-FG3f2v1jq0GFbJnbTi6WOlHCCpZIrKdHC7uZBMbjcAkhMmEX505NUY/QZSmxyU+Eb3qwxX/UNI+zVfbJ9jOSDA==",
"version": "5.22.1",
"resolved": "https://registry.npmjs.org/cloudron-manifestformat/-/cloudron-manifestformat-5.22.1.tgz",
"integrity": "sha512-WfCko1oNbrwMLoErZEXD68Z62Ia/1hGrA2urVs5k6NbpbRvL7/kwIPHfGTYLr7N3jDHrgIudwanemO0YwfzNrg==",
"dependencies": {
"cron": "^2.4.3",
"cron": "^3.1.6",
"java-packagename-regex": "^1.0.0",
"safetydance": "2.2.0",
"semver": "^7.5.4",
"safetydance": "2.4.0",
"semver": "^7.6.0",
"tv4": "^1.3.0",
"validator": "^13.11.0"
}
},
"node_modules/cloudron-manifestformat/node_modules/safetydance": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/safetydance/-/safetydance-2.2.0.tgz",
"integrity": "sha512-TzAedqLBi4KLXVYUuFp17HhX2AJJlzFsZqlPWyO5GHFEeqhUo70azU+CiGeFKi8xlbrvHUIz0hSIqw3eQTXidw==",
"engines": [
"node >= 4.0.0"
]
"node_modules/cloudron-manifestformat/node_modules/cron": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/cron/-/cron-3.1.6.tgz",
"integrity": "sha512-cvFiQCeVzsA+QPM6fhjBtlKGij7tLLISnTSvFxVdnFGLdz+ZdXN37kNe0i2gefmdD17XuZA6n2uPVwzl4FxW/w==",
"dependencies": {
"@types/luxon": "~3.3.0",
"luxon": "~3.4.0"
}
},
"node_modules/cloudron-manifestformat/node_modules/luxon": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz",
"integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==",
"engines": {
"node": ">=12"
}
},
"node_modules/co": {
"version": "4.6.0",
@@ -1212,10 +1209,11 @@
}
},
"node_modules/cookie-session": {
"version": "2.0.0",
"license": "MIT",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-2.1.0.tgz",
"integrity": "sha512-u73BDmR8QLGcs+Lprs0cfbcAPKl2HnPcjpwRXT41sEV4DRJ2+W0vJEEZkG31ofkx+HZflA70siRIjiTdIodmOQ==",
"dependencies": {
"cookies": "0.8.0",
"cookies": "0.9.1",
"debug": "3.2.7",
"on-headers": "~1.0.2",
"safe-buffer": "5.2.1"
@@ -1224,6 +1222,18 @@
"node": ">= 0.10"
}
},
"node_modules/cookie-session/node_modules/cookies": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz",
"integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==",
"dependencies": {
"depd": "~2.0.0",
"keygrip": "~1.1.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/cookie-session/node_modules/debug": {
"version": "3.2.7",
"license": "MIT",
@@ -1231,6 +1241,14 @@
"ms": "^2.1.1"
}
},
"node_modules/cookie-session/node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/cookie-session/node_modules/safe-buffer": {
"version": "5.2.1",
"funding": [
@@ -1281,9 +1299,9 @@
"license": "MIT"
},
"node_modules/cpu-features": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.8.tgz",
"integrity": "sha512-BbHBvtYhUhksqTjr6bhNOjGgMnhwhGTQmOoZGD+K7BCaQDCuZl/Ve1ZxUSMRwVC4D/rkCPQ2MAIeYzrWyK7eEg==",
"version": "0.0.9",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.9.tgz",
"integrity": "sha512-AKjgn2rP2yJyfbepsmLfiYcmtNn/2eUvocUyM/09yB0YDiz39HteK/5/T4Onf0pmdYDMgkBoGvRLvEguzyL7wQ==",
"dev": true,
"hasInstallScript": true,
"optional": true,
@@ -1826,15 +1844,15 @@
}
},
"node_modules/eslint": {
"version": "8.54.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.54.0.tgz",
"integrity": "sha512-NY0DfAkM8BIZDVl6PgSa1ttZbx3xHgJzSNJKYcQglem6CppHyMhRIQkBVSSMaSRnLhig3jsDbEzOjwCVt4AmmA==",
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz",
"integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
"@eslint/eslintrc": "^2.1.3",
"@eslint/js": "8.54.0",
"@eslint/eslintrc": "^2.1.4",
"@eslint/js": "8.56.0",
"@humanwhocodes/config-array": "^0.11.13",
"@humanwhocodes/module-importer": "^1.0.1",
"@nodelib/fs.walk": "^1.2.8",
@@ -2039,9 +2057,9 @@
}
},
"node_modules/eta": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/eta/-/eta-3.1.1.tgz",
"integrity": "sha512-GVKq8BhYjvGiwKAnvPOnTwAHach3uHglvW0nG9gjEmo8ZIe8HR1aCLdQ97jlxXPcCWhB6E3rDWOk2fahFKG5Cw==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/eta/-/eta-3.2.0.tgz",
"integrity": "sha512-Qzc3it7nLn49dbOb9+oHV9rwtt9qN8oShRztqkZ3gXPqQflF0VLin5qhWk0g/2ioibBwT4DU6OIMVft7tg/rVg==",
"engines": {
"node": ">=6.0.0"
},
@@ -2627,9 +2645,9 @@
}
},
"node_modules/globals": {
"version": "13.23.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz",
"integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==",
"version": "13.24.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
"integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
"dev": true,
"dependencies": {
"type-fest": "^0.20.2"
@@ -2915,9 +2933,9 @@
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz",
"integrity": "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==",
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
"integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==",
"dev": true,
"engines": {
"node": ">= 4"
@@ -3693,9 +3711,10 @@
"license": "MIT"
},
"node_modules/mocha": {
"version": "10.2.0",
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/mocha/-/mocha-10.3.0.tgz",
"integrity": "sha512-uF2XJs+7xSLsrmIvn37i/wnc91nw7XjOQB8ccyx5aEgdnohr7n+rEiZP23WkCYHjilR6+EboEnbq/ZQDz4LSbg==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-colors": "4.1.1",
"browser-stdout": "1.3.1",
@@ -3704,13 +3723,12 @@
"diff": "5.0.0",
"escape-string-regexp": "4.0.0",
"find-up": "5.0.0",
"glob": "7.2.0",
"glob": "8.1.0",
"he": "1.2.0",
"js-yaml": "4.1.0",
"log-symbols": "4.1.0",
"minimatch": "5.0.1",
"ms": "2.1.3",
"nanoid": "3.3.3",
"serialize-javascript": "6.0.0",
"strip-json-comments": "3.1.1",
"supports-color": "8.1.1",
@@ -3725,10 +3743,6 @@
},
"engines": {
"node": ">= 14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mochajs"
}
},
"node_modules/mocha/node_modules/find-up": {
@@ -3747,35 +3761,24 @@
}
},
"node_modules/mocha/node_modules/glob": {
"version": "7.2.0",
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
"minimatch": "^5.0.1",
"once": "^1.3.0"
},
"engines": {
"node": "*"
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/mocha/node_modules/glob/node_modules/minimatch": {
"version": "3.1.2",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/mocha/node_modules/locate-path": {
"version": "6.0.0",
"dev": true,
@@ -3896,16 +3899,17 @@
"license": "MIT"
},
"node_modules/moment": {
"version": "2.29.4",
"license": "MIT",
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"engines": {
"node": "*"
}
},
"node_modules/moment-timezone": {
"version": "0.5.43",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.43.tgz",
"integrity": "sha512-72j3aNyuIsDxdF1i7CEgV2FfxM1r6aaqJyLB2vwb33mXYyoyLly+F1zbWqhA3/bVIoJ4szlUoMbUnVdid32NUQ==",
"version": "0.5.45",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.45.tgz",
"integrity": "sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ==",
"dependencies": {
"moment": "^2.29.4"
},
@@ -4035,21 +4039,27 @@
"license": "ISC"
},
"node_modules/nan": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz",
"integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==",
"version": "2.18.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.18.0.tgz",
"integrity": "sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w==",
"dev": true,
"optional": true
},
"node_modules/nanoid": {
"version": "3.3.3",
"dev": true,
"license": "MIT",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.5.tgz",
"integrity": "sha512-/Veqm+QKsyMY3kqi4faWplnY1u+VuKO3dD2binyPIybP31DRO29bPF+1mszgLnrR2KqSLceFLBNw0zmvDzN1QQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": {
"nanoid": "bin/nanoid.cjs"
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
"node": "^18 || >=20"
}
},
"node_modules/natural-compare": {
@@ -4073,9 +4083,9 @@
}
},
"node_modules/nock": {
"version": "13.3.8",
"resolved": "https://registry.npmjs.org/nock/-/nock-13.3.8.tgz",
"integrity": "sha512-96yVFal0c/W1lG7mmfRe7eO+hovrhJYd2obzzOZ90f6fjpeU/XNvd9cYHZKZAQJumDfhXgoTpkpJ9pvMj+hqHw==",
"version": "13.5.1",
"resolved": "https://registry.npmjs.org/nock/-/nock-13.5.1.tgz",
"integrity": "sha512-+s7b73fzj5KnxbKH4Oaqz07tQ8degcMilU4rrmnKvI//b0JMBU4wEXFQ8zqr+3+L4eWSfU3H/UoIVGUV0tue1Q==",
"dev": true,
"dependencies": {
"debug": "^4.1.0",
@@ -4147,9 +4157,9 @@
}
},
"node_modules/nodemailer": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.7.tgz",
"integrity": "sha512-rUtR77ksqex/eZRLmQ21LKVH5nAAsVicAtAYudK7JgwenEDZ0UIQ1adUGqErz7sMkWYxWTTU1aeP2Jga6WQyJw==",
"version": "6.9.9",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.9.tgz",
"integrity": "sha512-dexTll8zqQoVJEZPwQAKzxxtFn0qTnjdQTchoU6Re9BUUGBJiOy3YMn/0ShTW6J5M0dfQ1NeDeRTTl4oIWgQMA==",
"engines": {
"node": ">=6.0.0"
}
@@ -4198,19 +4208,19 @@
}
},
"node_modules/oidc-provider": {
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-8.4.1.tgz",
"integrity": "sha512-8pABnyvEOjRkF3GdMDxW1JCO03z2IjP21xSuP0apdOE3zRnae1ObAj8KRBZVj14N7Yhtm75+XkmEy5mIEV3Bhw==",
"version": "8.4.5",
"resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-8.4.5.tgz",
"integrity": "sha512-2NsPrvIAX1W4ZR41cGbz2Lt2Ci8iXvECh+x+LcKcM115s/h8iB1pwnNlCdIrvAA2iBGM4/TkO75Xg7xb2FCzWA==",
"dependencies": {
"@koa/cors": "^4.0.0",
"@koa/cors": "^5.0.0",
"@koa/router": "^12.0.1",
"debug": "^4.3.4",
"eta": "^3.1.1",
"eta": "^3.2.0",
"got": "^13.0.0",
"jose": "^5.0.1",
"jose": "^5.1.3",
"jsesc": "^3.0.2",
"koa": "^2.14.2",
"nanoid": "^5.0.2",
"nanoid": "^5.0.4",
"object-hash": "^3.0.0",
"oidc-token-hash": "^5.0.3",
"quick-lru": "^7.0.0",
@@ -4221,30 +4231,13 @@
}
},
"node_modules/oidc-provider/node_modules/jose": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.1.1.tgz",
"integrity": "sha512-bfB+lNxowY49LfrBO0ITUn93JbUhxUN8I11K6oI5hJu/G6PO6fEUddVLjqdD0cQ9SXIHWXuWh7eJYwZF7Z0N/g==",
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.2.1.tgz",
"integrity": "sha512-qiaQhtQRw6YrOaOj0v59h3R6hUY9NvxBmmnMfKemkqYmBB0tEc97NbLP7ix44VP5p9/0YHG8Vyhzuo5YBNwviA==",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/oidc-provider/node_modules/nanoid": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.3.tgz",
"integrity": "sha512-I7X2b22cxA4LIHXPSqbBCEQSL+1wv8TuoefejsX4HFWyC6jc5JG7CEaxOltiKjc1M+YCS2YkrZZcj4+dytw9GA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/oidc-token-hash": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz",
@@ -4921,9 +4914,9 @@
}
},
"node_modules/semver": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
"dependencies": {
"lru-cache": "^6.0.0"
},
@@ -5106,9 +5099,9 @@
}
},
"node_modules/ssh2": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.14.0.tgz",
"integrity": "sha512-AqzD1UCqit8tbOKoj6ztDDi1ffJZ2rV2SwlgrVVrHPkV5vWqGJOVp5pmtj18PunkPJAuKQsnInyKV+/Nb2bUnA==",
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.15.0.tgz",
"integrity": "sha512-C0PHgX4h6lBxYx7hcXwu3QWdh4tg6tZZsTfXcdvc5caW/EMxaB4H9dWsl7qk+F7LAW762hp8VbXOX7x4xUYvEw==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
@@ -5119,8 +5112,8 @@
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.8",
"nan": "^2.17.0"
"cpu-features": "~0.0.9",
"nan": "^2.18.0"
}
},
"node_modules/ssh2-streams": {
@@ -5882,9 +5875,9 @@
"license": "ISC"
},
"node_modules/ws": {
"version": "8.14.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz",
"integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==",
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz",
"integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==",
"engines": {
"node": ">=10.0.0"
},
+13 -13
View File
@@ -19,15 +19,15 @@
"@google-cloud/dns": "^3.0.2",
"@google-cloud/storage": "^6.12.0",
"async": "^3.2.5",
"aws-sdk": "^2.1502.0",
"aws-sdk": "^2.1554.0",
"basic-auth": "^2.0.1",
"body-parser": "^1.20.2",
"cloudron-manifestformat": "^5.21.0",
"cloudron-manifestformat": "^5.22.1",
"connect": "^3.7.0",
"connect-lastmile": "^2.2.0",
"connect-timeout": "^1.9.0",
"cookie-parser": "^1.4.6",
"cookie-session": "^2.0.0",
"cookie-session": "^2.1.0",
"cron": "^2.4.4",
"db-migrate": "^0.11.14",
"db-migrate-mysql": "^2.3.2",
@@ -41,18 +41,18 @@
"jsonwebtoken": "^9.0.2",
"ldapjs": "^2.3.3",
"marked": "^7.0.5",
"moment": "^2.29.4",
"moment-timezone": "^0.5.43",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
"multiparty": "^4.2.3",
"mysql": "^2.18.1",
"nodemailer": "^6.9.7",
"nodemailer": "^6.9.9",
"nsyslog-parser": "^0.10.1",
"oidc-provider": "^8.4.1",
"oidc-provider": "^8.4.5",
"ovh": "^2.0.3",
"qrcode": "^1.5.3",
"readdirp": "^3.6.0",
"safetydance": "^2.4.0",
"semver": "^7.5.4",
"semver": "^7.6.0",
"speakeasy": "^2.0.0",
"superagent": "^8.1.2",
"tar-fs": "github:cloudron-io/tar-fs#ignore_stat_error",
@@ -61,20 +61,20 @@
"underscore": "^1.13.6",
"uuid": "^9.0.1",
"validator": "^13.11.0",
"ws": "^8.14.2",
"ws": "^8.16.0",
"xml2js": "^0.6.2"
},
"devDependencies": {
"commander": "^11.1.0",
"easy-table": "^1.2.0",
"eslint": "^8.54.0",
"eslint": "^8.56.0",
"expect.js": "*",
"hock": "^1.4.1",
"js2xmlparser": "^5.0.0",
"mocha": "^10.2.0",
"mocha": "^10.3.0",
"mock-aws-s3": "git+https://github.com/cloudron-io/mock-aws-s3.git",
"nock": "^13.3.8",
"ssh2": "^1.14.0",
"nock": "^13.5.1",
"ssh2": "^1.15.0",
"yesno": "^0.4.0"
},
"scripts": {
+5
View File
@@ -148,6 +148,11 @@ printf "**********************************************************************\n
EOF
chmod +x /etc/update-motd.d/91-cloudron-install-in-progress
# workaround netcup setting immutable bit. can be removed in 8.0
if lsattr -l /etc/resolv.conf 2>/dev/null | grep -q Immutable; then
chattr -i /etc/resolv.conf
fi
# Can only write after we have confirmed script has root access
echo "Running cloudron-setup with args : $@" > "${LOG_FILE}"
+113 -14
View File
@@ -41,7 +41,7 @@ function warn() {
}
function fail() {
echo -e "[${RED}FAIL${DONE}]\t${1}"
echo -e "[${RED}FAIL${DONE}]\t${1}" >&2
}
function enable_remote_access() {
@@ -94,7 +94,7 @@ function check_box() {
}
function owner_login() {
check_host_mysql
check_host_mysql >/dev/null
local -r owner_username=$(mysql -NB -uroot -ppassword -e "SELECT username FROM box.users WHERE role='owner' AND username IS NOT NULL AND active=1 ORDER BY creationTime LIMIT 1" 2>/dev/null)
local -r owner_password=$(pwgen -1s 12)
@@ -116,16 +116,25 @@ function send_diagnostics() {
echo -e $LINE"Ubuntu"$LINE >> $log
lsb_release -a &>> $log
echo -e $LINE"Dashboard Domain"$LINE >> $log
mysql -NB -uroot -ppassword -e "SELECT value FROM box.settings WHERE name='dashboard_domain'" &>> $log 2>/dev/null || true
echo -e $LINE"Cloudron"$LINE >> $log
cloudron_version=$(cat /home/yellowtent/box/VERSION || true)
echo -e "Cloudron version: ${cloudron_version}" >> $log
dashboard_domain=$(mysql -NB -uroot -ppassword -e "SELECT value FROM box.settings WHERE name='dashboard_domain'" 2>/dev/null || true)
echo -e "Dashboard domain: ${dashboard_domain}" >> $log
echo -e $LINE"Docker"$LINE >> $log
if ! timeout --kill-after 10s 15s docker system info &>> $log 2>&1; then
echo -e "Docker (system info) is not responding" >> $log
fi
echo -e $LINE"Docker containers"$LINE >> $log
if ! timeout --kill-after 10s 15s docker ps -a &>> $log 2>&1; then
echo -e "Docker is not responding" >> $log
echo -e "Docker (ps) is not responding" >> $log
fi
echo -e $LINE"Filesystem stats"$LINE >> $log
df -h &>> $log
if ! timeout --kill-after 10s 15s df -h &>> $log 2>&1; then
echo -e "df is not responding" >> $log
fi
echo -e $LINE"Appsdata stats"$LINE >> $log
du -hcsL /home/yellowtent/appsdata/* &>> $log || true
@@ -181,6 +190,23 @@ function check_unbound() {
success "unbound is running"
}
function check_dashboard_cert() {
local -r dashboard_domain=$(mysql -NB -uroot -ppassword -e "SELECT value FROM box.settings WHERE name='dashboard_domain'" 2>/dev/null)
local -r nginx_conf_file="/home/yellowtent/platformdata/nginx/applications/dashboard/my.${dashboard_domain}.conf"
local -r cert_file=$(sed -n -e 's/.*ssl_certificate [[:space:]]\+\(.*\);/\1/p' "${nginx_conf_file}")
local -r cert_expiry_date=$(openssl x509 -enddate -noout -in "${cert_file}" | sed -e 's/notAfter=//')
if ! openssl x509 -checkend 100 -noout -in "${cert_file}" >/dev/null 2>&1; then
fail "Certificate has expired. Certificate expired at ${cert_expiry_date}"
local -r task_id=$(mysql -NB -uroot -ppassword -e "SELECT id FROM box.tasks WHERE type='checkCerts' ORDER BY id DESC LIMIT 1" 2>/dev/null)
echo -e "\tPlease check /home/yellowtent/platformdata/logs/tasks/${task_id}.log for last cert renewal logs"
echo -e "\tCommon issues include expiry of domain's API key OR incoming http port 80 not being open"
exit 1
fi
}
function check_nginx() {
local -r dashboard_domain=$(mysql -NB -uroot -ppassword -e "SELECT value FROM box.settings WHERE name='dashboard_domain'" 2>/dev/null)
@@ -199,6 +225,32 @@ function check_nginx() {
success "nginx is running"
}
# this confirms that https works properly without any proxy (cloudflare) involved
function check_dashboard_site_loopback() {
local -r dashboard_domain=$(mysql -NB -uroot -ppassword -e "SELECT value FROM box.settings WHERE name='dashboard_domain'" 2>/dev/null)
if ! curl --fail -s --resolve "my.${dashboard_domain}:443:127.0.0.1" "https://my.${dashboard_domain}" >/dev/null; then
fail "Could not load dashboard website with loopback check"
exit 1
fi
}
function check_node() {
expected_node_version="$(sed -ne 's/readonly node_version=\(.*\)/\1/p' /home/yellowtent/box/scripts/installer.sh)"
current_node_version="$(node --version | tr -d '\n' | cut -c2-)" # strip trailing newline and 'v' prefix
if [[ "${current_node_version}" != "${expected_node_version}" ]]; then
fail "node version is incorrect. Expecting ${expected_node_version}. Got ${current_node_version}."
echo "You can try the following to fix the problem:"
echo " ln -sf /usr/local/node-${expected_node_version}/bin/node /usr/bin/node"
echo " ln -sf /usr/local/node-${expected_node_version}/bin/npm /usr/bin/npm"
echo " systemctl restart box"
exit 1
fi
success "node version is correct"
}
function check_docker() {
if ! systemctl is-active -q docker; then
info "Docker is down. Trying to restart docker ..."
@@ -213,14 +265,58 @@ function check_docker() {
success "docker is running"
}
function check_hairpin_nat() {
local -r dashboard_domain=$(mysql -NB -uroot -ppassword -e "SELECT value FROM box.settings WHERE name='dashboard_domain'" 2>/dev/null)
if ! curl --fail -s https://my.${dashboard_domain} >/dev/null; then
fail "Could not reach dashboard domain. Is Hairpin NAT functional?"
function check_node() {
expected_node_version="$(sed -ne 's/readonly node_version=\(.*\)/\1/p' /home/yellowtent/box/scripts/installer.sh)"
current_node_version="$(node --version | tr -d '\n' | cut -c2-)" # strip trailing newline and 'v' prefix
if [[ "${current_node_version}" != "${expected_node_version}" ]]; then
fail "node version is incorrect. Expecting ${expected_node_version}. Got ${current_node_version}."
echo "You can try the following to fix the problem:"
echo " ln -sf /usr/local/node-${expected_node_version}/bin/node /usr/bin/node"
echo " ln -sf /usr/local/node-${expected_node_version}/bin/npm /usr/bin/npm"
echo " systemctl restart box"
exit 1
fi
success "Hairpin NAT is good"
success "node version is correct"
}
function check_docker() {
if ! systemctl is-active -q docker; then
info "Docker is down. Trying to restart docker ..."
systemctl restart docker
if ! systemctl is-active -q docker; then
fail "Docker is still down, please investigate the error using 'journalctl -u docker'"
exit 1
fi
fi
success "docker is running"
}
function check_dashboard_site_domain() {
local -r dashboard_domain=$(mysql -NB -uroot -ppassword -e "SELECT value FROM box.settings WHERE name='dashboard_domain'" 2>/dev/null)
local -r domain_provider=$(mysql -NB -uroot -ppassword -e "SELECT provider FROM box.domains WHERE domain='${dashboard_domain}'" 2>/dev/null)
# TODO: check ipv4 and ipv6
if ! output=$(curl --fail -s https://my.${dashboard_domain}); then
fail "Could not load dashboard domain."
if [[ "${domain_provider}" == "cloudflare" ]]; then
echo "Maybe cloudflare proxying is not working. Delete the domain in Cloudflare dashboard and re-add it. This sometimes re-establishes the proxying"
else
echo "Hairpin NAT is not working. Please check if your router supports it"
fi
exit 1
fi
if ! echo $output | grep -q "Cloudron Dashboard"; then
fail "https://my.${dashboard_domain} is not the dashboard domain. Check if DNS is set properly to this server"
host my.${dashboard_domain} 127.0.0.1 # could also result in cloudflare
exit 1
fi
success "Dashboard is reachable via domain name"
}
function check_expired_domain() {
@@ -282,12 +378,15 @@ EOF
function troubleshoot() {
# note: disk space test has already been run globally
check_nginx
check_node
check_docker
check_host_mysql
check_nginx # requires mysql to be checked
check_dashboard_site_loopback # checks website via loopback
check_box
check_unbound
check_hairpin_nat # requires mysql to be checked
check_dashboard_cert
check_dashboard_site_domain # check website via domain name
check_expired_domain
}
+6
View File
@@ -25,6 +25,12 @@ apt-get -o Dpkg::Options::="--force-confdef" update -y
apt-get -o Dpkg::Options::="--force-confdef" upgrade -y
apt-mark unhold grub* >/dev/null
# workaround netcup setting immutable bit. can be removed in 8.0
if lsattr -l /etc/resolv.conf 2>/dev/null | grep -q Immutable; then
echo "==> Fixing up /etc/resolv.conf"
chattr -i /etc/resolv.conf
fi
echo "==> Installing required packages"
debconf-set-selections <<< 'mysql-server mysql-server/root_password password password'
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -40,7 +40,14 @@ usermod ${USER} -a -G docker
if ! grep -q ip6tables /etc/systemd/system/docker.service.d/cloudron.conf; then
log "Adding ip6tables flag to docker" # https://github.com/moby/moby/pull/41622
echo -e "[Service]\nExecStart=\nExecStart=/usr/bin/dockerd -H fd:// --log-driver=journald --exec-opt native.cgroupdriver=cgroupfs --storage-driver=overlay2 --experimental --ip6tables" > /etc/systemd/system/docker.service.d/cloudron.conf
echo -e "[Service]\nExecStart=\nExecStart=/usr/bin/dockerd -H fd:// --log-driver=journald --exec-opt native.cgroupdriver=cgroupfs --storage-driver=overlay2 --experimental --ip6tables --userland-proxy=false" > /etc/systemd/system/docker.service.d/cloudron.conf
systemctl daemon-reload
systemctl restart docker
fi
if ! grep -q userland-proxy /etc/systemd/system/docker.service.d/cloudron.conf; then
log "Adding userland-proxy=false to docker" # https://github.com/moby/moby/pull/41622
echo -e "[Service]\nExecStart=\nExecStart=/usr/bin/dockerd -H fd:// --log-driver=journald --exec-opt native.cgroupdriver=cgroupfs --storage-driver=overlay2 --experimental --ip6tables --userland-proxy=false" > /etc/systemd/system/docker.service.d/cloudron.conf
systemctl daemon-reload
systemctl restart docker
fi
+3 -3
View File
@@ -73,11 +73,11 @@ if ! ipset list cloudron_ldap_allowlist6 >/dev/null 2>&1; then
fi
ipset flush cloudron_ldap_allowlist6
ldap_allowlist_json="/home/yellowtent/platformdata/firewall/ldap_allowlist.txt"
ldap_allowlist="/home/yellowtent/platformdata/firewall/ldap_allowlist.txt"
# delete any existing redirect rule
$iptables -t nat -D PREROUTING -p tcp --dport 636 -j REDIRECT --to-ports 3004 2>/dev/null || true
$ip6tables -t nat -D PREROUTING -p tcp --dport 636 -j REDIRECT --to-ports 3004 >/dev/null || true
if [[ -f "${ldap_allowlist_json}" ]]; then
if [[ -f "${ldap_allowlist}" ]]; then
# without the -n block, any last line without a new line won't be read it!
while read -r line || [[ -n "$line" ]]; do
[[ -z "${line}" ]] && continue # ignore empty lines
@@ -87,7 +87,7 @@ if [[ -f "${ldap_allowlist_json}" ]]; then
else
ipset add -! cloudron_ldap_allowlist "${line}" # the -! ignore duplicates
fi
done < "${ldap_allowlist_json}"
done < "${ldap_allowlist}"
# ldap server we expose 3004 and also redirect from standard ldaps port 636
$iptables -t nat -I PREROUTING -p tcp --dport 636 -j REDIRECT --to-ports 3004
+3
View File
@@ -68,4 +68,7 @@ yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/du.sh
Defaults!/home/yellowtent/box/src/scripts/hdparm.sh env_keep="HOME BOX_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/hdparm.sh
Defaults!/home/yellowtent/box/src/scripts/hdparm.sh env_keep="HOME BOX_ENV"
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/logtail.sh
cloudron-support ALL=(ALL) NOPASSWD: ALL
+17 -21
View File
@@ -19,8 +19,9 @@ const assert = require('assert'),
path = require('path'),
paths = require('./paths.js'),
promiseRetry = require('./promise-retry.js'),
superagent = require('superagent'),
safe = require('safetydance'),
shell = require('./shell.js'),
superagent = require('superagent'),
users = require('./users.js');
const CA_PROD_DIRECTORY_URL = 'https://acme-v02.api.letsencrypt.org/directory',
@@ -70,13 +71,12 @@ function b64(str) {
return urlBase64Encode(buf.toString('base64'));
}
function getModulus(pem) {
async function getModulus(pem) {
assert.strictEqual(typeof pem, 'string');
const stdout = safe.child_process.execSync('openssl rsa -modulus -noout', { input: pem, encoding: 'utf8' });
if (!stdout) return null;
const stdout = await shell.exec('getModulus', 'openssl rsa -modulus -noout', { input: pem });
const match = stdout.match(/Modulus=([0-9a-fA-F]+)$/m);
if (!match) return null;
if (!match) throw new BoxError(BoxError.OPENSSL_ERROR, 'Could not get modulus');
return Buffer.from(match[1], 'hex');
}
@@ -98,7 +98,7 @@ Acme2.prototype.sendSignedRequest = async function (url, payload) {
header.jwk = {
e: b64(Buffer.from([0x01, 0x00, 0x01])), // exponent - 65537
kty: 'RSA',
n: b64(getModulus(this.accountKey))
n: b64(await getModulus(this.accountKey))
};
}
@@ -153,8 +153,7 @@ Acme2.prototype.updateContact = async function (registrationUri) {
};
async function generateAccountKey() {
const acmeAccountKey = safe.child_process.execSync('openssl genrsa 4096', { encoding: 'utf8' });
if (!acmeAccountKey) throw new BoxError(BoxError.OPENSSL_ERROR, `Could not generate acme account key: ${safe.error.message}`);
const acmeAccountKey = await shell.exec('generateAccountKey', 'openssl genrsa 4096', {});
return acmeAccountKey;
}
@@ -237,13 +236,13 @@ Acme2.prototype.waitForOrder = async function (orderUrl) {
});
};
Acme2.prototype.getKeyAuthorization = function (token) {
Acme2.prototype.getKeyAuthorization = async function (token) {
assert(typeof this.accountKey, 'string');
const jwk = {
e: b64(Buffer.from([0x01, 0x00, 0x01])), // Exponent - 65537
kty: 'RSA',
n: b64(getModulus(this.accountKey))
n: b64(await getModulus(this.accountKey))
};
const shasum = crypto.createHash('sha256');
@@ -257,7 +256,7 @@ Acme2.prototype.notifyChallengeReady = async function (challenge) {
debug(`notifyChallengeReady: ${challenge.url} was met`);
const keyAuthorization = this.getKeyAuthorization(challenge.token);
const keyAuthorization = await this.getKeyAuthorization(challenge.token);
const payload = {
resource: 'challenge',
@@ -295,8 +294,7 @@ Acme2.prototype.signCertificate = async function (finalizationUrl, csrPem) {
assert.strictEqual(typeof finalizationUrl, 'string');
assert.strictEqual(typeof csrPem, 'string');
const csrDer = safe.child_process.execSync('openssl req -inform pem -outform der', { input: csrPem });
if (!csrDer) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error);
const csrDer = await shell.exec('signCertificate', 'openssl req -inform pem -outform der', { input: csrPem, encoding: 'buffer' });
const payload = {
csr: b64(csrDer)
@@ -317,8 +315,8 @@ Acme2.prototype.ensureKey = async function () {
}
debug(`ensureKey: generating new key for ${this.cn}`);
const newKey = safe.child_process.execSync('openssl ecparam -genkey -name secp384r1', { encoding: 'utf8' }); // openssl ecparam -list_curves
if (!newKey) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error);
// same as prime256v1. openssl ecparam -list_curves. we used to use secp384r1 but it doesn't seem to be accepted by few mail servers
const newKey = await shell.exec('ensureKey', 'openssl ecparam -genkey -name secp256r1', {});
return newKey;
};
@@ -346,9 +344,7 @@ Acme2.prototype.createCsr = async function (key) {
if (!safe.fs.writeFileSync(opensslConfigFile, conf)) throw new BoxError(BoxError.FS_ERROR, `Failed to write openssl config: ${safe.error.message}`);
// while we pass the CN anyways, subjectAltName takes precedence
const csrPem = safe.child_process.execSync(`openssl req -new -key ${keyFilePath} -outform PEM -subj /CN=${this.cn} -config ${opensslConfigFile}`, { encoding: 'utf8' });
if (!csrPem) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error);
const csrPem = await shell.exec('createCsr', `openssl req -new -key ${keyFilePath} -outform PEM -subj /CN=${this.cn} -config ${opensslConfigFile}`, {});
await safe(fs.promises.rm(tmpdir, { recursive: true, force: true }));
debug(`createCsr: csr file created for ${this.cn}`);
return csrPem; // inspect with openssl req -text -noout -in hostname.csr -inform pem
@@ -374,7 +370,7 @@ Acme2.prototype.prepareHttpChallenge = async function (challenge) {
debug(`prepareHttpChallenge: preparing for challenge ${JSON.stringify(challenge)}`);
const keyAuthorization = this.getKeyAuthorization(challenge.token);
const keyAuthorization = await this.getKeyAuthorization(challenge.token);
const challengeFilePath = path.join(paths.ACME_CHALLENGES_DIR, challenge.token);
debug(`prepareHttpChallenge: writing ${keyAuthorization} to ${challengeFilePath}`);
@@ -413,7 +409,7 @@ Acme2.prototype.prepareDnsChallenge = async function (cn, challenge) {
debug(`prepareDnsChallenge: preparing for challenge: ${JSON.stringify(challenge)}`);
const keyAuthorization = this.getKeyAuthorization(challenge.token);
const keyAuthorization = await this.getKeyAuthorization(challenge.token);
const shasum = crypto.createHash('sha256');
shasum.update(keyAuthorization);
@@ -431,7 +427,7 @@ Acme2.prototype.cleanupDnsChallenge = async function (cn, challenge) {
assert.strictEqual(typeof cn, 'string');
assert.strictEqual(typeof challenge, 'object');
const keyAuthorization = this.getKeyAuthorization(challenge.token);
const keyAuthorization = await this.getKeyAuthorization(challenge.token);
const shasum = crypto.createHash('sha256');
shasum.update(keyAuthorization);
+1 -1
View File
@@ -98,7 +98,7 @@ async function checkAppHealth(app, options) {
if (healthCheckError) {
await apps.appendLogLine(app, `=> Healtheck error: ${healthCheckError}`);
await setHealth(app, apps.HEALTH_UNHEALTHY);
} else if (response.status > 403) { // 2xx and 3xx are ok. even 401 and 403 are ok for now (for WP sites)
} else if (response.status > 499) { // 2xx, 3xx and 4xx are ok. maybe 503 can be excluded?
await apps.appendLogLine(app, `=> Healtheck error got response status ${response.status}`);
await setHealth(app, apps.HEALTH_UNHEALTHY);
} else {
+93 -52
View File
@@ -95,8 +95,8 @@ exports = module.exports = {
downloadFile,
uploadFile,
backupConfig,
restoreConfig,
writeConfig,
loadConfig,
PORT_TYPE_TCP: 'tcp',
PORT_TYPE_UDP: 'udp',
@@ -133,6 +133,7 @@ exports = module.exports = {
HEALTH_DEAD: 'dead',
// exported for testing
_checkForPortBindingConflict: checkForPortBindingConflict,
_validatePortBindings: validatePortBindings,
_validateAccessRestriction: validateAccessRestriction,
_validateUpstreamUri: validateUpstreamUri,
@@ -240,11 +241,11 @@ function validatePortBindings(portBindings, manifest) {
if (!portBindings) return null;
const tcpPorts = manifest.tcpPorts || { };
const udpPorts = manifest.udpPorts || { };
const tcpPorts = manifest.tcpPorts || {};
const udpPorts = manifest.udpPorts || {};
for (const portName in portBindings) {
if (!/^[a-zA-Z0-9_]+$/.test(portName)) return new BoxError(BoxError.BAD_FIELD, `${portName} is not a valid environment variable in portBindings`);
if (!/^[A-Z0-9_]+$/.test(portName)) return new BoxError(BoxError.BAD_FIELD, `${portName} is not a valid environment variable in portBindings`);
const hostPort = portBindings[portName];
if (!Number.isInteger(hostPort)) return new BoxError(BoxError.BAD_FIELD, `${hostPort} is not an integer in ${portName} portBindings`);
@@ -272,8 +273,10 @@ function translatePortBindings(portBindings, manifest) {
for (let portName in portBindings) {
const portType = portName in tcpPorts ? exports.PORT_TYPE_TCP : exports.PORT_TYPE_UDP;
result[portName] = { hostPort: portBindings[portName], type: portType };
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 };
}
return result;
}
@@ -793,6 +796,39 @@ function accessLevel(app, user) {
return canAccess(app, user) ? 'user' : null;
}
async function checkForPortBindingConflict(portBindings, id = '') {
assert.strictEqual(typeof portBindings, 'object');
assert.strictEqual(typeof id, 'string');
let existingPortBindings;
if (id) existingPortBindings = await database.query('SELECT * FROM appPortBindings WHERE appId != ?', [ id ]);
else existingPortBindings = 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');
for (let portName in portBindings) {
const p = portBindings[portName];
const testPorts = p.type === 'tcp' ? tcpPorts : udpPorts;
const found = testPorts.find((e) => {
// 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;
return !((a2 < b1) || (b2 < a1));
});
if (found) throw new BoxError(BoxError.CONFLICT, `Conflicting port ${p.hostPort}`);
}
}
async function add(id, appStoreId, manifest, subdomain, domain, portBindings, data) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof appStoreId, 'string');
@@ -828,6 +864,8 @@ async function add(id, appStoreId, manifest, subdomain, domain, portBindings, da
enableRedis = 'enableRedis' in data ? data.enableRedis : true,
icon = data.icon || null;
await checkForPortBindingConflict(portBindings);
const queries = [];
queries.push({
@@ -847,8 +885,8 @@ 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) VALUES (?, ?, ?, ?)',
args: [ env, portBindings[env].hostPort, portBindings[env].type, id ]
query: 'INSERT INTO appPortBindings (environmentVariable, hostPort, type, appId, count) VALUES (?, ?, ?, ?, ?)',
args: [ env, portBindings[env].hostPort, portBindings[env].type, id, portBindings[env].portCount ]
});
});
@@ -912,15 +950,19 @@ async function updateWithConstraints(id, app, constraints) {
assert(!('tags' in app) || Array.isArray(app.tags));
assert(!('env' in app) || typeof app.env === 'object');
const queries = [ ];
if ('portBindings' in app) {
const portBindings = app.portBindings || { };
await checkForPortBindingConflict(portBindings, 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 ];
queries.push({ query: 'INSERT INTO appPortBindings (hostPort, type, environmentVariable, appId) VALUES(?, ?, ?, ?)', args: values });
const values = [ portBindings[env].hostPort, portBindings[env].type, env, id, portBindings[env].portCount ];
queries.push({ query: 'INSERT INTO appPortBindings (hostPort, type, environmentVariable, appId, count) VALUES(?, ?, ?, ?, ?)', args: values });
});
}
@@ -1285,7 +1327,6 @@ async function install(data, auditSource) {
const subdomain = data.subdomain.toLowerCase(),
domain = data.domain.toLowerCase(),
portBindings = data.portBindings || null,
accessRestriction = data.accessRestriction || null,
memoryLimit = data.memoryLimit || 0,
debugMode = data.debugMode || null,
@@ -1310,8 +1351,9 @@ async function install(data, auditSource) {
error = checkManifestConstraints(manifest);
if (error) throw error;
error = validatePortBindings(portBindings, manifest);
error = validatePortBindings(data.portBindings || null, manifest);
if (error) throw error;
const portBindings = translatePortBindings(data.portBindings || null, manifest);
error = validateAccessRestriction(accessRestriction);
if (error) throw error;
@@ -1393,7 +1435,7 @@ async function install(data, auditSource) {
installationState: exports.ISTATE_PENDING_INSTALL
};
const [addError] = await safe(add(appId, appStoreId, manifest, subdomain, domain, translatePortBindings(portBindings, manifest), app));
const [addError] = await safe(add(appId, appStoreId, manifest, subdomain, domain, portBindings, app));
if (addError && addError.reason === BoxError.ALREADY_EXISTS) throw getDuplicateErrorDetails(addError.message, locations, portBindings);
if (addError) throw addError;
@@ -1818,10 +1860,7 @@ async function setCertificate(app, data, auditSource) {
const domainObject = await domains.get(domain);
if (domainObject === null) throw new BoxError(BoxError.NOT_FOUND, 'Domain not found');
if (cert && key) {
const error = reverseProxy.validateCertificate(subdomain, domain, { cert, key });
if (error) throw error;
}
if (cert && key) await reverseProxy.validateCertificate(subdomain, domain, { cert, key });
const certificate = cert && key ? { cert, key } : null;
const result = await database.query('UPDATE locations SET certificateJson=? WHERE location=? AND domain=?', [ certificate ? JSON.stringify(certificate) : null, subdomain, domain ]);
@@ -1852,9 +1891,8 @@ async function setLocation(app, data, auditSource) {
};
if ('portBindings' in data) {
error = validatePortBindings(data.portBindings, app.manifest);
error = validatePortBindings(data.portBindings || null, app.manifest);
if (error) throw error;
values.portBindings = translatePortBindings(data.portBindings || null, app.manifest);
}
@@ -1996,6 +2034,11 @@ async function updateApp(app, data, auditSource) {
values.mailboxDomain = app.domain;
}
if (!manifest.addons?.recvmail) { // clear if the update removed addon. required for fk constraint
values.enableInbox = false;
values.inboxName = values.inboxDomain = null;
}
const hasSso = !!updateConfig.manifest.addons?.proxyAuth || !!updateConfig.manifest.addons?.ldap || !!manifest.addons?.oidc;
if (!hasSso && app.sso) values.sso = false; // turn off sso flag, if the update removes sso options
@@ -2053,7 +2096,7 @@ async function getLogs(app, options) {
const cp = logs.tail(logPaths, { lines: options.lines, follow: options.follow });
const logStream = new logs.LogStream({ format: options.format || 'json', source: appId });
logStream.close = cp.kill.bind(cp, 'SIGKILL'); // hook for caller. closing stream kills the child process
logStream.on('close', () => cp.terminate()); // the caller has to call destroy() on logStream. destroy() of Transform emits 'close'
cp.stdout.pipe(logStream);
@@ -2134,22 +2177,28 @@ async function restore(app, backupId, auditSource) {
// for empty or null backupId, use existing manifest to mimic a reinstall
const backupInfo = backupId ? await backups.get(backupId) : { manifest: app.manifest };
const manifest = backupInfo.manifest;
if (!backupInfo.manifest) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Could not get restore manifest');
if (!manifest) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Could not get restore manifest');
if (backupInfo.encryptionVersion === 1) throw new BoxError(BoxError.BAD_FIELD, 'This encrypted backup was created with an older Cloudron version and has to be restored using the CLI tool');
// re-validate because this new box version may not accept old configs
error = checkManifestConstraints(backupInfo.manifest);
error = checkManifestConstraints(manifest);
if (error) throw error;
let values = { manifest: backupInfo.manifest };
if (!backupInfo.manifest.addons?.sendmail) { // clear if restore removed addon
const values = { manifest };
if (!manifest.addons?.sendmail) { // clear if restore removed addon
values.mailboxName = values.mailboxDomain = null;
} else if (!app.mailboxName || app.mailboxName.endsWith('.app')) { // allocate since restore added the addon
values.mailboxName = mailboxNameForSubdomain(app.subdomain, backupInfo.manifest);
values.mailboxName = mailboxNameForSubdomain(app.subdomain, manifest);
values.mailboxDomain = app.domain;
}
if (!manifest.addons?.recvmail) { // recvmail is always optional. clear if restore removed addon
values.enableInbox = false;
values.inboxName = values.inboxDomain = null;
}
const restoreConfig = { remotePath: backupInfo.remotePath, backupFormat: backupInfo.format };
const task = {
@@ -2164,7 +2213,7 @@ async function restore(app, backupId, auditSource) {
const taskId = await addTask(appId, exports.ISTATE_PENDING_RESTORE, task, auditSource);
await eventlog.add(eventlog.ACTION_APP_RESTORE, auditSource, { app, backupId: backupInfo.id, remotePath: backupInfo.remotePath, fromManifest: app.manifest, toManifest: backupInfo.manifest, taskId });
await eventlog.add(eventlog.ACTION_APP_RESTORE, auditSource, { app, backupId: backupInfo.id, remotePath: backupInfo.remotePath, fromManifest: app.manifest, toManifest: manifest, taskId });
return { taskId };
}
@@ -2258,21 +2307,18 @@ async function clone(app, data, user, auditSource) {
const subdomain = data.subdomain.toLowerCase(),
domain = data.domain.toLowerCase(),
portBindings = data.portBindings || null,
backupId = data.backupId,
overwriteDns = 'overwriteDns' in data ? data.overwriteDns : false,
skipDnsSetup = 'skipDnsSetup' in data ? data.skipDnsSetup : false,
appId = app.id;
skipDnsSetup = 'skipDnsSetup' in data ? data.skipDnsSetup : false;
assert.strictEqual(typeof backupId, 'string');
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof portBindings, 'object');
const backupInfo = await backups.get(backupId);
if (!backupInfo) throw new BoxError(BoxError.NOT_FOUND, 'Backup not found');
if (!backupInfo.manifest) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Could not get restore config');
if (!backupInfo.manifest) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Could not detect restore manifest');
if (backupInfo.encryptionVersion === 1) throw new BoxError(BoxError.BAD_FIELD, 'This encrypted backup was created with an older Cloudron version and cannot be cloned');
const manifest = backupInfo.manifest, appStoreId = app.appStoreId;
@@ -2291,8 +2337,9 @@ async function clone(app, data, user, auditSource) {
error = checkManifestConstraints(manifest);
if (error) throw error;
error = validatePortBindings(portBindings, manifest);
error = validatePortBindings(data.portBindings || null, manifest);
if (error) throw error;
const portBindings = translatePortBindings(data.portBindings || null, manifest);
// should we copy the original app's mailbox settings instead?
const mailboxName = manifest.addons?.sendmail ? mailboxNameForSubdomain(subdomain, manifest) : null;
@@ -2302,31 +2349,25 @@ async function clone(app, data, user, auditSource) {
const icons = await getIcons(app.id);
const obj = {
const dolly = _.pick(app, 'memoryLimit', 'cpuShares', 'crontab', 'reverseProxyConfig', 'env', 'servicesConfig', 'tags',
'enableMailbox', 'mailboxDisplayName', 'mailboxName', 'mailboxDomain', 'enableInbox', 'inboxName', 'inboxDomain',
'enableTurn', 'enableRedis', 'mounts', 'enableBackup', 'enableAutomaticUpdate', 'accessRestriction', 'operators', 'sso');
if (!manifest.addons?.recvmail) dolly.inboxDomain = null; // needed because we are cloning _current_ app settings with old manifest
const obj = Object.assign(dolly, {
installationState: exports.ISTATE_PENDING_CLONE,
runState: exports.RSTATE_RUNNING,
memoryLimit: app.memoryLimit,
cpuShares: app.cpuShares,
accessRestriction: app.accessRestriction,
sso: !!app.sso,
mailboxName,
mailboxDomain,
enableBackup: app.enableBackup,
reverseProxyConfig: app.reverseProxyConfig,
env: app.env,
secondaryDomains,
redirectDomains: [],
aliasDomains: [],
servicesConfig: app.servicesConfig,
label: app.label ? `${app.label}-clone` : '',
tags: app.tags,
enableAutomaticUpdate: app.enableAutomaticUpdate,
icon: icons.icon,
enableMailbox: app.enableMailbox,
mailboxDisplayName: app.mailboxDisplayName
};
});
const [addError] = await safe(add(newAppId, appStoreId, manifest, subdomain, domain, translatePortBindings(portBindings, manifest), obj));
const [addError] = await safe(add(newAppId, appStoreId, manifest, subdomain, domain, portBindings, obj));
if (addError && addError.reason === BoxError.ALREADY_EXISTS) throw getDuplicateErrorDetails(addError.message, locations, portBindings);
if (addError) throw addError;
@@ -2346,7 +2387,7 @@ async function clone(app, data, user, auditSource) {
newApp.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
newApp.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
await eventlog.add(eventlog.ACTION_APP_CLONE, auditSource, { appId: newAppId, oldAppId: appId, backupId, remotePath: backupInfo.remotePath, oldApp: app, newApp, taskId });
await eventlog.add(eventlog.ACTION_APP_CLONE, auditSource, { appId: newAppId, oldAppId: app.id, backupId, remotePath: backupInfo.remotePath, oldApp: app, newApp, taskId });
return { id: newAppId, taskId };
}
@@ -2835,7 +2876,7 @@ async function uploadFile(app, sourceFilePath, destFilePath) {
// the built-in bash printf understands "%q" but not /usr/bin/printf.
// ' gets replaced with '\'' . the first closes the quote and last one starts a new one
const escapedDestFilePath = safe.child_process.execSync(`printf %q '${destFilePath.replace(/'/g, '\'\\\'\'')}'`, { shell: '/bin/bash', encoding: 'utf8' });
const escapedDestFilePath = await shell.exec('uploadFile', `printf %q '${destFilePath.replace(/'/g, '\'\\\'\'')}'`, { shell: '/bin/bash' });
debug(`uploadFile: ${sourceFilePath} -> ${escapedDestFilePath}`);
const execId = await createExec(app, { cmd: [ 'bash', '-c', `cat - > ${escapedDestFilePath}` ], tty: false });
@@ -2854,10 +2895,10 @@ async function uploadFile(app, sourceFilePath, destFilePath) {
});
}
async function backupConfig(app) {
async function writeConfig(app) {
assert.strictEqual(typeof app, 'object');
if (!safe.fs.writeFileSync(path.join(paths.APPS_DATA_DIR, app.id + '/config.json'), JSON.stringify(app))) {
if (!safe.fs.writeFileSync(path.join(paths.APPS_DATA_DIR, app.id + '/config.json'), JSON.stringify(app, null, 4))) {
throw new BoxError(BoxError.FS_ERROR, 'Error creating config.json: ' + safe.error.message);
}
@@ -2865,7 +2906,7 @@ async function backupConfig(app) {
if (!error && icons.icon) safe.fs.writeFileSync(path.join(paths.APPS_DATA_DIR, app.id + '/icon.png'), icons.icon);
}
async function restoreConfig(app) {
async function loadConfig(app) {
assert.strictEqual(typeof app, 'object');
const appConfig = safe.JSON.parse(safe.fs.readFileSync(path.join(paths.APPS_DATA_DIR, app.id + '/config.json')));
+8 -11
View File
@@ -47,6 +47,7 @@ const apps = require('./apps.js'),
safe = require('safetydance'),
semver = require('semver'),
settings = require('./settings.js'),
shell = require('./shell.js'),
superagent = require('superagent'),
support = require('./support.js');
@@ -297,7 +298,6 @@ async function registerCloudron(data) {
if (response.status === 401) throw new BoxError(BoxError.LICENSE_ERROR, 'Setup token invalid');
if (response.status !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, `Unable to register cloudron: ${response.statusCode} ${error.message}`);
// cloudronId, token
if (!response.body.cloudronId) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Invalid response - no cloudron id');
if (!response.body.cloudronToken) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Invalid response - no token');
@@ -305,6 +305,11 @@ async function registerCloudron(data) {
await settings.set(settings.APPSTORE_API_TOKEN_KEY, response.body.cloudronToken);
debug(`registerCloudron: Cloudron registered with id ${response.body.cloudronId}`);
// app could already have been installed if we deleted the cloudron.io record and user re-registers
for (const app of await apps.list()) {
await purchaseApp({ appId: app.id, appstoreId: app.appStoreId, manifestId: app.manifest.id || 'customapp' });
}
}
async function updateCloudron(data) {
@@ -338,10 +343,6 @@ async function registerCloudronWithSetupToken(options) {
const { domain } = await dashboard.getLocation();
await registerCloudron({ domain, setupToken: options.setupToken, version: constants.VERSION });
for (const app of await apps.list()) {
await purchaseApp({ appId: app.id, appstoreId: app.appStoreId, manifestId: app.manifest.id || 'customapp' });
}
}
async function registerCloudronWithLogin(options) {
@@ -353,10 +354,6 @@ async function registerCloudronWithLogin(options) {
const { domain } = await dashboard.getLocation();
await registerCloudron({ domain, accessToken: result.accessToken, version: constants.VERSION });
for (const app of await apps.list()) {
await purchaseApp({ appId: app.id, appstoreId: app.appStoreId, manifestId: app.manifest.id || 'customapp' });
}
}
async function unregister() {
@@ -395,8 +392,8 @@ async function createTicket(info, auditSource) {
const logPaths = await apps.getLogPaths(info.app);
for (const logPath of logPaths) {
const logs = safe.child_process.execSync(`tail --lines=1000 ${logPath}`);
if (logs) request.attach(path.basename(logPath), logs, path.basename(logPath));
const [error, logs] = await safe(shell.exec('createTicket', `tail --lines=1000 ${logPath}`, {}));
if (!error && logs) request.attach(path.basename(logPath), logs, path.basename(logPath));
}
} else {
request.send(info);
+3 -2
View File
@@ -24,6 +24,7 @@ const apps = require('./apps.js'),
dns = require('./dns.js'),
docker = require('./docker.js'),
ejs = require('ejs'),
execSync = require('child_process').execSync,
fs = require('fs'),
iputils = require('./iputils.js'),
manifestFormat = require('cloudron-manifestformat'),
@@ -313,7 +314,7 @@ async function install(app, args, progressCallback) {
await progressCallback({ percent: 60, message: 'Importing addons in-place' });
await services.setupAddons(app, app.manifest.addons);
await services.clearAddons(app, _.omit(app.manifest.addons, 'localstorage'));
await apps.restoreConfig(app);
await apps.loadConfig(app);
await services.restoreAddons(app, app.manifest.addons);
} else if (app.installationState === apps.ISTATE_PENDING_IMPORT) { // import
await progressCallback({ percent: 65, message: 'Downloading backup and restoring addons' });
@@ -324,7 +325,7 @@ async function install(app, args, progressCallback) {
if (mountObject) await progressCallback({ percent: 70, message: 'Setting up mount for importing' });
backupConfig.rootPath = backups.getRootPath(backupConfig, `/mnt/appimport-${app.id}`);
await backuptask.downloadApp(app, restoreConfig, (progress) => { progressCallback({ percent: 75, message: progress.message }); });
await apps.restoreConfig(app);
await apps.loadConfig(app);
if (mountObject) await mounts.removeMount(mountObject);
await progressCallback({ percent: 75, message: 'Restoring addons' });
await services.restoreAddons(app, app.manifest.addons);
+5 -7
View File
@@ -22,6 +22,7 @@ const assert = require('assert'),
ProgressStream = require('../progress-stream.js'),
promiseRetry = require('../promise-retry.js'),
safe = require('safetydance'),
shell = require('../shell.js'),
storage = require('../storage.js'),
stream = require('stream'),
syncer = require('../syncer.js'),
@@ -109,17 +110,14 @@ 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()) {
const emptyDirs = safe.child_process.execSync(`find ${lp} -type d -empty`, { encoding: 'utf8', maxBuffer: 1024 * 1024 * 30 });
if (emptyDirs === null) throw new BoxError(BoxError.FS_ERROR, `Error finding empty dirs: ${safe.error.message}`);
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)));
const execFiles = safe.child_process.execSync(`find ${lp} -type f -executable`, { encoding: 'utf8', maxBuffer: 1024 * 1024 * 30 });
if (execFiles === null) throw new BoxError(BoxError.FS_ERROR, `Error finding executables: ${safe.error.message}`);
const execFiles = await shell.exec('saveFsMetadata', `find ${lp} -type f -executable`, { maxBuffer: 1024 * 1024 * 80 });
if (execFiles.length) metadata.execFiles = metadata.execFiles.concat(execFiles.trim().split('\n').map((ef) => dataLayout.toRemotePath(ef)));
const symlinks = safe.child_process.execSync(`find ${lp} -type l`, { encoding: 'utf8', maxBuffer: 1024 * 1024 * 30 });
if (symlinks === null) throw new BoxError(BoxError.FS_ERROR, `Error finding symlinks: ${safe.error.message}`);
if (symlinks.length) metadata.symlinks = metadata.symlinks.concat(symlinks.trim().split('\n').map((sl) => {
const symlinkFiles = await shell.exec('safeFsMetadata', `find ${lp} -type l`, { maxBuffer: 1024 * 1024 * 30 });
if (symlinkFiles.length) metadata.symlinks = metadata.symlinks.concat(symlinkFiles.trim().split('\n').map((sl) => {
const target = safe.fs.readlinkSync(sl);
return { path: dataLayout.toRemotePath(sl), target };
}));
+1 -1
View File
@@ -244,7 +244,7 @@ async function startBackupTask(auditSource) {
const backupConfig = await getConfig();
const memoryLimit = backupConfig.limits?.memoryLimit ? Math.max(backupConfig.limits.memoryLimit/1024/1024, 800) : 800;
const memoryLimit = backupConfig.limits?.memoryLimit ? Math.max(backupConfig.limits.memoryLimit/1024/1024, 1024) : 1024;
const taskId = await tasks.add(tasks.TASK_BACKUP, [ { /* options */ } ]);
+2 -3
View File
@@ -60,8 +60,7 @@ async function checkPreconditions(backupConfig, dataLayout) {
let used = 0;
for (const localPath of dataLayout.localPaths()) {
debug(`checkPreconditions: getting disk usage of ${localPath}`);
const result = safe.child_process.execSync(`du -Dsb --exclude='*.lock' --exclude='dovecot.list.index.log.*' "${localPath}"`, { encoding: 'utf8' });
if (!result) throw new BoxError(BoxError.FS_ERROR, `du error: ${safe.error.message}`);
const result = await shell.execArgs('checkPreconditions', 'du', [ '-Dsb', '--exclude=*.lock', '--exclude=dovecot.list.index.log.*', localPath], {});
used += parseInt(result, 10);
}
@@ -328,7 +327,7 @@ async function snapshotApp(app, progressCallback) {
const startTime = new Date();
progressCallback({ message: `Snapshotting app ${app.fqdn}` });
await apps.backupConfig(app);
await apps.writeConfig(app);
await services.backupAddons(app, app.manifest.addons);
debug(`snapshotApp: ${app.fqdn} took ${(new Date() - startTime)/1000} seconds`);
+1 -1
View File
@@ -63,7 +63,7 @@ BoxError.NOT_SIGNED = 'Not Signed';
BoxError.NOT_SUPPORTED = 'Not Supported';
BoxError.OPENSSL_ERROR = 'OpenSSL Error';
BoxError.PLAN_LIMIT = 'Plan Limit';
BoxError.SPAWN_ERROR = 'Spawn Error';
BoxError.SHELL_ERROR = 'Shell Error'; // exec or spawn cmd failed
BoxError.TASK_ERROR = 'Task Error';
BoxError.TIMEOUT = 'Timeout';
BoxError.TRY_AGAIN = 'Try Again';
+33 -12
View File
@@ -15,6 +15,7 @@ exports = module.exports = {
handleTimeZoneChanged,
handleAutoupdatePatternChanged,
handleDynamicDnsChanged,
handleExternalLdapChanged,
DEFAULT_AUTOUPDATE_PATTERN,
};
@@ -29,6 +30,7 @@ const appHealthMonitor = require('./apphealthmonitor.js'),
CronJob = require('cron').CronJob,
debug = require('debug')('box:cron'),
dyndns = require('./dyndns.js'),
externalLdap = require('./externalldap.js'),
eventlog = require('./eventlog.js'),
janitor = require('./janitor.js'),
mail = require('./mail.js'),
@@ -57,7 +59,8 @@ const gJobs = {
dynamicDns: null,
schedulerSync: null,
appHealthMonitor: null,
diskUsage: null
diskUsage: null,
externalLdapSyncer: null
};
// cron format
@@ -173,6 +176,7 @@ async function startJobs() {
await handleBackupPolicyChanged(await backups.getPolicy());
await handleAutoupdatePatternChanged(await updater.getAutoupdatePattern());
await handleDynamicDnsChanged(await network.getDynamicDns());
await handleExternalLdapChanged(await externalLdap.getConfig());
}
async function handleBackupPolicyChanged(value) {
@@ -183,6 +187,7 @@ async function handleBackupPolicyChanged(value) {
debug(`backupPolicyChanged: schedule ${value.schedule} (${tz})`);
if (gJobs.backup) gJobs.backup.stop();
gJobs.backup = null;
gJobs.backup = new CronJob({
cronTime: value.schedule,
@@ -208,6 +213,7 @@ async function handleAutoupdatePatternChanged(pattern) {
debug(`autoupdatePatternChanged: pattern - ${pattern} (${tz})`);
if (gJobs.autoUpdater) gJobs.autoUpdater.stop();
gJobs.autoUpdater = null;
if (pattern === constants.AUTOUPDATE_PATTERN_NEVER) return;
@@ -244,17 +250,32 @@ function handleDynamicDnsChanged(enabled) {
debug('Dynamic DNS setting changed to %s', enabled);
if (enabled) {
gJobs.dynamicDns = new CronJob({
// until we can be smarter about actual IP changes, lets ensure it every 10minutes
cronTime: '00 */10 * * * *',
onTick: async () => { await safe(dyndns.refreshDns(AuditSource.CRON), { debug }); },
start: true
});
} else {
if (gJobs.dynamicDns) gJobs.dynamicDns.stop();
gJobs.dynamicDns = null;
}
if (gJobs.dynamicDns) gJobs.dynamicDns.stop();
gJobs.dynamicDns = null;
if (!enabled) return;
gJobs.dynamicDns = new CronJob({
// until we can be smarter about actual IP changes, lets ensure it every 10minutes
cronTime: '00 */10 * * * *',
onTick: async () => { await safe(dyndns.refreshDns(AuditSource.CRON), { debug }); },
start: true
});
}
async function handleExternalLdapChanged(config) {
assert.strictEqual(typeof config, 'object');
if (gJobs.externalLdapSyncer) gJobs.externalLdapSyncer.stop();
gJobs.externalLdapSyncer = null;
if (config.provider === 'noop') return;
gJobs.externalLdapSyncer = new CronJob({
cronTime: '00 00 */4 * * *', // every 4 hours
onTick: async () => await safe(externalLdap.startSyncer(AuditSource.CRON), { debug }),
start: true
});
}
async function stopJobs() {
+8 -3
View File
@@ -21,8 +21,9 @@ const apps = require('./apps.js'),
branding = require('./branding.js'),
constants = require('./constants.js'),
debug = require('debug')('box:dashboard'),
eventlog = require('./eventlog.js'),
dns = require('./dns.js'),
externalLdap = require('./externalldap.js'),
eventlog = require('./eventlog.js'),
Location = require('./location.js'),
mailServer = require('./mailserver.js'),
platform = require('./platform.js'),
@@ -56,6 +57,8 @@ async function clearLocation() {
async function getConfig() {
const ubuntuVersion = await system.getUbuntuVersion();
const profileConfig = await users.getProfileConfig();
const externalLdapConfig = await externalLdap.getConfig();
const { fqdn:adminFqdn, domain:adminDomain } = await getLocation();
// be picky about what we send out here since this is sent for 'normal' users as well
@@ -74,6 +77,8 @@ async function getConfig() {
features: appstore.getFeatures(),
profileLocked: profileConfig.lockUserProfiles,
mandatory2FA: profileConfig.mandatory2FA,
external2FA: externalLdap.supports2FA(externalLdapConfig),
ldapGroupsSynced: externalLdapConfig.syncGroups
};
}
@@ -83,7 +88,7 @@ async function startPrepareLocation(domain, auditSource) {
debug(`prepareLocation: ${domain}`);
if (constants.DEMO) throw new BoxError(BoxError.CONFLICT, 'Not allowed in demo mode');
if (constants.DEMO) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');
const fqdn = dns.fqdn(constants.DASHBOARD_SUBDOMAIN, domain);
const result = await apps.list();
@@ -118,7 +123,7 @@ async function setupLocation(subdomain, domain, auditSource) {
debug(`setupLocation: ${domain}`);
if (constants.DEMO) throw new BoxError(BoxError.CONFLICT, 'Not allowed in demo mode');
if (constants.DEMO) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');
await reverseProxy.writeDashboardConfig(domain);
await setLocation(subdomain, domain);
+4 -5
View File
@@ -78,7 +78,7 @@ async function clear() {
await fs.promises.writeFile('/tmp/extra.cnf', `[client]\nhost=${gDatabase.hostname}\nuser=${gDatabase.username}\npassword=${gDatabase.password}\ndatabase=${gDatabase.name}`, 'utf8');
const cmd = 'mysql --defaults-extra-file=/tmp/extra.cnf -Nse "SHOW TABLES" | grep -v "^migrations$" | while read table; do mysql --defaults-extra-file=/tmp/extra.cnf -e "SET FOREIGN_KEY_CHECKS = 0; TRUNCATE TABLE $table"; done';
await shell.promises.exec('clear_database', cmd);
await shell.exec('clear_database', cmd, { shell: '/bin/bash' });
}
async function query() {
@@ -136,7 +136,7 @@ async function importFromFile(file) {
const cmd = `/usr/bin/mysql -h "${gDatabase.hostname}" -u ${gDatabase.username} -p${gDatabase.password} ${gDatabase.name} < ${file}`;
await query('CREATE DATABASE IF NOT EXISTS box');
const [error] = await safe(shell.promises.exec('importFromFile', cmd));
const [error] = await safe(shell.exec('importFromFile', cmd, { shell: '/bin/bash' }));
if (error) throw new BoxError(BoxError.DATABASE_ERROR, error);
}
@@ -144,13 +144,12 @@ async function exportToFile(file) {
assert.strictEqual(typeof file, 'string');
// latest mysqldump enables column stats by default which is not present in 5.7 util
const mysqlDumpHelp = safe.child_process.execSync('/usr/bin/mysqldump --help', { encoding: 'utf8' });
if (!mysqlDumpHelp) throw new BoxError(BoxError.DATABASE_ERROR, safe.error);
const mysqlDumpHelp = await shell.exec('exportToFile', '/usr/bin/mysqldump --help', {});
const hasColStats = mysqlDumpHelp.includes('column-statistics');
const colStats = hasColStats ? '--column-statistics=0' : '';
const cmd = `/usr/bin/mysqldump -h "${gDatabase.hostname}" -u root -p${gDatabase.password} ${colStats} --single-transaction --routines --triggers ${gDatabase.name} > "${file}"`;
const [error] = await safe(shell.promises.exec('exportToFile', cmd));
const [error] = await safe(shell.exec('exportToFile', cmd, { shell: '/bin/bash' }));
if (error) throw new BoxError(BoxError.DATABASE_ERROR, error);
}
+13 -5
View File
@@ -8,7 +8,9 @@ exports = module.exports = {
const assert = require('assert'),
BoxError = require('./boxerror.js'),
safe = require('safetydance');
debug = require('debug')('box:df'),
safe = require('safetydance'),
shell = require('./shell.js');
// binary units (non SI) 1024 based
function prettyBytes(bytes) {
@@ -35,8 +37,11 @@ function parseLine(line) {
}
async function disks() {
const output = safe.child_process.execSync('df -B1 --output=source,fstype,size,used,avail,pcent,target', { encoding: 'utf8' });
if (!output) throw new BoxError(BoxError.FS_ERROR, `Error running df: ${safe.error.message}`);
const [error, output] = await safe(shell.exec('disks', 'df -B1 --output=source,fstype,size,used,avail,pcent,target', { timeout: 5000 }));
if (error) {
debug(`disks: df command failed. error: ${error}\n stdout: ${error.stdout}\n stderr: ${error.stderr}`);
throw new BoxError(BoxError.FS_ERROR, `Error running df: ${error.message}`);
}
const lines = output.trim().split('\n').slice(1); // discard header
const result = [];
@@ -49,8 +54,11 @@ async function disks() {
async function file(filename) {
assert.strictEqual(typeof filename, 'string');
const output = safe.child_process.execSync(`df -B1 --output=source,fstype,size,used,avail,pcent,target ${filename}`, { encoding: 'utf8' });
if (!output) throw new BoxError(BoxError.FS_ERROR, `Error running df: ${safe.error.message}`);
const [error, output] = await safe(shell.exec('file', `df -B1 --output=source,fstype,size,used,avail,pcent,target ${filename}`, { timeout: 5000 }));
if (error) {
debug(`file: df command failed. error: ${error}\n stdout: ${error.stdout}\n stderr: ${error.stderr}`);
throw new BoxError(BoxError.FS_ERROR, `Error running df: ${error.message}`);
}
const lines = output.trim().split('\n').slice(1); // discard header
return parseLine(lines[0]);
+31 -26
View File
@@ -39,7 +39,7 @@ async function getConfig() {
if (value === null) return {
enabled: false,
secret: '',
allowlist: '' // empty means allow all
allowlist: ''
};
return JSON.parse(value);
@@ -86,21 +86,25 @@ async function applyConfig(config) {
if (!gServer) await start();
}
async function setConfig(directoryServerConfig) {
async function setConfig(directoryServerConfig, auditSource) {
assert.strictEqual(typeof directoryServerConfig, 'object');
assert(auditSource && typeof auditSource === 'object');
if (constants.DEMO) throw new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode');
if (constants.DEMO) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');
const oldConfig = await getConfig();
const config = {
enabled: directoryServerConfig.enabled,
secret: directoryServerConfig.secret,
// if list is empty, we allow all IPs
allowlist: directoryServerConfig.allowlist || ''
allowlist: directoryServerConfig.allowlist
};
await validateConfig(config);
await settings.setJson(settings.DIRECTORY_SERVER_KEY, config);
await applyConfig(config);
await eventlog.add(eventlog.ACTION_DIRECTORY_SERVER_CONFIGURE, auditSource, { fromEnabled: oldConfig.enabled, toEnabled: config.enabled });
}
// helper function to deal with pagination
@@ -199,10 +203,10 @@ async function userSearch(req, res, next) {
debug('user search: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);
const [error, allUsers] = await safe(users.list());
if (error) return next(new ldap.OperationsError(error.toString()));
if (error) return next(new ldap.OperationsError(error.message));
const [groupsError, allGroups] = await safe(groups.listWithMembers());
if (groupsError) return next(new ldap.OperationsError(error.toString()));
if (groupsError) return next(new ldap.OperationsError(groupsError.message));
let results = [];
@@ -214,10 +218,9 @@ async function userSearch(req, res, next) {
const dn = ldap.parseDN(`cn=${user.id},ou=users,dc=cloudron`);
const displayName = user.displayName || user.username || ''; // displayName can be empty and username can be null
const nameParts = displayName.split(' ');
const firstName = nameParts[0];
const lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : ''; // choose last part, if it exists
const { firstName, lastName, middleName } = users.parseDisplayName(displayName);
// https://datatracker.ietf.org/doc/html/rfc2798
const obj = {
dn: dn.toString(),
attributes: {
@@ -230,6 +233,8 @@ async function userSearch(req, res, next) {
mailAlternateAddress: user.fallbackEmail,
displayname: displayName,
givenName: firstName,
sn: lastName,
middleName: middleName,
username: user.username,
samaccountname: user.username, // to support ActiveDirectory clients
memberof: allGroups.filter(function (g) { return g.userIds.indexOf(user.id) !== -1; }).map(function (g) { return `cn=${g.name},ou=groups,dc=cloudron`; })
@@ -238,13 +243,9 @@ async function userSearch(req, res, next) {
if (user.twoFactorAuthenticationEnabled) obj.attributes.twoFactorAuthenticationEnabled = true;
// http://www.zytrax.com/books/ldap/ape/core-schema.html#sn has 'name' as SUP which is a DirectoryString
// which is required to have atleast one character if present
if (lastName.length !== 0) obj.attributes.sn = lastName;
// ensure all filter values are also lowercase
const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString()));
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message));
if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && lowerCaseFilter.matches(obj.attributes)) {
results.push(obj);
@@ -258,12 +259,12 @@ async function groupSearch(req, res, next) {
debug('group search: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);
const [error, allUsers] = await safe(users.list());
if (error) return next(new ldap.OperationsError(error.toString()));
if (error) return next(new ldap.OperationsError(error.message));
const results = [];
let [errorGroups, allGroups] = await safe(groups.listWithMembers());
if (errorGroups) return next(new ldap.OperationsError(errorGroups.toString()));
if (errorGroups) return next(new ldap.OperationsError(errorGroups.message));
for (const group of allGroups) {
const dn = ldap.parseDN(`cn=${group.name},ou=groups,dc=cloudron`);
@@ -283,7 +284,7 @@ async function groupSearch(req, res, next) {
// ensure all filter values are also lowercase
const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString()));
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message));
if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && lowerCaseFilter.matches(obj.attributes)) {
results.push(obj);
@@ -298,10 +299,14 @@ async function userAuth(req, res, next) {
// extract the common name which might have different attribute names
const cnAttributeName = Object.keys(req.dn.rdns[0].attrs)[0];
const commonName = req.dn.rdns[0].attrs[cnAttributeName].value;
if (!commonName) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (!commonName) return next(new ldap.NoSuchObjectError('Missing CN'));
// totptoken is passed as the "attribute" using the '+' separator in the first RDNS of the request DN
// when totptoken attribute is present, it signals that we must enforce totp check
// totp check is currently requested by the client. this is the only way to auth against external cloudron dashboard, external cloudron app and external apps
const TOTPTOKEN_ATTRIBUTE_NAME = 'totptoken'; // This has to be in-sync with externalldap.js
const totpToken = req.dn.rdns[0].attrs[TOTPTOKEN_ATTRIBUTE_NAME] ? req.dn.rdns[0].attrs[TOTPTOKEN_ATTRIBUTE_NAME].value : null;
const totpToken = TOTPTOKEN_ATTRIBUTE_NAME in req.dn.rdns[0].attrs ? req.dn.rdns[0].attrs[TOTPTOKEN_ATTRIBUTE_NAME].value : null;
const skipTotpCheck = !(TOTPTOKEN_ATTRIBUTE_NAME in req.dn.rdns[0].attrs);
let verifyFunc;
if (cnAttributeName === 'mail') {
@@ -314,9 +319,9 @@ async function userAuth(req, res, next) {
verifyFunc = users.verifyWithUsername;
}
const [error, user] = await safe(verifyFunc(commonName, req.credentials || '', '', { relaxedTotpCheck: true, totpToken }));
if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (error && error.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(req.dn.toString()));
const [error, user] = await safe(verifyFunc(commonName, req.credentials || '', '', { totpToken, skipTotpCheck }));
if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(error.message));
if (error && error.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(error.message));
if (error) return next(new ldap.OperationsError(error.message));
req.user = user;
@@ -353,8 +358,8 @@ async function start() {
const config = await getConfig();
if (!req.dn.equals(constants.USER_DIRECTORY_LDAP_DN)) return next(new ldap.InvalidCredentialsError(req.dn.toString()));
if (req.credentials !== config.secret) return next(new ldap.InvalidCredentialsError(req.dn.toString()));
if (!req.dn.equals(constants.USER_DIRECTORY_LDAP_DN)) return next(new ldap.InvalidCredentialsError('Invalid DN'));
if (req.credentials !== config.secret) return next(new ldap.InvalidCredentialsError('Invalid Secret'));
req.user = { user: 'directoryServerAdmin' };
@@ -391,7 +396,7 @@ async function stop() {
debug('stopping server');
gServer.close(); // has no callback
await util.promisify(gServer.close.bind(gServer))();
gServer = null;
}
+13 -6
View File
@@ -77,9 +77,16 @@ async function getZoneByName(domainConfig, zoneName) {
const [error, response] = await safe(createRequest('GET', `${CLOUDFLARE_ENDPOINT}/zones?name=${zoneName}&status=active`, domainConfig));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
if (response.statusCode !== 200 || response.body.success !== true) throw translateRequestError(response);
if (!response.body.result.length) throw new BoxError(BoxError.NOT_FOUND, util.format('%s %j', response.statusCode, response.body));
if (!response.body.result || !response.body.result.length) throw new BoxError(BoxError.NOT_FOUND, `${response.statusCode} ${response.text}`);
return response.body.result[0];
// check 'id' and 'name_servers' exist in the response
const zone = response.body.result[0];
const zoneId = safe.query(zone, 'id');
if (typeof zoneId !== 'string') throw new BoxError(BoxError.EXTERNAL_ERROR, `No zone id in response: ${response.statusCode} ${response.text}`);
const name_servers = safe.query(zone, 'name_servers');
if (!Array.isArray(name_servers)) throw new BoxError(BoxError.EXTERNAL_ERROR, `name_servers is not an array: ${response.statusCode} ${response.text}`);
return zone;
}
// gets records filtered by zone, type and fqdn
@@ -110,8 +117,8 @@ async function upsert(domainObject, location, type, values) {
debug('upsert: %s for zone %s of type %s with values %j', fqdn, zoneName, type, values);
const result = await getZoneByName(domainConfig, zoneName);
const zoneId = result.id;
const zone = await getZoneByName(domainConfig, zoneName);
const zoneId = zone.id;
const records = await getDnsRecords(domainConfig, zoneId, fqdn, type);
@@ -223,8 +230,8 @@ async function wait(domainObject, subdomain, type, value, options) {
debug('wait: %s for zone %s of type %s', fqdn, zoneName, type);
const result = await getZoneByName(domainConfig, zoneName);
const zoneId = result.id;
const zone = await getZoneByName(domainConfig, zoneName);
const zoneId = zone.id;
const dnsRecords = await getDnsRecords(domainConfig, zoneId, fqdn, type);
if (dnsRecords.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'Domain not found');
+2
View File
@@ -92,6 +92,8 @@ async function upsert(domainObject, location, type, values) {
if (type === 'MX') {
priority = value.split(' ')[0];
value = value.split(' ')[1];
} else if (type === 'TXT') {
value = value.replace(/^"(.*)"$/, '$1'); // strip any double quotes
}
const data = {
+8 -4
View File
@@ -19,6 +19,7 @@ const assert = require('assert'),
network = require('../network.js'),
safe = require('safetydance'),
superagent = require('superagent'),
timers = require('timers/promises'),
util = require('util'),
waitForDns = require('./waitfordns.js'),
xml2js = require('xml2js');
@@ -39,6 +40,9 @@ async function getQuery(domainConfig) {
const ip = await network.getIPv4(); // only supports ipv4
// https://www.namecheap.com/support/knowledgebase/article.aspx/9739/63/api-faq/#z . 50 / minute
await timers.setTimeout(5000); // limits to 12req/min for this process. we can have 3 apptasks in parallel
return {
ApiUser: domainConfig.username,
ApiKey: domainConfig.token,
@@ -53,8 +57,8 @@ async function getZone(domainConfig, zoneName) {
const query = await getQuery(domainConfig);
query.Command = 'namecheap.domains.dns.getHosts';
query.SLD = zoneName.split('.')[0];
query.TLD = zoneName.split('.')[1];
query.SLD = zoneName.split('.', 1)[0];
query.TLD = zoneName.slice(query.SLD.length + 1);
const [error, response] = await safe(superagent.get(ENDPOINT).query(query).ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
@@ -85,8 +89,8 @@ async function setZone(domainConfig, zoneName, hosts) {
const query = await getQuery(domainConfig);
query.Command = 'namecheap.domains.dns.setHosts';
query.SLD = zoneName.split('.')[0];
query.TLD = zoneName.split('.')[1];
query.SLD = zoneName.split('.', 1)[0];
query.TLD = zoneName.slice(query.SLD.length + 1);
// Map to query params https://www.namecheap.com/support/api/methods/domains-dns/set-hosts.aspx
hosts.forEach(function (host, i) {
+17 -13
View File
@@ -41,6 +41,7 @@ const apps = require('./apps.js'),
dashboard = require('./dashboard.js'),
debug = require('debug')('box:docker'),
Docker = require('dockerode'),
fs = require('fs'),
paths = require('./paths.js'),
promiseRetry = require('./promise-retry.js'),
services = require('./services.js'),
@@ -237,16 +238,18 @@ async function getMounts(app) {
// This only returns ipv4 addresses
// We dont bind to ipv6 interfaces, public prefix changes and container restarts wont work
function getAddressesForPort53() {
const deviceLinks = safe.fs.readdirSync('/sys/class/net'); // https://man7.org/linux/man-pages/man5/sysfs.5.html
if (!deviceLinks) return [];
async function getAddressesForPort53() {
const [error, deviceLinks] = await safe(fs.promises.readdir('/sys/class/net')); // https://man7.org/linux/man-pages/man5/sysfs.5.html
if (error) return [];
const devices = deviceLinks.map(d => { return { name: d, link: safe.fs.readlinkSync(`/sys/class/net/${d}`) }; });
const physicalDevices = devices.filter(d => d.link && !d.link.includes('virtual'));
const addresses = [];
for (const phy of physicalDevices) {
const inet = safe.JSON.parse(safe.child_process.execSync(`ip -f inet -j addr show dev ${phy.name} scope global`, { encoding: 'utf8' }));
const [error, output] = await safe(shell.exec('getAddressesForPort53', `ip -f inet -j addr show dev ${phy.name} scope global`, {}));
if (error) continue;
const inet = safe.JSON.parse(output) || [];
for (const r of inet) {
const address = safe.query(r, 'addr_info[0].local');
if (address) addresses.push(address);
@@ -288,15 +291,18 @@ async function createSubcontainer(app, name, cmd, options) {
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
portEnv.push(`${portName}=${hostPort}`);
if (portCount > 1) portEnv.push(`${portName}_COUNT=${portCount}`);
// docker portBindings requires ports to be exposed
exposedPorts[`${containerPort}/${portType}`] = {};
portEnv.push(`${portName}=${hostPort}`);
const hostIps = hostPort === 53 ? getAddressesForPort53() : [ '0.0.0.0', '::0' ]; // port 53 is special because it is possibly taken by systemd-resolved
dockerPortBindings[`${containerPort}/${portType}`] = hostIps.map(hip => { return { HostIp: hip, HostPort: hostPort + '' }; });
for (let i = 0; i < portCount; ++i) {
exposedPorts[`${containerPort+i}/${portType}`] = {};
dockerPortBindings[`${containerPort+i}/${portType}`] = hostIps.map(hip => { return { HostIp: hip, HostPort: (hostPort + i) + '' }; });
}
}
const appEnv = [];
@@ -642,12 +648,10 @@ async function update(name, memory, memorySwap) {
assert.strictEqual(typeof memory, 'number');
assert.strictEqual(typeof memorySwap, 'number');
const args = `update --memory ${memory} --memory-swap ${memorySwap} ${name}`.split(' ');
// scale back db containers, if possible. this is retried because updating memory constraints can fail
// with failed to write to memory.memsw.limit_in_bytes: write /sys/fs/cgroup/memory/docker/xx/memory.memsw.limit_in_bytes: device or resource busy
for (let times = 0; times < 10; times++) {
const [error] = await safe(shell.promises.spawn(`update(${name})`, '/usr/bin/docker', args, { }));
const [error] = await safe(shell.exec(`update(${name})`, `docker update --memory ${memory} --memory-swap ${memorySwap} ${name}`, {}));
if (!error) return;
await timers.setTimeout(60 * 1000);
}
+2 -1
View File
@@ -181,7 +181,8 @@ async function start() {
}
async function stop() {
if (gHttpServer) gHttpServer.close();
if (!gHttpServer) return;
await util.promisify(gHttpServer.close.bind(gHttpServer))();
gHttpServer = null;
}
+4 -7
View File
@@ -142,8 +142,7 @@ async function add(domain, data, auditSource) {
}
if (fallbackCertificate) {
let error = reverseProxy.validateCertificate('test', domain, fallbackCertificate);
if (error) throw error;
await reverseProxy.validateCertificate('test', domain, fallbackCertificate);
} else {
fallbackCertificate = await reverseProxy.generateFallbackCertificate(domain);
}
@@ -205,7 +204,7 @@ async function setConfig(domain, data, auditSource) {
let { zoneName, provider, config, fallbackCertificate, tlsConfig } = data;
const { domain:dashboardDomain } = await dashboard.getLocation();
if (constants.DEMO && (domain === dashboardDomain)) throw new BoxError(BoxError.CONFLICT, 'Not allowed in demo mode');
if (constants.DEMO && (domain === dashboardDomain)) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');
const domainObject = await get(domain);
if (zoneName) {
@@ -214,10 +213,7 @@ async function setConfig(domain, data, auditSource) {
zoneName = domainObject.zoneName;
}
if (fallbackCertificate) {
let error = reverseProxy.validateCertificate('test', domain, fallbackCertificate);
if (error) throw error;
}
if (fallbackCertificate) await reverseProxy.validateCertificate('test', domain, fallbackCertificate);
const tlsConfigError = validateTlsConfig(tlsConfig, provider);
if (tlsConfigError) throw tlsConfigError;
@@ -287,6 +283,7 @@ async function del(domain, auditSource) {
const [error, results] = await safe(database.transaction(queries));
if (error && error.code === 'ER_ROW_IS_REFERENCED_2') {
if (error.message.includes('mailboxes_aliasDomain_constraint')) throw new BoxError(BoxError.CONFLICT, 'Domain is in use in a mailbox, list or an alias');
if (error.message.includes('mailboxes_domain_constraint')) throw new BoxError(BoxError.CONFLICT, 'Domain is in use in a mailbox, list or an alias');
if (error.message.includes('apps_mailDomain_constraint')) throw new BoxError(BoxError.CONFLICT, 'Domain is in use in an app\'s mailbox section');
if (error.message.includes('locations')) throw new BoxError(BoxError.CONFLICT, 'Domain is in use in an app\'s location');
+4
View File
@@ -41,10 +41,14 @@ exports = module.exports = {
ACTION_DASHBOARD_DOMAIN_UPDATE: 'dashboard.domain.update',
ACTION_DIRECTORY_SERVER_CONFIGURE: 'directoryserver.configure',
ACTION_DOMAIN_ADD: 'domain.add',
ACTION_DOMAIN_UPDATE: 'domain.update',
ACTION_DOMAIN_REMOVE: 'domain.remove',
ACTION_EXTERNAL_LDAP_CONFIGURE: 'externalldap.configure',
ACTION_INSTALL_FINISH: 'cloudron.install.finish',
ACTION_MAIL_LOCATION: 'mail.location',
+55 -36
View File
@@ -7,6 +7,8 @@ exports = module.exports = {
verifyPassword,
maybeCreateUser,
supports2FA,
startSyncer,
removePrivateFields,
@@ -18,10 +20,11 @@ const assert = require('assert'),
AuditSource = require('./auditsource.js'),
BoxError = require('./boxerror.js'),
constants = require('./constants.js'),
cron = require('./cron.js'),
debug = require('debug')('box:externalldap'),
eventlog = require('./eventlog.js'),
groups = require('./groups.js'),
ldap = require('ldapjs'),
once = require('./once.js'),
safe = require('safetydance'),
settings = require('./settings.js'),
tasks = require('./tasks.js'),
@@ -68,10 +71,11 @@ async function getConfig() {
return config;
}
async function setConfig(newConfig) {
async function setConfig(newConfig, auditSource) {
assert.strictEqual(typeof newConfig, 'object');
assert(auditSource && typeof auditSource === 'object');
if (constants.DEMO) throw new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode');
if (constants.DEMO) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');
const currentConfig = await getConfig();
@@ -81,6 +85,19 @@ async function setConfig(newConfig) {
if (error) throw error;
await settings.setJson(settings.EXTERNAL_LDAP_KEY, newConfig);
if (newConfig.provider === 'noop') {
await users.resetSource(); // otherwise, the owner could be 'ldap' source and lock themselves out
await groups.resetSource();
}
await eventlog.add(eventlog.ACTION_EXTERNAL_LDAP_CONFIGURE, auditSource, { oldConfig: removePrivateFields(currentConfig), config: removePrivateFields(newConfig) });
await cron.handleExternalLdapChanged(newConfig);
}
function supports2FA(config) {
return config.provider === 'cloudron';
}
// performs service bind if required
@@ -98,7 +115,10 @@ async function getClient(config, options) {
url: config.url,
tlsOptions: {
rejectUnauthorized: config.acceptSelfSignedCerts ? false : true
}
},
// https://github.com/ldapjs/node-ldapjs/issues/486
timeout: 60000,
connectTimeout: 10000
};
client = ldap.createClient(ldapConfig);
@@ -108,12 +128,9 @@ async function getClient(config, options) {
}
return await new Promise((resolve, reject) => {
reject = once(reject);
// ensure we don't just crash
client.on('error', function (error) {
client.on('error', function (error) { // don't reject, we must have gotten a bind error
debug('getClient: ExternalLdap client error:', error);
reject(new BoxError(BoxError.EXTERNAL_ERROR, error));
});
// skip bind auth if none exist or if not wanted
@@ -278,32 +295,32 @@ async function maybeCreateUser(identifier) {
return await users.add(user.email, { username: user.username, password: null, displayName: user.displayName, source: 'ldap' }, AuditSource.EXTERNAL_LDAP);
}
async function verifyPassword(user, password, totpToken) {
assert.strictEqual(typeof user, 'object');
async function verifyPassword(username, password, options) {
assert.strictEqual(typeof username, 'string');
assert.strictEqual(typeof password, 'string');
assert(totpToken === null || typeof totpToken === 'string');
assert.strictEqual(typeof options, 'object');
const config = await getConfig();
if (config.provider === 'noop') throw new BoxError(BoxError.BAD_STATE, 'not enabled');
const ldapUsers = await ldapUserSearch(config, { filter: `${config.usernameField}=${user.username}` });
const ldapUsers = await ldapUserSearch(config, { filter: `${config.usernameField}=${username}` });
if (ldapUsers.length === 0) throw new BoxError(BoxError.NOT_FOUND);
if (ldapUsers.length > 1) throw new BoxError(BoxError.CONFLICT);
const client = await getClient(config, { bind: false });
let userAuthDn;
if (totpToken) {
// inject totptoken into first attribute
if (!options.skipTotpCheck && supports2FA(config)) {
// inject totptoken into first attribute. in ldap, '+' is the attribute separator in a RDNS
const rdns = ldapUsers[0].dn.split(',');
userAuthDn = `${rdns[0]}+totptoken=${totpToken},` + rdns.slice(1).join(',');
userAuthDn = `${rdns[0]}+totptoken=${options.totpToken},` + rdns.slice(1).join(',');
} else {
userAuthDn = ldapUsers[0].dn;
}
const [error] = await safe(util.promisify(client.bind.bind(client))(userAuthDn, password));
client.unbind();
if (error instanceof ldap.InvalidCredentialsError) throw new BoxError(BoxError.INVALID_CREDENTIALS);
if (error instanceof ldap.InvalidCredentialsError) throw new BoxError(BoxError.INVALID_CREDENTIALS, error.lde_message);
if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, error);
return translateUser(config, ldapUsers[0]);
@@ -384,14 +401,11 @@ async function syncGroups(config, progressCallback) {
let percent = 40;
let step = 30/(ldapGroups.length+1); // ensure no divide by 0
// we ignore all non internal errors here and just log them for now
for (const ldapGroup of ldapGroups) {
let groupName = ldapGroup[config.groupnameField];
if (!groupName) return;
// some servers return empty array for unknown properties :-/
if (typeof groupName !== 'string') return;
if (typeof groupName !== 'string') return; // some servers return empty array for unknown properties :-/
// groups are lowercase
groupName = groupName.toLowerCase();
percent += step;
@@ -401,10 +415,13 @@ async function syncGroups(config, progressCallback) {
if (!result) {
debug(`syncGroups: [adding group] groupname=${groupName}`);
const [error] = await safe(groups.add({ name: groupName, source: 'ldap' }));
if (error) debug('syncGroups: Failed to create group', groupName, error);
} else {
// convert local group to ldap group. 2 reasons:
// 1. we reset source flag when externalldap is disabled. if we renable, it automatically coverts
// 2. externalldap connector usually implies user wants to user external users/groups.
groups.update(result.id, { source: 'ldap' });
debug(`syncGroups: [up-to-date group] groupname=${groupName}`);
}
}
@@ -412,26 +429,26 @@ async function syncGroups(config, progressCallback) {
debug('syncGroups: sync done');
}
async function syncGroupUsers(config, progressCallback) {
async function syncGroupMembers(config, progressCallback) {
assert.strictEqual(typeof config, 'object');
assert.strictEqual(typeof progressCallback, 'function');
if (!config.syncGroups) {
debug('syncGroupUsers: Group users sync is disabled');
debug('syncGroupMembers: Group users sync is disabled');
progressCallback({ percent: 99, message: 'Skipping group users sync...' });
return [];
}
const allGroups = await groups.list();
const ldapGroups = allGroups.filter(function (g) { return g.source === 'ldap'; });
debug(`syncGroupUsers: Found ${ldapGroups.length} groups to sync users`);
debug(`syncGroupMembers: Found ${ldapGroups.length} groups to sync users`);
for (const group of ldapGroups) {
debug(`syncGroupUsers: Sync users for group ${group.name}`);
debug(`syncGroupMembers: Sync users for group ${group.name}`);
const result = await ldapGroupSearch(config, {});
if (!result || result.length === 0) {
debug(`syncGroupUsers: Unable to find group ${group.name} ignoring for now.`);
debug(`syncGroupMembers: Unable to find group ${group.name} ignoring for now.`);
continue;
}
@@ -442,7 +459,7 @@ async function syncGroupUsers(config, progressCallback) {
});
if (!found) {
debug(`syncGroupUsers: Unable to find group ${group.name} ignoring for now.`);
debug(`syncGroupMembers: Unable to find group ${group.name} ignoring for now.`);
continue;
}
@@ -451,32 +468,34 @@ async function syncGroupUsers(config, progressCallback) {
// if only one entry is in the group ldap returns a string, not an array!
if (typeof ldapGroupMembers === 'string') ldapGroupMembers = [ ldapGroupMembers ];
debug(`syncGroupUsers: Group ${group.name} has ${ldapGroupMembers.length} members.`);
debug(`syncGroupMembers: Group ${group.name} has ${ldapGroupMembers.length} members.`);
const userIds = [];
for (const memberDn of ldapGroupMembers) {
const [ldapError, result] = await safe(ldapGetByDN(config, memberDn));
if (ldapError) {
debug(`syncGroupUsers: Failed to get ${memberDn}: %o`, ldapError);
debug(`syncGroupMembers: Group ${group.name} failed to get ${memberDn}: %o`, ldapError);
continue;
}
debug(`syncGroupUsers: Found member object at ${memberDn} adding to group ${group.name}`);
debug(`syncGroupMembers: Group ${group.name} has member object ${memberDn}`);
const username = result[config.usernameField].toLowerCase();
const username = result[config.usernameField]?.toLowerCase();
if (!username) continue;
const [getError, userObject] = await safe(users.getByUsername(username));
if (getError || !userObject) {
debug(`syncGroupUsers: Failed to get user by username ${username}. %o`, getError ? getError : 'User not found');
debug(`syncGroupMembers: Failed to get user by username ${username}. %o`, getError ? getError : 'User not found');
continue;
}
const [addError] = await safe(groups.addMember(group.id, userObject.id));
if (addError && addError.reason !== BoxError.ALREADY_EXISTS) debug('syncGroupUsers: Failed to add member. %o', addError);
userIds.push(userObject.id);
}
const [setError] = await safe(groups.setMembers(group, userIds, { skipSourceCheck: true }));
if (setError) debug(`syncGroupMembers: Failed to set members of group ${group.name}. %o`, setError);
}
debug('syncGroupUsers: done');
debug('syncGroupMembers: done');
}
async function sync(progressCallback) {
@@ -489,7 +508,7 @@ async function sync(progressCallback) {
await syncUsers(config, progressCallback);
await syncGroups(config, progressCallback);
await syncGroupUsers(config, progressCallback);
await syncGroupMembers(config, progressCallback);
progressCallback({ percent: 100, message: 'Done' });
+60 -36
View File
@@ -5,19 +5,24 @@ exports = module.exports = {
remove,
get,
getByName,
update,
setName,
getWithMembers,
list,
listWithMembers,
getMembers,
addMember,
setMembers,
removeMember,
isMember,
setMembership,
getMembership,
setLocalMembership,
resetSource,
// exported for testing
_getMembership: getMembership
};
const assert = require('assert'),
@@ -30,7 +35,7 @@ const assert = require('assert'),
const GROUPS_FIELDS = [ 'id', 'name', 'source' ].join(',');
// keep this in sync with validateUsername
function validateGroupname(name) {
function validateName(name) {
assert.strictEqual(typeof name, 'string');
if (name.length < 1) return new BoxError(BoxError.BAD_FIELD, 'name must be atleast 1 char');
@@ -44,7 +49,7 @@ function validateGroupname(name) {
return null;
}
function validateGroupSource(source) {
function validateSource(source) {
assert.strictEqual(typeof source, 'string');
if (source !== '' && source !== 'ldap') return new BoxError(BoxError.BAD_FIELD, 'source must be "" or "ldap"');
@@ -60,10 +65,10 @@ async function add(group) {
name = name.toLowerCase(); // we store names in lowercase
source = source || '';
let error = validateGroupname(name);
let error = validateName(name);
if (error) throw error;
error = validateGroupSource(source);
error = validateSource(source);
if (error) throw error;
const id = `gid-${uuid.v4()}`;
@@ -72,7 +77,7 @@ async function add(group) {
if (error && error.code === 'ER_DUP_ENTRY') throw new BoxError(BoxError.ALREADY_EXISTS, error);
if (error) throw error;
return { id, name };
return { id, name, source };
}
async function remove(id) {
@@ -151,15 +156,23 @@ async function getMembership(userId) {
return result.map(function (r) { return r.groupId; });
}
async function setMembership(userId, groupIds) {
assert.strictEqual(typeof userId, 'string');
assert(Array.isArray(groupIds));
async function setLocalMembership(user, localGroupIds) {
assert.strictEqual(typeof user, 'object'); // can be local or external
assert(Array.isArray(localGroupIds));
let queries = [ ];
queries.push({ query: 'DELETE from groupMembers WHERE userId = ?', args: [ userId ] });
groupIds.forEach(function (gid) {
queries.push({ query: 'INSERT INTO groupMembers (groupId, userId) VALUES (? , ?)', args: [ gid, userId ] });
});
// ensure groups are actually local
for (const groupId of localGroupIds) {
const group = await get(groupId);
if (!group) throw new BoxError(BoxError.NOT_FOUND, `Group ${groupId} not found`);
if (group.source) throw new BoxError(BoxError.BAD_STATE, 'Cannot set members of external group');
}
const queries = [];
// a remote user may already be part of some external groups. do not clear those because remote groups are non-editable
queries.push({ query: 'DELETE FROM groupMembers WHERE userId = ? AND groupId IN (SELECT id FROM userGroups WHERE source = ?)', args: [ user.id, '' ] });
for (const gid of localGroupIds) {
queries.push({ query: 'INSERT INTO groupMembers (groupId, userId) VALUES (? , ?)', args: [ gid, user.id ] });
}
const [error] = await safe(database.transaction(queries));
if (error && error.code === 'ER_NO_REFERENCED_ROW_2') throw new BoxError(BoxError.NOT_FOUND, 'Group not found');
@@ -167,25 +180,17 @@ async function setMembership(userId, groupIds) {
if (error) throw error;
}
async function addMember(groupId, userId) {
assert.strictEqual(typeof groupId, 'string');
assert.strictEqual(typeof userId, 'string');
const [error] = await safe(database.query('INSERT INTO groupMembers (groupId, userId) VALUES (?, ?)', [ groupId, userId ]));
if (error && error.code === 'ER_DUP_ENTRY') throw new BoxError(BoxError.ALREADY_EXISTS, error);
if (error && error.code === 'ER_NO_REFERENCED_ROW_2' && error.sqlMessage.includes('userId')) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
if (error && error.code === 'ER_NO_REFERENCED_ROW_2') throw new BoxError(BoxError.NOT_FOUND, 'Group not found');
if (error) throw error;
}
async function setMembers(groupId, userIds) {
assert.strictEqual(typeof groupId, 'string');
async function setMembers(group, userIds, options) {
assert.strictEqual(typeof group, 'object');
assert(Array.isArray(userIds));
assert.strictEqual(typeof options, 'object');
let queries = [];
queries.push({ query: 'DELETE FROM groupMembers WHERE groupId = ?', args: [ groupId ] });
if (!options.skipSourceCheck && group.source === 'ldap') throw new BoxError(BoxError.BAD_STATE, 'Cannot set members of external group');
const queries = [];
queries.push({ query: 'DELETE FROM groupMembers WHERE groupId = ?', args: [ group.id ] });
for (let i = 0; i < userIds.length; i++) {
queries.push({ query: 'INSERT INTO groupMembers (groupId, userId) VALUES (?, ?)', args: [ groupId, userIds[i] ] });
queries.push({ query: 'INSERT INTO groupMembers (groupId, userId) VALUES (?, ?)', args: [ group.id, userIds[i] ] });
}
const [error] = await safe(database.transaction(queries));
@@ -216,17 +221,23 @@ async function update(id, data) {
if ('name' in data) {
assert.strictEqual(typeof data.name, 'string');
const error = validateGroupname(data.name);
data.name = data.name.toLowerCase();
const error = validateName(data.name);
if (error) throw error;
}
if ('source' in data) {
assert.strictEqual(typeof data.source, 'string');
const error = validateSource(data.source);
if (error) throw error;
}
const args = [];
const fields = [];
for (const k in data) {
if (k === 'name') {
assert.strictEqual(typeof data.name, 'string');
if (k === 'name' || k === 'source') {
fields.push(k + ' = ?');
args.push(data.name);
args.push(data[k]);
}
}
args.push(id);
@@ -236,3 +247,16 @@ async function update(id, data) {
if (updateError) throw updateError;
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Group not found');
}
async function setName(group, name) {
assert.strictEqual(typeof group, 'object');
assert.strictEqual(typeof name, 'string');
if (group.source === 'ldap') throw new BoxError(BoxError.BAD_STATE, 'Cannot set name of external group');
await update(group.id, { name });
}
async function resetSource() {
await database.query('UPDATE userGroups SET source = ?', [ '' ]);
}
+4 -4
View File
@@ -13,12 +13,12 @@ exports = module.exports = {
'images': {
'base': 'registry.docker.com/cloudron/base:4.2.0@sha256:46da2fffb36353ef714f97ae8e962bd2c212ca091108d768ba473078319a47f4',
'graphite': 'registry.docker.com/cloudron/graphite:3.4.3@sha256:75df420ece34b31a7ce8d45b932246b7f524c123e1854f5e8f115a9e94e33f20',
'mail': 'registry.docker.com/cloudron/mail:3.11.3@sha256:0e8ec3ba14482e2256ab0fb75021da11f437e6f269cd9dc0232426aca1b9361a',
'mongodb': 'registry.docker.com/cloudron/mongodb:5.1.2@sha256:897bea3cae08c8c10f9f5adaff853be314ab94aa98d96a8d0caa502babd983aa',
'mail': 'registry.docker.com/cloudron/mail:3.12.1@sha256:f539bea6c7360d3c0aa604323847172359593f109b304bb2d2c5152ca56be05c',
'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.1.6@sha256:a89231a7835955767893a83b2d993764f59da24e292385b06470c8e42a1ffa0e',
'postgresql': 'registry.docker.com/cloudron/postgresql:5.2.1@sha256:5ef3aea8873da25ea5e682e458b11c99fc8df25ae90c7695a6f40bda8d120057',
'redis': 'registry.docker.com/cloudron/redis:3.5.2@sha256:5c3d9a912d3ad723b195cfcbe9f44956a2aa88f9e29f7da3ef725162f8e2829a',
'sftp': 'registry.docker.com/cloudron/sftp:3.8.3@sha256:e00d8ef884b8657b57499d397d9db7f141f3d17253eec2752cdef5d15fff51da',
'sftp': 'registry.docker.com/cloudron/sftp:3.8.6@sha256:6b4e3f192c23eadb21d2035ba05f8432d7961330edb93921f36a4eaa60c4a4aa',
'turn': 'registry.docker.com/cloudron/turn:1.7.2@sha256:9ed8da613c1edc5cb8700657cf6e49f0f285b446222a8f459f80919945352f6d',
}
};
+50 -57
View File
@@ -51,10 +51,7 @@ async function userAuthInternal(appId, req, res, next) {
// extract the common name which might have different attribute names
const attributeName = Object.keys(req.dn.rdns[0].attrs)[0];
const commonName = req.dn.rdns[0].attrs[attributeName].value;
if (!commonName) return next(new ldap.NoSuchObjectError(req.dn.toString()));
const TOTPTOKEN_ATTRIBUTE_NAME = 'totptoken';
const totpToken = req.dn.rdns[0].attrs[TOTPTOKEN_ATTRIBUTE_NAME] ? req.dn.rdns[0].attrs[TOTPTOKEN_ATTRIBUTE_NAME].value : null;
if (!commonName) return next(new ldap.NoSuchObjectError('Missing CN'));
let verifyFunc;
if (attributeName === 'mail') {
@@ -67,9 +64,9 @@ async function userAuthInternal(appId, req, res, next) {
verifyFunc = users.verifyWithUsername;
}
const [error, user] = await safe(verifyFunc(commonName, req.credentials || '', appId || '', { relaxedTotpCheck: true, totpToken }));
if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (error && error.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(req.dn.toString()));
const [error, user] = await safe(verifyFunc(commonName, req.credentials || '', appId || '', { skipTotpCheck: true }));
if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(error.message));
if (error && error.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(error.message));
if (error) return next(new ldap.OperationsError(error.message));
req.user = user;
@@ -149,10 +146,10 @@ async function userSearch(req, res, next) {
debug('user search: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);
const [error, result] = await safe(getUsersWithAccessToApp(req));
if (error) return next(new ldap.OperationsError(error.toString()));
if (error) return next(new ldap.OperationsError(error.message));
const [groupsError, allGroups] = await safe(groups.listWithMembers());
if (groupsError) return next(new ldap.OperationsError(error.toString()));
if (groupsError) return next(new ldap.OperationsError(groupsError.message));
let results = [];
@@ -164,10 +161,9 @@ async function userSearch(req, res, next) {
const dn = ldap.parseDN('cn=' + user.id + ',ou=users,dc=cloudron');
const displayName = user.displayName || user.username || ''; // displayName can be empty and username can be null
const nameParts = displayName.split(' ');
const firstName = nameParts[0];
const lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : ''; // choose last part, if it exists
const { firstName, lastName, middleName } = users.parseDisplayName(displayName);
// https://datatracker.ietf.org/doc/html/rfc2798
const obj = {
dn: dn.toString(),
attributes: {
@@ -180,19 +176,16 @@ async function userSearch(req, res, next) {
mailAlternateAddress: user.fallbackEmail,
displayname: displayName,
givenName: firstName,
sn: lastName,
middleName: middleName,
username: user.username,
samaccountname: user.username, // to support ActiveDirectory clients
memberof: allGroups.filter(function (g) { return g.userIds.indexOf(user.id) !== -1; }).map(function (g) { return `cn=${g.name},ou=groups,dc=cloudron`; })
}
};
// http://www.zytrax.com/books/ldap/ape/core-schema.html#sn has 'name' as SUP which is a DirectoryString
// which is required to have atleast one character if present
if (lastName.length !== 0) obj.attributes.sn = lastName;
// ensure all filter values are also lowercase
const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString()));
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message));
if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && lowerCaseFilter.matches(obj.attributes)) {
results.push(obj);
@@ -207,8 +200,8 @@ async function groupSearch(req, res, next) {
const results = [];
let [errorGroups, resultGroups] = await safe(groups.listWithMembers());
if (errorGroups) return next(new ldap.OperationsError(errorGroups.toString()));
let [groupsListError, resultGroups] = await safe(groups.listWithMembers());
if (groupsListError) return next(new ldap.OperationsError(groupsListError.message));
if (req.app.accessRestriction && req.app.accessRestriction.groups) {
resultGroups = resultGroups.filter(function (g) { return req.app.accessRestriction.groups.indexOf(g.id) !== -1; });
@@ -229,7 +222,7 @@ async function groupSearch(req, res, next) {
// ensure all filter values are also lowercase
const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString()));
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message));
if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && lowerCaseFilter.matches(obj.attributes)) {
results.push(obj);
@@ -243,7 +236,7 @@ async function groupUsersCompare(req, res, next) {
debug('group users compare: dn %s, attribute %s, value %s (from %s)', req.dn.toString(), req.attribute, req.value, req.connection.ldap.id);
const [error, result] = await safe(getUsersWithAccessToApp(req));
if (error) return next(new ldap.OperationsError(error.toString()));
if (error) return next(new ldap.OperationsError(error.message));
// we only support memberuid here, if we add new group attributes later add them here
if (req.attribute === 'memberuid') {
@@ -258,7 +251,7 @@ async function groupAdminsCompare(req, res, next) {
debug('group admins compare: dn %s, attribute %s, value %s (from %s)', req.dn.toString(), req.attribute, req.value, req.connection.ldap.id);
const [error, result] = await safe(getUsersWithAccessToApp(req));
if (error) return next(new ldap.OperationsError(error.toString()));
if (error) return next(new ldap.OperationsError(error.message));
// we only support memberuid here, if we add new group attributes later add them here
if (req.attribute === 'memberuid') {
@@ -287,9 +280,9 @@ async function mailboxSearch(req, res, next) {
if (parts.length !== 2) return next(new ldap.NoSuchObjectError(dn.toString()));
const [error, mailbox] = await safe(mail.getMailbox(parts[0], parts[1]));
if (error) return next(new ldap.OperationsError(error.toString()));
if (error) return next(new ldap.OperationsError(error.message));
if (!mailbox) return next(new ldap.NoSuchObjectError(dn.toString()));
if (!mailbox.active) return next(new ldap.NoSuchObjectError(dn.toString()));
if (!mailbox.active) return next(new ldap.NoSuchObjectError('Mailbox is not active'));
const obj = {
dn: dn.toString(),
@@ -306,7 +299,7 @@ async function mailboxSearch(req, res, next) {
// ensure all filter values are also lowercase
const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString()));
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message));
if (lowerCaseFilter.matches(obj.attributes)) {
finalSend([ obj ], req, res, next);
@@ -316,7 +309,7 @@ async function mailboxSearch(req, res, next) {
} else { // new sogo and dovecot listing (doveadm -A)
// TODO figure out how proper pagination here could work
let [error, mailboxes] = await safe(mail.listAllMailboxes(1, 100000));
if (error) return next(new ldap.OperationsError(error.toString()));
if (error) return next(new ldap.OperationsError(error.message));
mailboxes = mailboxes.filter(m => m.active);
@@ -349,7 +342,7 @@ async function mailboxSearch(req, res, next) {
// ensure all filter values are also lowercase
const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString()));
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message));
if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && lowerCaseFilter.matches(obj.attributes)) {
results.push(obj);
@@ -363,17 +356,17 @@ async function mailboxSearch(req, res, next) {
async function mailAliasSearch(req, res, next) {
debug('mail alias get: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);
if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError('Missing CN'));
const email = req.dn.rdns[0].attrs.cn.value.toLowerCase();
const parts = email.split('@');
if (parts.length !== 2) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (parts.length !== 2) return next(new ldap.NoSuchObjectError('Invalid CN'));
const [error, alias] = await safe(mail.searchAlias(parts[0], parts[1]));
if (error) return next(new ldap.OperationsError(error.toString()));
if (!alias) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (error) return next(new ldap.OperationsError(error.message));
if (!alias) return next(new ldap.NoSuchObjectError('No such alias'));
if (!alias.active) return next(new ldap.NoSuchObjectError(req.dn.toString())); // there is no way to disable an alias. this is just here for completeness
if (!alias.active) return next(new ldap.NoSuchObjectError('Mailbox is not active')); // there is no way to disable an alias. this is just here for completeness
// https://wiki.debian.org/LDAP/MigrationTools/Examples
// https://docs.oracle.com/cd/E19455-01/806-5580/6jej518pp/index.html
@@ -390,7 +383,7 @@ async function mailAliasSearch(req, res, next) {
// ensure all filter values are also lowercase
const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString()));
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message));
if (lowerCaseFilter.matches(obj.attributes)) {
finalSend([ obj ], req, res, next);
@@ -402,20 +395,20 @@ async function mailAliasSearch(req, res, next) {
async function mailingListSearch(req, res, next) {
debug('mailing list get: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);
if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError('Missing CN'));
let email = req.dn.rdns[0].attrs.cn.value.toLowerCase();
let parts = email.split('@');
if (parts.length !== 2) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (parts.length !== 2) return next(new ldap.NoSuchObjectError('Invalid CN'));
const name = parts[0], domain = parts[1];
const [error, result] = await safe(mail.resolveList(parts[0], parts[1]));
if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (error) return next(new ldap.OperationsError(error.toString()));
if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError('No such list'));
if (error) return next(new ldap.OperationsError(error.message));
const { resolvedMembers, list } = result;
if (!list.active) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (!list.active) return next(new ldap.NoSuchObjectError('List is not active'));
// http://ldapwiki.willeke.com/wiki/Original%20Mailgroup%20Schema%20From%20Netscape
// members are fully qualified (https://docs.oracle.com/cd/E19444-01/816-6018-10/groups.htm#13356)
@@ -433,7 +426,7 @@ async function mailingListSearch(req, res, next) {
// ensure all filter values are also lowercase
const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString()));
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message));
if (lowerCaseFilter.matches(obj.attributes)) {
finalSend([ obj ], req, res, next);
@@ -457,7 +450,7 @@ async function authorizeUserForApp(req, res, next) {
const canAccess = apps.canAccess(req.app, req.user);
// we return no such object, to avoid leakage of a users existence
if (!canAccess) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (!canAccess) return next(new ldap.NoSuchObjectError('Invalid user or insufficient previleges'));
await eventlog.upsertLoginEvent(req.user.ghost ? eventlog.ACTION_USER_LOGIN_GHOST : eventlog.ACTION_USER_LOGIN, AuditSource.LDAP, { appId: req.app.id, userId: req.user.id, user: users.removePrivateFields(req.user) });
@@ -469,13 +462,13 @@ async function verifyMailboxPassword(mailbox, password) {
assert.strictEqual(typeof password, 'string');
if (mailbox.ownerType === mail.OWNERTYPE_USER) {
return await users.verify(mailbox.ownerId, password, users.AP_MAIL /* identifier */, { relaxedTotpCheck: true });
return await users.verify(mailbox.ownerId, password, users.AP_MAIL /* identifier */, { skipTotpCheck: true });
} else if (mailbox.ownerType === mail.OWNERTYPE_GROUP) {
const userIds = await groups.getMembers(mailbox.ownerId);
let verifiedUser = null;
for (const userId of userIds) {
const [error, result] = await safe(users.verify(userId, password, users.AP_MAIL /* identifier */, { relaxedTotpCheck: true }));
const [error, result] = await safe(users.verify(userId, password, users.AP_MAIL /* identifier */, { skipTotpCheck: true }));
if (error) continue; // try the next user
verifiedUser = result;
break; // found a matching validated user
@@ -491,17 +484,17 @@ async function verifyMailboxPassword(mailbox, password) {
async function authenticateSftp(req, res, next) {
debug('sftp auth: %s (from %s)', req.dn.toString(), req.connection.ldap.id);
if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError('Missing CN'));
const email = req.dn.rdns[0].attrs.cn.value.toLowerCase();
const parts = email.split('@');
if (parts.length !== 2) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (parts.length !== 2) return next(new ldap.NoSuchObjectError('Invalid CN'));
let [error, app] = await safe(apps.getByFqdn(parts[1]));
if (error || !app) return next(new ldap.InvalidCredentialsError(req.dn.toString()));
if (error || !app) return next(new ldap.InvalidCredentialsError());
[error] = await safe(users.verifyWithUsername(parts[0], req.credentials, app.id, { relaxedTotpCheck: true }));
if (error) return next(new ldap.InvalidCredentialsError(req.dn.toString()));
[error] = await safe(users.verifyWithUsername(parts[0], req.credentials, app.id, { skipTotpCheck: true }));
if (error) return next(new ldap.InvalidCredentialsError(error.message));
debug('sftp auth: success');
@@ -511,16 +504,16 @@ async function authenticateSftp(req, res, next) {
async function userSearchSftp(req, res, next) {
debug('sftp user search: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);
if (req.filter.attribute !== 'username' || !req.filter.value) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (req.filter.attribute !== 'username' || !req.filter.value) return next(new ldap.NoSuchObjectError());
const parts = req.filter.value.split('@');
if (parts.length !== 2) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (parts.length !== 2) return next(new ldap.NoSuchObjectError());
const username = parts[0];
const appFqdn = parts[1];
const [error, app] = await safe(apps.getByFqdn(appFqdn));
if (error) return next(new ldap.OperationsError(error.toString()));
if (error) return next(new ldap.OperationsError(error.message));
// only allow apps which specify "ftp" support in the localstorage addon
if (!safe.query(app.manifest.addons, 'localstorage.ftp.uid')) return next(new ldap.UnavailableError('Not supported'));
@@ -529,7 +522,7 @@ async function userSearchSftp(req, res, next) {
const uidNumber = app.manifest.addons.localstorage.ftp.uid;
const [userGetError, user] = await safe(users.getByUsername(username));
if (userGetError) return next(new ldap.OperationsError(userGetError.toString()));
if (userGetError) return next(new ldap.OperationsError(userGetError.message));
if (!user) return next(new ldap.OperationsError('Invalid username'));
if (!apps.isOperator(app, user)) return next(new ldap.InsufficientAccessRightsError('Not authorized'));
@@ -595,13 +588,13 @@ async function authenticateService(serviceId, dn, req, res, next) {
const [appPasswordError] = await safe(verifyAppMailboxPassword(serviceId, email, req.credentials || ''));
if (!appPasswordError) return res.end(); // validated as app
if (appPasswordError.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(dn.toString()));
if (appPasswordError.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(appPasswordError.message));
if (appPasswordError.reason !== BoxError.NOT_FOUND) return next(new ldap.OperationsError(appPasswordError.message));
if (!mailbox || !mailbox.active) return next(new ldap.NoSuchObjectError(dn.toString())); // user auth requires active mailbox
const [verifyError, result] = await safe(verifyMailboxPassword(mailbox, req.credentials || ''));
if (verifyError && verifyError.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(dn.toString()));
if (verifyError && verifyError.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(dn.toString()));
if (verifyError && verifyError.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(verifyError.message));
if (verifyError && verifyError.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(verifyError.message));
if (verifyError) return next(new ldap.OperationsError(verifyError.message));
eventlog.upsertLoginEvent(result.ghost ? eventlog.ACTION_USER_LOGIN_GHOST : eventlog.ACTION_USER_LOGIN, AuditSource.MAIL, { mailboxId: email, userId: result.id, user: users.removePrivateFields(result) });
@@ -610,7 +603,7 @@ async function authenticateService(serviceId, dn, req, res, next) {
}
async function authenticateMail(req, res, next) {
if (!req.dn.rdns[1].attrs.ou) return next(new ldap.NoSuchObjectError(req.dn.toString()));
if (!req.dn.rdns[1].attrs.ou) return next(new ldap.NoSuchObjectError());
await authenticateService(req.dn.rdns[1].attrs.ou.value.toLowerCase(), req.dn, req, res, next);
}
@@ -710,6 +703,6 @@ async function start() {
async function stop() {
if (!gServer) return;
gServer.close();
await util.promisify(gServer.close.bind(gServer))();
gServer = null;
}
+13 -16
View File
@@ -2,6 +2,7 @@
const assert = require('assert'),
path = require('path'),
shell = require('./shell.js'),
spawn = require('child_process').spawn,
stream = require('stream'),
{ StringDecoder } = require('string_decoder'),
@@ -25,12 +26,9 @@ class LogStream extends TransformStream {
if (isNaN(timestamp)) timestamp = 0;
const message = line.slice(data[0].length+1);
// ignore faulty empty logs
if (!timestamp && !message) return;
return JSON.stringify({
realtimeTimestamp: timestamp * 1000,
message: message,
realtimeTimestamp: timestamp * 1000, // timestamp info can be missing (0) for app logs via logPaths
message: message || line, // send the line if message parsing failed
source: this._options.source
}) + '\n';
}
@@ -58,24 +56,23 @@ function tail(filePaths, options) {
assert(Array.isArray(filePaths));
assert.strictEqual(typeof options, 'object');
const lines = options.lines === -1 ? '+1' : options.lines,
follow = options.follow;
const lines = options.lines === -1 ? '+1' : options.lines;
const args = [ LOGTAIL_CMD, '--lines=' + lines ];
if (options.follow) args.push('--follow');
const args = [ '--lines=' + lines ];
if (follow) args.push('--follow');
return spawn(LOGTAIL_CMD, args.concat(filePaths));
return shell.sudo('tail', args.concat(filePaths), { streamStdout: true }, () => {});
}
function journalctl(unit, options) {
assert.strictEqual(typeof unit, 'string');
assert.strictEqual(typeof options, 'object');
const args = [];
args.push('--lines=' + (options.lines === -1 ? 'all' : options.lines));
args.push(`--unit=${unit}`);
args.push('--no-pager');
args.push('--output=short-iso');
const args = [
'--lines=' + (options.lines === -1 ? 'all' : options.lines),
`--unit=${unit}`,
'--no-pager',
'--output=short-iso'
];
if (options.follow) args.push('--follow');
+1 -1
View File
@@ -179,7 +179,7 @@ async function checkOutboundPort25() {
return await new Promise((resolve) => {
const client = new net.Socket();
client.setTimeout(5000);
client.connect(25, constants.PORT25_CHECK_SERVER);
client.connect({ port: 25, host: constants.PORT25_CHECK_SERVER, family: 4 }); // family is 4 to keep it predictable
client.on('connect', function () {
relay.status = true;
relay.value = 'OK';
+17 -11
View File
@@ -31,6 +31,7 @@ const assert = require('assert'),
docker = require('./docker.js'),
domains = require('./domains.js'),
eventlog = require('./eventlog.js'),
fs = require('fs'),
hat = require('./hat.js'),
infra = require('./infra_version.js'),
Location = require('./location.js'),
@@ -53,8 +54,8 @@ async function generateDkimKey() {
const privateKeyFilePath = path.join(os.tmpdir(), `dkim-${crypto.randomBytes(4).readUInt32LE(0)}.private`);
// https://www.unlocktheinbox.com/dkim-key-length-statistics/ and https://docs.aws.amazon.com/ses/latest/DeveloperGuide/send-email-authentication-dkim-easy.html for key size
if (!safe.child_process.execSync(`openssl genrsa -out ${privateKeyFilePath} 1024`)) return new BoxError(BoxError.OPENSSL_ERROR, safe.error);
if (!safe.child_process.execSync(`openssl rsa -in ${privateKeyFilePath} -out ${publicKeyFilePath} -pubout -outform PEM`)) return new BoxError(BoxError.OPENSSL_ERROR, safe.error);
await shell.exec('generateDkimKey', `openssl genrsa -out ${privateKeyFilePath} 1024`, {});
await shell.exec('generateDkimKey', `openssl rsa -in ${privateKeyFilePath} -out ${publicKeyFilePath} -pubout -outform PEM`, {});
const publicKey = safe.fs.readFileSync(publicKeyFilePath, 'utf8');
if (!publicKey) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
@@ -153,15 +154,19 @@ async function configureMail(mailFqdn, mailDomain, serviceConfig) {
const mailCertFilePath = `${paths.MAIL_CONFIG_DIR}/tls_cert.pem`;
const mailKeyFilePath = `${paths.MAIL_CONFIG_DIR}/tls_key.pem`;
if (!safe.child_process.execSync(`cp ${paths.DHPARAMS_FILE} ${dhparamsFilePath}`)) throw new BoxError(BoxError.FS_ERROR, `Could not copy dhparams: ${safe.error.message}`);
const [readError, dhparams] = await safe(fs.promises.readFile(paths.DHPARAMS_FILE));
if (readError) throw new BoxError(BoxError.FS_ERROR, `Could not read dhparams: ${readError.message}`);
const [copyError] = await safe(fs.promises.writeFile(dhparamsFilePath, dhparams));
if (copyError) throw new BoxError(BoxError.FS_ERROR, `Could not copy dhparams: ${copyError.message}`);
if (!safe.fs.writeFileSync(mailCertFilePath, certificate.cert)) throw new BoxError(BoxError.FS_ERROR, `Could not create cert file: ${safe.error.message}`);
if (!safe.fs.writeFileSync(mailKeyFilePath, certificate.key)) throw new BoxError(BoxError.FS_ERROR, `Could not create key file: ${safe.error.message}`);
// if the 'yellowtent' user of OS and the 'cloudron' user of mail container don't match, the keys become inaccessible by mail code
if (!safe.fs.chmodSync(mailKeyFilePath, 0o644)) throw new BoxError(BoxError.FS_ERROR, `Could not chmod key file: ${safe.error.message}`);
await shell.promises.exec('stopMail', 'docker stop mail || true');
await shell.promises.exec('removeMail', 'docker rm -f mail || true');
debug('configureMail: stopping and deleting previous mail container');
await docker.stopContainer('mail');
await docker.deleteContainer('mail');
const allowInbound = await createMailConfig(mailFqdn);
@@ -170,7 +175,7 @@ async function configureMail(mailFqdn, mailDomain, serviceConfig) {
const cmd = serviceConfig.recoveryMode ? '/bin/bash -c \'echo "Debug mode. Sleeping" && sleep infinity\'' : '';
const logLevel = serviceConfig.recoveryMode ? 'data' : 'info';
const runCmd = `docker run --restart=always -d --name="mail" \
const runCmd = `docker run --restart=always -d --name=mail \
--net cloudron \
--net-alias mail \
--log-driver syslog \
@@ -181,16 +186,17 @@ async function configureMail(mailFqdn, mailDomain, serviceConfig) {
--memory-swap ${memoryLimit} \
--dns 172.18.0.1 \
--dns-search=. \
-e CLOUDRON_MAIL_TOKEN="${cloudronToken}" \
-e CLOUDRON_RELAY_TOKEN="${relayToken}" \
-e CLOUDRON_MAIL_TOKEN=${cloudronToken} \
-e CLOUDRON_RELAY_TOKEN=${relayToken} \
-e LOGLEVEL=${logLevel} \
-v "${paths.MAIL_DATA_DIR}:/app/data" \
-v "${paths.MAIL_CONFIG_DIR}:/etc/mail:ro" \
-v ${paths.MAIL_DATA_DIR}:/app/data \
-v ${paths.MAIL_CONFIG_DIR}:/etc/mail:ro \
${ports} \
--label isCloudronManaged=true \
${readOnly} -v /run -v /tmp ${image} ${cmd}`;
await shell.promises.exec('startMail', runCmd);
debug('configureMail: starting mail container');
await shell.exec('configureMail', runCmd, { shell: '/bin/bash' });
}
async function restart() {
+8 -8
View File
@@ -71,7 +71,7 @@ function isManagedProvider(provider) {
// nfs - no_root_squash is mode on server to map all root to 'nobody' user. all_squash does this for all users (making it like ftp)
// sshfs - supports users/permissions
// cifs - does not support permissions
function renderMountFile(mount) {
async function renderMountFile(mount) {
assert.strictEqual(typeof mount, 'object');
const { name, hostPath, mountType, mountOptions } = mount;
@@ -79,8 +79,7 @@ function renderMountFile(mount) {
let options, what, type;
switch (mountType) {
case 'cifs': {
const out = safe.child_process.execSync(`systemd-escape -p '${hostPath}'`, { encoding: 'utf8' }); // this ensures uniqueness of creds file
if (!out) throw new BoxError(BoxError.FS_ERROR, `Could not determine credentials file name: ${safe.error.message}`);
const out = await shell.execArgs('renderMountFile', 'systemd-escape', [ '-p', hostPath ], {}); // this ensures uniqueness of creds file
const credentialsFilePath = path.join(paths.CIFS_CREDENTIALS_DIR, `${out.trim()}.cred`);
if (!safe.fs.writeFileSync(credentialsFilePath, `username=${mountOptions.username}\npassword=${mountOptions.password}\n`, { mode: 0o600 })) throw new BoxError(BoxError.FS_ERROR, `Could not write credentials file: ${safe.error.message}`);
@@ -139,8 +138,7 @@ async function removeMount(mount) {
const keyFilePath = path.join(paths.SSHFS_KEYS_DIR, `id_rsa_${mountOptions.host}`);
safe.fs.unlinkSync(keyFilePath);
} else if (mountType === 'cifs') {
const out = safe.child_process.execSync(`systemd-escape -p '${hostPath}'`, { encoding: 'utf8' });
if (!out) return;
const out = await shell.execArgs('removeMount', 'systemd-escape', [ '-p', hostPath ], {});
const credentialsFilePath = path.join(paths.CIFS_CREDENTIALS_DIR, `${out.trim()}.cred`);
safe.fs.unlinkSync(credentialsFilePath);
}
@@ -152,7 +150,8 @@ async function getStatus(mountType, hostPath) {
if (mountType === 'filesystem') return { state: 'active', message: 'Mounted' };
const state = safe.child_process.execSync(`mountpoint -q -- ${hostPath}`) ? 'active' : 'inactive';
const [error] = await safe(shell.execArgs('getVolumeStatus', 'mountpoint', [ '-q', '--', hostPath ], { timeout: 5000 }));
const state = error ? 'inactive' : 'active';
if (mountType === 'mountpoint') return { state, message: state === 'active' ? 'Mounted' : 'Not mounted' };
@@ -160,7 +159,7 @@ async function getStatus(mountType, hostPath) {
let message;
if (state !== 'active') { // find why it failed
const logsJson = safe.child_process.execSync(`journalctl -u $(systemd-escape -p --suffix=mount ${hostPath}) -n 10 --no-pager -o json`, { encoding: 'utf8' });
const logsJson = await shell.exec('getStatus', `journalctl -u $(systemd-escape -p --suffix=mount ${hostPath}) -n 10 --no-pager -o json`, { shell: '/bin/bash' });
if (logsJson) {
const lines = logsJson.trim().split('\n').map(l => JSON.parse(l)); // array of json
@@ -195,7 +194,8 @@ async function tryAddMount(mount, options) {
if (constants.TEST) return;
const [error] = await safe(shell.promises.sudo('addMount', [ ADD_MOUNT_CMD, renderMountFile(mount), options.timeout ], {}));
const mountFileContents = await renderMountFile(mount);
const [error] = await safe(shell.promises.sudo('addMount', [ ADD_MOUNT_CMD, mountFileContents, options.timeout ], {}));
if (error && error.code === 2) throw new BoxError(BoxError.MOUNT_ERROR, 'Failed to unmount existing mount'); // at this point, the old mount config is still there
if (options.skipCleanup) return;
+3 -3
View File
@@ -93,7 +93,7 @@ async function setBlocklist(blocklist, auditSource) {
}
if (count >= 262144) throw new BoxError(BoxError.CONFLICT, 'Blocklist is too large. Max 262144 entries are allowed'); // see the cloudron-firewall.sh
if (constants.DEMO) throw new BoxError(BoxError.CONFLICT, 'Not allowed in demo mode');
if (constants.DEMO) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');
// store in blob since the value field is TEXT and has 16kb size limit
await settings.setBlob(settings.FIREWALL_BLOCKLIST_KEY, Buffer.from(blocklist));
@@ -125,7 +125,7 @@ async function getIPv4Config() {
async function setIPv4Config(ipv4Config) {
assert.strictEqual(typeof ipv4Config, 'object');
if (constants.DEMO) throw new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode');
if (constants.DEMO) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');
const error = await testIPv4Config(ipv4Config);
if (error) throw error;
@@ -141,7 +141,7 @@ async function getIPv6Config() {
async function setIPv6Config(ipv6Config) {
assert.strictEqual(typeof ipv6Config, 'object');
if (constants.DEMO) throw new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode');
if (constants.DEMO) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');
const error = await testIPv6Config(ipv6Config);
if (error) throw error;
+9 -5
View File
@@ -20,6 +20,7 @@ const assert = require('assert'),
blobs = require('./blobs.js'),
branding = require('./branding.js'),
constants = require('./constants.js'),
crypto = require('crypto'),
dashboard = require('./dashboard.js'),
database = require('./database.js'),
debug = require('debug')('box:oidc'),
@@ -522,7 +523,7 @@ function interactionLogin(provider) {
const verifyFunc = username.indexOf('@') === -1 ? users.verifyWithUsername : users.verifyWithEmail;
const [verifyError, user] = await safe(verifyFunc(username, password, users.AP_WEBADMIN, { totpToken }));
const [verifyError, user] = await safe(verifyFunc(username, password, users.AP_WEBADMIN, { totpToken, skipTotpCheck: false }));
if (verifyError && verifyError.reason === BoxError.INVALID_CREDENTIALS) return next(new HttpError(401, verifyError.message));
if (verifyError && verifyError.reason === BoxError.NOT_FOUND) return next(new HttpError(401, 'Username and password does not match'));
if (verifyError) return next(new HttpError(500, verifyError));
@@ -647,18 +648,21 @@ async function claims(userId/*, use, scope*/) {
if (error) return { error: 'user not found' };
const displayName = user.displayName || user.username || ''; // displayName can be empty and username can be null
const nameParts = displayName.split(' ');
const firstName = nameParts[0];
const lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : ''; // choose last part, if it exists
const { firstName, lastName, middleName } = users.parseDisplayName(displayName);
const { fqdn:dashboardFqdn } = await dashboard.getLocation();
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
const claims = {
sub: user.username, // it is essential to always return a sub claim
email: user.email,
email_verified: true,
family_name: lastName,
middle_name: middleName,
given_name: firstName,
locale: 'en-US',
name: user.displayName,
picture: `https://${dashboardFqdn}/api/v1/profile/avatar/${user.id}`,
preferred_username: user.username
};
@@ -723,7 +727,7 @@ async function start() {
let cookieSecret = await settings.get(settings.OIDC_COOKIE_SECRET_KEY);
if (!cookieSecret) {
debug('Generating new cookie secret');
cookieSecret = require('crypto').randomBytes(256).toString('base64');
cookieSecret = crypto.randomBytes(256).toString('base64');
await settings.set(settings.OIDC_COOKIE_SECRET_KEY, cookieSecret);
}
+11 -9
View File
@@ -82,7 +82,7 @@
</div>
<div class="card-form-bottom-bar">
<a href="/passwordreset.html">{{ login.resetPasswordAction }}</a>
<button class="btn btn-primary btn-outline" type="submit" id="loginSubmitButton">{{ login.signInAction }}</button>
<button class="btn btn-primary btn-outline" type="submit" id="loginSubmitButton"><i id="busyIndicator" class="hide fa fa-circle-notch fa-spin"></i> {{ login.signInAction }}</button>
</div>
</form>
</div>
@@ -103,6 +103,7 @@ document.getElementById('loginForm').addEventListener('submit', function (event)
document.getElementById('passwordError').classList.add('hide');
document.getElementById('totpError').classList.add('hide');
document.getElementById('internalError').classList.add('hide');
document.getElementById('busyIndicator').classList.remove('hide');
var apiUrl = '<%= submitUrl %>';
console.log('submit', apiUrl);
@@ -123,20 +124,20 @@ document.getElementById('loginForm').addEventListener('submit', function (event)
return res.json(); // we always return objects
}).then(function (data) {
if (res.status === 401) {
if (data.message === 'Username and password does not match') {
document.getElementById('inputPassword').value = '';
document.getElementById('inputPassword').focus();
document.getElementById('passwordError').classList.remove('hide');
return;
} else if (data.message.indexOf('totpToken') !== -1) {
if (data.message.indexOf('totpToken') !== -1) {
document.getElementById('inputTotpToken').value = '';
document.getElementById('inputTotpToken').focus();
document.getElementById('totpError').classList.remove('hide');
return;
} else {
throw new Error('Something went wrong');
document.getElementById('inputPassword').value = '';
document.getElementById('inputPassword').focus();
document.getElementById('passwordError').classList.remove('hide');
}
document.getElementById('busyIndicator').classList.add('hide');
return;
} else if (res.status !== 200) {
document.getElementById('busyIndicator').classList.add('hide');
throw new Error('Something went wrong');
}
@@ -144,6 +145,7 @@ document.getElementById('loginForm').addEventListener('submit', function (event)
else console.log('login success but missing redirectTo in data:', data);
}).catch(function (error) {
document.getElementById('internalError').classList.remove('hide');
document.getElementById('busyIndicator').classList.add('hide');
console.warn(error, res);
});
});
+23 -11
View File
@@ -49,10 +49,10 @@ async function pruneInfraImages() {
// cannot blindly remove all unused images since redis image may not be used
const imageNames = Object.keys(infra.images).map(addon => infra.images[addon]);
const output = safe.child_process.execSync('docker images --digests --format "{{.ID}} {{.Repository}} {{.Tag}} {{.Digest}}"', { encoding: 'utf8' });
if (output === null) {
debug(`Failed to list images ${safe.error.message}`);
throw safe.error;
const [error, output] = await safe(shell.exec('pruneInfraImages', 'docker images --digests --format "{{.ID}} {{.Repository}} {{.Tag}} {{.Digest}}"', { shell: '/bin/bash' }));
if (error) {
debug(`Failed to list images ${error.message}`);
throw error;
}
const lines = output.trim().split('\n');
@@ -69,8 +69,8 @@ async function pruneInfraImages() {
const imageIdToPrune = tag === '<none>' ? `${repo}@${digest}` : `${repo}:${tag}`; // untagged, use digest
console.log(`pruneInfraImages: removing unused image of ${imageName}: ${imageIdToPrune}`);
const result = safe.child_process.execSync(`docker rmi '${imageIdToPrune}'`, { encoding: 'utf8' });
if (result === null) console.log(`Error removing image ${imageIdToPrune}: ${safe.error.mesage}`);
const [error] = await safe(shell.execArgs('pruneInfraImages', 'docker', [ 'rmi', imageIdToPrune ], {}));
if (error) console.log(`Error removing image ${imageIdToPrune}: ${error.mesage}`);
}
}
}
@@ -78,16 +78,16 @@ async function pruneInfraImages() {
async function createDockerNetwork() {
debug('createDockerNetwork: recreating docker network');
await shell.promises.exec('createDockerNetwork', 'docker network rm cloudron || true');
await shell.exec('createDockerNetwork', 'docker network rm -f cloudron', {});
// the --ipv6 option will work even in ipv6 is disabled. fd00 is IPv6 ULA
await shell.promises.exec('createDockerNetwork', `docker network create --subnet=${constants.DOCKER_IPv4_SUBNET} --ip-range=${constants.DOCKER_IPv4_RANGE} --gateway ${constants.DOCKER_IPv4_GATEWAY} --ipv6 --subnet=fd00:c107:d509::/64 cloudron`);
await shell.exec('createDockerNetwork', `docker network create --subnet=${constants.DOCKER_IPv4_SUBNET} --ip-range=${constants.DOCKER_IPv4_RANGE} --gateway ${constants.DOCKER_IPv4_GATEWAY} --ipv6 --subnet=fd00:c107:d509::/64 cloudron`, {});
}
async function removeAllContainers() {
debug('removeAllContainers: removing all containers for infra upgrade');
await shell.promises.exec('removeAllContainers', 'docker ps -qa --filter \'label=isCloudronManaged\' | xargs --no-run-if-empty docker stop');
await shell.promises.exec('removeAllContainers', 'docker ps -qa --filter \'label=isCloudronManaged\' | xargs --no-run-if-empty docker rm -f');
await shell.exec('removeAllContainers', 'docker ps -qa --filter \'label=isCloudronManaged\' | xargs --no-run-if-empty docker stop', { shell: '/bin/bash' });
await shell.exec('removeAllContainers', 'docker ps -qa --filter \'label=isCloudronManaged\' | xargs --no-run-if-empty docker rm -f', { shell: '/bin/bash' });
}
async function markApps(existingInfra, restoreOptions) {
@@ -181,7 +181,7 @@ async function startInfra(restoreOptions) {
}
async function initialize() {
debug('initializing platform');
debug('initialize: start platform');
await database.initialize();
await tasks.stopAllTasks();
@@ -207,6 +207,8 @@ async function initialize() {
async function uninitialize() {
debug('uninitializing platform');
if (await users.isActivated()) await onDeactivated();
await cron.stopJobs();
await dockerProxy.stop();
await tasks.stopAllTasks();
@@ -230,6 +232,16 @@ async function onActivated(restoreOptions) {
// the UI some time to query the dashboard domain in the restore code path
if (!constants.TEST) await timers.setTimeout(30000);
await reverseProxy.writeDefaultConfig({ activated :true });
debug('onActivated: finished');
}
async function onDeactivated() {
debug('onDeactivated: stopping post activation services');
await cron.stopJobs();
await dockerProxy.stop();
await oidc.stop();
}
async function onDashboardLocationChanged(auditSource) {
+2 -2
View File
@@ -24,6 +24,7 @@ const assert = require('assert'),
platform = require('./platform.js'),
reverseProxy = require('./reverseproxy.js'),
safe = require('safetydance'),
shell = require('./shell.js'),
semver = require('semver'),
paths = require('./paths.js'),
system = require('./system.js'),
@@ -57,8 +58,7 @@ function setProgress(task, message) {
async function ensureDhparams() {
if (fs.existsSync(paths.DHPARAMS_FILE)) return;
debug('ensureDhparams: generating dhparams');
const dhparams = safe.child_process.execSync('openssl dhparam -dsaparam 2048');
if (!dhparams) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error);
const dhparams = await shell.exec('ensureDhParams', 'openssl dhparam -dsaparam 2048', {});
if (!safe.fs.writeFileSync(paths.DHPARAMS_FILE, dhparams)) throw new BoxError(BoxError.FS_ERROR, `Could not save dhparams.pem: ${safe.error.message}`);
}
+2 -2
View File
@@ -68,7 +68,7 @@ async function authorizationHeader(req, res, next) {
if (!app.manifest.addons.proxyAuth.basicAuth) return next(); // this is a flag because this allows auth to bypass 2FA
const verifyFunc = credentials.name.indexOf('@') !== -1 ? users.verifyWithEmail : users.verifyWithUsername;
const [verifyError, user] = await safe(verifyFunc(credentials.name, credentials.pass, appId, { relaxedTotpCheck: true }));
const [verifyError, user] = await safe(verifyFunc(credentials.name, credentials.pass, appId, { skipTotpCheck: true }));
if (verifyError) return next(new HttpError(403, 'Invalid username or password' ));
req.user = user;
@@ -166,7 +166,7 @@ async function passwordAuth(req, res, next) {
const { username, password, totpToken } = req.body;
const verifyFunc = username.indexOf('@') !== -1 ? users.verifyWithEmail : users.verifyWithUsername;
const [error, user] = await safe(verifyFunc(username, password, appId, { totpToken }));
const [error, user] = await safe(verifyFunc(username, password, appId, { totpToken, skipTotpCheck: false }));
if (error) return next(new HttpError(403, error.message));
req.user = user;
+32 -36
View File
@@ -60,7 +60,6 @@ const acme2 = require('./acme2.js'),
settings = require('./settings.js'),
shell = require('./shell.js'),
tasks = require('./tasks.js'),
util = require('util'),
validator = require('validator');
const NGINX_APPCONFIG_EJS = fs.readFileSync(__dirname + '/nginxconfig.ejs', { encoding: 'utf8' });
@@ -73,13 +72,13 @@ function nginxLocation(s) {
return `~ ^(?!(${re.slice(1)}))`; // negative regex assertion - https://stackoverflow.com/questions/16302897/nginx-location-not-equal-to-regex
}
function getCertificateDatesSync(cert) {
async function getCertificateDates(cert) {
assert.strictEqual(typeof cert, 'string');
const result = safe.child_process.spawnSync('/usr/bin/openssl', [ 'x509', '-startdate', '-enddate', '-subject', '-noout' ], { input: cert, encoding: 'utf8' });
if (!result) return { startDate: null, endDate: null } ; // some error
const [error, result] = await safe(shell.exec('getCertificateDates', 'openssl x509 -startdate -enddate -subject -noout', { input: cert }));
if (error) return { startDate: null, endDate: null } ; // some error
const lines = result.stdout.trim().split('\n');
const lines = result.trim().split('\n');
const notBefore = lines[0].split('=')[1];
const notBeforeDate = new Date(notBefore);
@@ -104,17 +103,17 @@ async function isOcspEnabled(certFilePath) {
// We used to check for the must-staple in the cert using openssl x509 -text -noout -in ${certFilePath} | grep -q status_request
// however, we cannot set the must-staple because first request to nginx fails because of it's OCSP caching behavior
const result = safe.child_process.execSync(`openssl x509 -in ${certFilePath} -noout -ocsp_uri`, { encoding: 'utf8' });
return result && result.length > 0; // no error and has uri
const [error, result] = await safe(shell.exec('isOscpEnabled', `openssl x509 -in ${certFilePath} -noout -ocsp_uri`, {}));
return !error && result.length > 0; // no error and has uri
}
// checks if the certificate matches the options provided by user (like wildcard, le-staging etc)
function providerMatchesSync(domainObject, cert) {
async function providerMatches(domainObject, cert) {
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof cert, 'string');
const subjectAndIssuer = safe.child_process.execSync('/usr/bin/openssl x509 -noout -subject -issuer', { encoding: 'utf8', input: cert });
if (!subjectAndIssuer) return false; // something bad happenned
const [error, subjectAndIssuer] = await safe(shell.exec('providerMatches', 'openssl x509 -noout -subject -issuer', { input: cert }));
if (error) return false; // something bad happenned
const subject = subjectAndIssuer.match(/^subject=(.*)$/m)[1];
const domain = subject.substr(subject.indexOf('=') + 1).trim(); // subject can be /CN=, CN=, CN = and other forms
@@ -131,7 +130,7 @@ function providerMatchesSync(domainObject, cert) {
const mismatch = issuerMismatch || wildcardMismatch;
debug(`providerMatchesSync: subject=${subject} domain=${domain} issuer=${issuer} `
debug(`providerMatches: subject=${subject} domain=${domain} issuer=${issuer} `
+ `wildcard=${isWildcardCert}/${wildcard} prod=${isLetsEncryptProd}/${prod} `
+ `issuerMismatch=${issuerMismatch} wildcardMismatch=${wildcardMismatch} match=${!mismatch}`);
@@ -140,7 +139,7 @@ function providerMatchesSync(domainObject, cert) {
// note: https://tools.ietf.org/html/rfc4346#section-7.4.2 (certificate_list) requires that the
// servers certificate appears first (and not the intermediate cert)
function validateCertificate(subdomain, domain, certificate) {
async function validateCertificate(subdomain, domain, certificate) {
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof domain, 'string');
assert(certificate && typeof certificate, 'object');
@@ -148,29 +147,27 @@ function validateCertificate(subdomain, domain, certificate) {
const { cert, key } = certificate;
// check for empty cert and key strings
if (!cert && key) return new BoxError(BoxError.BAD_FIELD, 'missing cert');
if (cert && !key) return new BoxError(BoxError.BAD_FIELD, 'missing key');
if (!cert && key) throw new BoxError(BoxError.BAD_FIELD, 'missing cert');
if (cert && !key) throw new BoxError(BoxError.BAD_FIELD, 'missing key');
// -checkhost checks for SAN or CN exclusively. SAN takes precedence and if present, ignores the CN.
const fqdn = dns.fqdn(subdomain, domain);
let result = safe.child_process.execSync(`openssl x509 -noout -checkhost "${fqdn}"`, { encoding: 'utf8', input: cert });
if (result === null) return new BoxError(BoxError.BAD_FIELD, 'Unable to get certificate subject:' + safe.error.message);
if (result.indexOf('does match certificate') === -1) return new BoxError(BoxError.BAD_FIELD, `Certificate is not valid for this domain. Expecting ${fqdn}`);
const [checkHostError, checkHostOutput] = await safe(shell.exec('validateCertificate', `openssl x509 -noout -checkhost ${fqdn}`, { input: cert }));
if (checkHostError) throw new BoxError(BoxError.BAD_FIELD, 'Could not validate certificate');
if (checkHostOutput.indexOf('does match certificate') === -1) throw new BoxError(BoxError.BAD_FIELD, `Certificate is not valid for this domain. Expecting ${fqdn}`);
// check if public key in the cert and private key matches. pkey below works for RSA and ECDSA keys
const pubKeyFromCert = safe.child_process.execSync('openssl x509 -noout -pubkey', { encoding: 'utf8', input: cert });
if (pubKeyFromCert === null) return new BoxError(BoxError.BAD_FIELD, `Unable to get public key from certificate: ${safe.error.message}`);
const [pubKeyError1, pubKeyFromCert] = await safe(shell.exec('validateCertificate', 'openssl x509 -noout -pubkey', { input: cert }));
if (pubKeyError1) throw new BoxError(BoxError.BAD_FIELD, 'Could not get public key from cert');
const [pubKeyError2, pubKeyFromKey] = await safe(shell.exec('validateCertificate', 'openssl pkey -pubout', { input: key }));
if (pubKeyError2) throw new BoxError(BoxError.BAD_FIELD, 'Could not get public key from private key');
const pubKeyFromKey = safe.child_process.execSync('openssl pkey -pubout', { encoding: 'utf8', input: key });
if (pubKeyFromKey === null) return new BoxError(BoxError.BAD_FIELD, `Unable to get public key from private key: ${safe.error.message}`);
if (pubKeyFromCert !== pubKeyFromKey) return new BoxError(BoxError.BAD_FIELD, 'Public key does not match the certificate.');
if (pubKeyFromCert !== pubKeyFromKey) throw new BoxError(BoxError.BAD_FIELD, 'Public key does not match the certificate.');
// check expiration
result = safe.child_process.execSync('openssl x509 -checkend 0', { encoding: 'utf8', input: cert });
if (!result) return new BoxError(BoxError.BAD_FIELD, 'Certificate has expired.');
const [error] = await safe(shell.exec('validateCertificate', 'openssl x509 -checkend 0', { input: cert }));
if (error) throw new BoxError(BoxError.BAD_FIELD, 'Certificate has expired');
return null;
}
@@ -209,8 +206,8 @@ async function generateFallbackCertificate(domain) {
const configFile = path.join(os.tmpdir(), 'openssl-' + crypto.randomBytes(4).readUInt32LE(0) + '.conf');
safe.fs.writeFileSync(configFile, opensslConfWithSan, 'utf8');
// the days field is chosen to be less than 825 days per apple requirement (https://support.apple.com/en-us/HT210176)
const certCommand = util.format(`openssl req -x509 -newkey rsa:2048 -keyout ${keyFilePath} -out ${certFilePath} -days 800 -subj /CN=*.${cn} -extensions SAN -config ${configFile} -nodes`);
if (!safe.child_process.execSync(certCommand)) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error.message);
const certCommand = `openssl req -x509 -newkey rsa:2048 -keyout ${keyFilePath} -out ${certFilePath} -days 800 -subj /CN=*.${cn} -extensions SAN -config ${configFile} -nodes`;
await shell.exec('generateFallbackCertificate', certCommand, {});
safe.fs.unlinkSync(configFile);
const cert = safe.fs.readFileSync(certFilePath, 'utf8');
@@ -267,11 +264,11 @@ function getAcmeCertificateNameSync(fqdn, domainObject) {
}
}
function needsRenewalSync(cert, options) {
async function needsRenewal(cert, options) {
assert.strictEqual(typeof cert, 'string');
assert.strictEqual(typeof options, 'object');
const { startDate, endDate } = getCertificateDatesSync(cert);
const { startDate, endDate } = await getCertificateDates(cert);
const now = new Date();
let isExpiring;
@@ -433,7 +430,9 @@ async function ensureCertificate(location, options, auditSource) {
const cert = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.cert`);
if (key && cert) {
if (providerMatchesSync(domainObject, cert) && !needsRenewalSync(cert, options)) {
const sameProvider = await providerMatches(domainObject, cert);
const outdated = await needsRenewal(cert, options);
if (sameProvider && !outdated) {
debug(`ensureCertificate: ${fqdn} acme cert exists and is up to date`);
return;
}
@@ -629,7 +628,7 @@ async function cleanupCerts(locations, auditSource, progressCallback) {
if (certNamesInUse.has(certName)) continue;
const cert = await blobs.getString(certId);
const { endDate } = getCertificateDatesSync(cert);
const { endDate } = await getCertificateDates(cert);
if (!endDate) continue; // some error
if (now - endDate >= (60 * 60 * 24 * 30 * 6 * 1000)) { // expired 6 months ago and not in use
@@ -736,10 +735,7 @@ async function writeDefaultConfig(options) {
const cn = 'cloudron-' + (new Date()).toISOString(); // randomize date a bit to keep firefox happy
// the days field is chosen to be less than 825 days per apple requirement (https://support.apple.com/en-us/HT210176)
if (!safe.child_process.execSync(`openssl req -x509 -newkey rsa:2048 -keyout ${keyFilePath} -out ${certFilePath} -days 800 -subj /CN=${cn} -nodes`)) {
debug(`writeDefaultConfig: could not generate certificate: ${safe.error.message}`);
throw new BoxError(BoxError.OPENSSL_ERROR, safe.error);
}
await shell.exec('writeDefaultConfig', `openssl req -x509 -newkey rsa:2048 -keyout ${keyFilePath} -out ${certFilePath} -days 800 -subj /CN=${cn} -nodes`, {});
}
const data = {
+1 -1
View File
@@ -27,7 +27,7 @@ async function passwordAuth(req, res, next) {
const verifyFunc = username.indexOf('@') === -1 ? users.verifyWithUsername : users.verifyWithEmail;
let [error, user] = await safe(verifyFunc(username, password, users.AP_WEBADMIN, { totpToken }));
let [error, user] = await safe(verifyFunc(username, password, users.AP_WEBADMIN, { totpToken, skipTotpCheck: false }));
if (error && error.reason === BoxError.INVALID_CREDENTIALS) return next(new HttpError(401, error.message));
if (error && error.reason === BoxError.NOT_FOUND) return next(new HttpError(401, 'Unauthorized'));
if (error) return next(new HttpError(500, error));
+4 -4
View File
@@ -669,8 +669,6 @@ async function getLogStream(req, res, next) {
const lines = 'lines' in req.query ? parseInt(req.query.lines, 10) : 10; // we ignore last-event-id
if (isNaN(lines)) return next(new HttpError(400, 'lines must be a valid number'));
function sse(id, data) { return 'id: ' + id + '\ndata: ' + data + '\n\n'; }
if (req.headers.accept !== 'text/event-stream') return next(new HttpError(400, 'This API call requires EventStream'));
const options = {
@@ -690,10 +688,11 @@ async function getLogStream(req, res, next) {
'Access-Control-Allow-Origin': '*'
});
res.write('retry: 3000\n');
res.on('close', logStream.close);
res.on('close', () => logStream.destroy());
logStream.on('data', function (data) {
const obj = JSON.parse(data);
res.write(sse(obj.realtimeTimestamp, JSON.stringify(obj))); // send timestamp as id
const sse = `data: ${JSON.stringify(obj)}\n\n`;
res.write(sse);
});
logStream.on('end', res.end.bind(res));
logStream.on('error', res.end.bind(res, null));
@@ -720,6 +719,7 @@ async function getLogs(req, res, next) {
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no' // disable nginx buffering
});
res.on('close', () => logStream.destroy());
logStream.pipe(res);
}

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