diff --git a/bin/quickstart.sh b/bin/quickstart.sh index fd6226b6..6bf37656 100755 --- a/bin/quickstart.sh +++ b/bin/quickstart.sh @@ -1,105 +1,104 @@ #!/bin/bash set -e function die() { echo "$1" exit 1 } rpm -qv composer >/dev/null 2>&1 || \ test ! -z "$(which composer 2>/dev/null)" || \ die "Is composer installed?" rpm -qv docker-compose >/dev/null 2>&1 || \ test ! -z "$(which docker-compose 2>/dev/null)" || \ die "Is docker-compose installed?" rpm -qv npm >/dev/null 2>&1 || \ test ! -z "$(which npm 2>/dev/null)" || \ die "Is npm installed?" rpm -qv php >/dev/null 2>&1 || \ test ! -z "$(which php 2>/dev/null)" || \ die "Is php installed?" rpm -qv php-ldap >/dev/null 2>&1 || \ test ! -z "$(php --ini | grep ldap)" || \ die "Is php-ldap installed?" rpm -qv php-mysqlnd >/dev/null 2>&1 || \ test ! -z "$(php --ini | grep mysql)" || \ die "Is php-mysqlnd installed?" test ! -z "$(php --modules | grep swoole)" || \ die "Is swoole installed?" test ! -z "$(grep 'systemd.unified_cgroup_hierarchy=0' /proc/cmdline)" || \ die "systemd containers only work with cgroupv1 (use 'grubby --update-kernel=ALL --args=\"systemd.unified_cgroup_hierarchy=0\"' and a reboot to fix)" base_dir=$(dirname $(dirname $0)) # Always reset .env with .env.example cp src/.env.example src/.env -if [ -f "src/.env.local" ]; then +if [ -f "src/env.local" ]; then # Ensure there's a line ending echo "" >> src/.env - cat src/.env.local >> src/.env + cat src/env.local >> src/.env fi docker pull docker.io/kolab/centos7:latest docker-compose down --remove-orphans docker-compose build coturn kolab mariadb openvidu kurento-media-server pdns-sql proxy redis nginx bin/regen-certs docker-compose up -d coturn kolab mariadb openvidu kurento-media-server pdns-sql proxy redis pushd ${base_dir}/src/ rm -rf vendor/ composer.lock php -dmemory_limit=-1 $(which composer) install npm install find bootstrap/cache/ -type f ! -name ".gitignore" -delete ./artisan key:generate ./artisan clear-compiled ./artisan cache:clear ./artisan horizon:install if [ ! -f storage/oauth-public.key -o ! -f storage/oauth-private.key ]; then ./artisan passport:keys --force fi cat >> .env << EOF PASSPORT_PRIVATE_KEY="$(cat storage/oauth-private.key)" PASSPORT_PUBLIC_KEY="$(cat storage/oauth-public.key)" EOF if rpm -qv chromium 2>/dev/null; then chver=$(rpmquery --queryformat="%{VERSION}" chromium | awk -F'.' '{print $1}') ./artisan dusk:chrome-driver ${chver} fi if [ ! -f 'resources/countries.php' ]; then ./artisan data:countries fi npm run dev popd docker-compose up -d nginx pushd ${base_dir}/src/ rm -rf database/database.sqlite ./artisan db:ping --wait php -dmemory_limit=512M ./artisan migrate:refresh --seed ./artisan data:import || : -./artisan swoole:http stop >/dev/null 2>&1 || : -SWOOLE_HTTP_DAEMONIZE=true ./artisan swoole:http start +./artisan octane:stop >/dev/null 2>&1 || : +nohup ./artisan octane:start --host=$(grep OCTANE_HTTP_HOST .env | tail -n1 | sed "s/OCTANE_HTTP_HOST=//") > octane.out & ./artisan horizon:terminate >/dev/null 2>&1 || : -nohup ./artisan horizon >/dev/null 2>&1 & +nohup ./artisan horizon > horizon.out & popd - diff --git a/docker-compose.yml b/docker-compose.yml index f235fac7..543a72ab 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,210 +1,210 @@ version: '3' services: coturn: container_name: kolab-coturn healthcheck: interval: 10s test: "kill -0 $$(cat /tmp/turnserver.pid)" timeout: 5s retries: 30 environment: - DB_NAME=${OPENVIDU_COTURN_REDIS_DATABASE} - DB_PASSWORD=${OPENVIDU_COTURN_REDIS_PASSWORD} - REDIS_IP=${OPENVIDU_COTURN_REDIS_IP} - TURN_PUBLIC_IP=${OPENVIDU_COTURN_IP} - TURN_LISTEN_PORT=3478 hostname: sturn.mgmt.com image: openvidu/openvidu-coturn:1.0.0 network_mode: host restart: on-failure tty: true kolab: build: context: ./docker/kolab/ container_name: kolab depends_on: - mariadb extra_hosts: - "kolab.mgmt.com:127.0.0.1" environment: - DB_HOST=${DB_HOST} - DB_ROOT_PASSWORD=Welcome2KolabSystems healthcheck: interval: 10s test: test -f /tmp/kolab-init.done timeout: 5s retries: 30 hostname: kolab.mgmt.com image: kolab network_mode: host tmpfs: - /run - /tmp - /var/run - /var/tmp tty: true volumes: - ./ext/:/src/:ro - /etc/letsencrypt/:/etc/letsencrypt/:ro - ./docker/certs/ca.cert:/etc/pki/tls/certs/ca.cert:ro - ./docker/certs/ca.cert:/etc/pki/ca-trust/source/anchors/ca.cert:ro - ./docker/certs/kolab.hosted.com.cert:/etc/pki/tls/certs/kolab.hosted.com.cert - ./docker/certs/kolab.hosted.com.key:/etc/pki/tls/certs/kolab.hosted.com.key - ./docker/certs/kolab.mgmt.com.cert:/etc/pki/tls/certs/kolab.mgmt.com.cert - ./docker/certs/kolab.mgmt.com.key:/etc/pki/tls/certs/kolab.mgmt.com.key - ./docker/kolab/utils:/root/utils:ro - ./src/.env:/.dockerenv:ro - /sys/fs/cgroup:/sys/fs/cgroup:ro kurento-media-server: build: context: ./docker/kurento-media-server/ container_name: kolab-kurento-media-server environment: - GST_DEBUG=3,Kurento*:4,kms*:4,sdp*:4,webrtc*:4,*rtpendpoint:4,rtp*handler:4,rtpsynchronizer:4,agnosticbin:4 hostname: kurento-media-server.hosted.com image: apheleia/kurento-media-server:6.15.0 network_mode: host mariadb: container_name: kolab-mariadb environment: MYSQL_ROOT_PASSWORD: Welcome2KolabSystems TZ: "+02:00" healthcheck: interval: 10s test: test -e /var/run/mysqld/mysqld.sock timeout: 5s retries: 30 image: mariadb network_mode: host nginx: build: context: ./docker/nginx/ args: APP_WEBSITE_DOMAIN: ${APP_WEBSITE_DOMAIN:?err} healthcheck: interval: 10s test: ["CMD-SHELL", "curl -so /dev/null http://localhost/ || exit 1"] timeout: 5s retries: 30 container_name: kolab-nginx hostname: nginx.hosted.com image: kolab-nginx network_mode: host tmpfs: - /run - /tmp - /var/run - /var/tmp tty: true volumes: - ./docker/certs/imap.hosted.com.cert:/etc/pki/tls/certs/imap.hosted.com.cert - ./docker/certs/imap.hosted.com.key:/etc/pki/tls/private/imap.hosted.com.key openvidu: build: context: ./docker/openvidu/ container_name: kolab-openvidu depends_on: - kurento-media-server environment: - APP_DOMAIN=${APP_DOMAIN} - CERTIFICATE_TYPE=letsencrypt - COTURN_IP=${OPENVIDU_COTURN_IP} - COTURN_REDIS_DBNAME=${OPENVIDU_COTURN_REDIS_DATABASE} - COTURN_REDIS_PASSWORD=${OPENVIDU_COTURN_REDIS_PASSWORD} - COTURN_REDIS_IP=${OPENVIDU_COTURN_REDIS_IP} - DOMAIN_OR_PUBLIC_IP=${OPENVIDU_PUBLIC_IP} - SERVER_PORT=${OPENVIDU_SERVER_PORT} - KMS_STUN_IP=${OPENVIDU_COTURN_IP} - KMS_STUN_PORT=3478 - KMS_URIS=["ws://localhost:8888/kurento", "ws://localhost:8889/kurento"] - OPENVIDU_SECRET=${OPENVIDU_API_PASSWORD} - OPENVIDU_WEBHOOK=${OPENVIDU_WEBHOOK} - OPENVIDU_WEBHOOK_ENDPOINT=${OPENVIDU_WEBHOOK_ENDPOINT} - SERVER_SSL_ENABLED=false hostname: openvidu.hosted.com image: apheleia/openvidu:2.18.0 network_mode: host tmpfs: - /run - /tmp - /var/run - /var/tmp tty: true volumes: - /etc/letsencrypt/:/etc/letsencrypt/:ro pdns-sql: build: context: ./docker/pdns-sql/ container_name: kolab-pdns-sql depends_on: - mariadb hostname: pdns-sql image: apheleia/kolab-pdns-sql network_mode: host tmpfs: - /run - /tmp - /var/run - /var/tmp tty: true volumes: - /sys/fs/cgroup:/sys/fs/cgroup:ro proxy: build: context: ./docker/proxy/ healthcheck: interval: 10s test: ["CMD-SHELL", "curl -so /dev/null http://localhost/ || exit 1"] timeout: 5s retries: 30 container_name: kolab-proxy hostname: kanarip.internet-box.ch image: kolab-proxy network_mode: host tmpfs: - /run - /tmp - /var/run - /var/tmp tty: true volumes: - ./docker/certs/:/etc/certs/:ro - /etc/letsencrypt/:/etc/letsencrypt/:ro - /sys/fs/cgroup:/sys/fs/cgroup:ro redis: build: context: ./docker/redis/ healthcheck: interval: 10s test: "redis-cli ping || exit 1" timeout: 5s retries: 30 container_name: kolab-redis hostname: redis image: redis network_mode: host volumes: - ./docker/redis/redis.conf:/usr/local/etc/redis/redis.conf:ro swoole: build: context: ./docker/swoole/ container_name: kolab-swoole - image: apheleia/swoole:4.6.x + image: apheleia/swoole:4.8.x worker: build: context: ./docker/worker/ container_name: kolab-worker depends_on: - kolab hostname: worker image: kolab-worker network_mode: host tmpfs: - /run - /tmp - /var/run - /var/tmp tty: true volumes: - ./src:/home/worker/src.orig:ro - /sys/fs/cgroup:/sys/fs/cgroup:ro diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile index 3a4459f4..a0069aac 100644 --- a/docker/nginx/Dockerfile +++ b/docker/nginx/Dockerfile @@ -1,25 +1,25 @@ -FROM fedora:34 +FROM fedora:35 MAINTAINER Jeroen van Meeuwen ENV container docker RUN dnf -y install \ --setopt 'tsflags=nodocs' \ nginx \ nginx-mod-mail && \ dnf clean all COPY nginx.conf /etc/nginx/nginx.conf ARG APP_WEBSITE_DOMAIN RUN sed -i -r -e "s|^.*auth_http_header.*$| auth_http_header Host services.$APP_WEBSITE_DOMAIN;|g" /etc/nginx/nginx.conf # Forward request logs to Docker log collector RUN ln -sf /dev/stdout /var/log/nginx/access.log \ && ln -sf /dev/stderr /var/log/nginx/error.log STOPSIGNAL SIGTERM CMD ["nginx", "-g", "daemon off;"] EXPOSE 110/tcp 143/tcp 993/tcp 995/tcp diff --git a/docker/proxy/Dockerfile b/docker/proxy/Dockerfile index 592e1e2a..61281386 100644 --- a/docker/proxy/Dockerfile +++ b/docker/proxy/Dockerfile @@ -1,46 +1,46 @@ -FROM fedora:31 +FROM fedora:35 MAINTAINER Jeroen van Meeuwen ENV container docker ENV SYSTEMD_PAGER='' RUN dnf -y install \ --setopt 'tsflags=nodocs' \ bash-completion \ bind-utils \ certbot \ curl \ dhcp-client \ git \ iproute \ iptraf-ng \ iputils \ less \ lsof \ mtr \ net-tools \ NetworkManager \ NetworkManager-tui \ network-scripts \ nginx \ nmap-ncat \ openssh-clients \ openssh-server \ procps-ng \ python3-certbot-nginx \ strace \ systemd-udev \ tcpdump \ telnet \ traceroute \ vim-enhanced \ wget && \ dnf clean all COPY rootfs/ / RUN systemctl enable nginx CMD ["/lib/systemd/systemd", "--system"] ENTRYPOINT "/lib/systemd/systemd" diff --git a/docker/swoole/Dockerfile b/docker/swoole/Dockerfile index 20612ae2..9d64eeda 100644 --- a/docker/swoole/Dockerfile +++ b/docker/swoole/Dockerfile @@ -1,67 +1,68 @@ -FROM fedora:34 +FROM fedora:35 MAINTAINER Jeroen van Meeuwen -ARG SWOOLE_VERSION=v4.6.7 +ARG SWOOLE_VERSION=v4.8.7 ENV HOME=/opt/app-root/src LABEL io.k8s.description="Platform for serving PHP applications under Swoole" \ io.k8s.display-name="Swoole ${SWOOLE_VERSION}" \ io.openshift.expose-services="8000:http" \ io.openshift.tags="builder,php,swoole" +RUN dnf -y update RUN dnf -y install \ composer \ diffutils \ file \ git \ make \ npm \ openssl-devel \ patch \ php-cli \ php-common \ php-devel \ php-ldap \ php-opcache \ php-pecl-apcu \ php-mysqlnd \ re2c \ wget && \ git clone https://github.com/swoole/swoole-src.git/ /swoole-src.git/ && \ cd /swoole-src.git/ && \ git checkout -f ${SWOOLE_VERSION} && \ git clean -d -f -x && \ phpize --clean && \ phpize && \ ./configure \ --enable-sockets \ --disable-mysqlnd \ --enable-http2 \ --enable-openssl && \ make -j4 && \ make install && \ cd / && \ rm -rf /swoole-src.git/ && \ dnf -y remove \ diffutils \ file \ make \ openssl-devel \ php-devel \ re2c && \ dnf clean all && \ echo "extension=swoole.so" >> /etc/php.d/swoole.ini && \ php -m 2>&1 | grep -q swoole RUN id default || (groupadd -g 1001 default && useradd -d /opt/app-root/ -u 1001 -g 1001 default) USER 1001 WORKDIR ${HOME} COPY /rootfs / EXPOSE 8000 CMD [ "/usr/local/bin/usage" ] diff --git a/docker/swoole/rootfs/usr/local/bin/run-container b/docker/swoole/rootfs/usr/local/bin/run-container index 8e6df328..f565ce15 100755 --- a/docker/swoole/rootfs/usr/local/bin/run-container +++ b/docker/swoole/rootfs/usr/local/bin/run-container @@ -1,42 +1,42 @@ #!/bin/bash set -x set -e if [ -z "$@" ]; then cd $(basename ${GIT_URI} .git) if [ ! -z "${APP_SRC}" ]; then cd ${APP_SRC} fi if [ ! -f ".env" -a -f ".env.example" ]; then mv .env.example .env fi if [ -z "${APP_KEY}" ]; then ./artisan key:generate unset APP_KEY fi if [ -z "${JWT_SECRET}" ]; then ./artisan jwt:secret -f fi ./artisan clear-compiled # This should not occur in production #./artisan cache:clear # A standalone environment doesn't have anything to ping #timeout 10m ./artisan db:ping --wait ./artisan migrate env - exec ./artisan swoole:http start + exec ./artisan octane:start else exec $@ fi diff --git a/src/.env.example b/src/.env.example index f82c26b8..ecf69e41 100644 --- a/src/.env.example +++ b/src/.env.example @@ -1,180 +1,176 @@ APP_NAME=Kolab APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://127.0.0.1:8000 #APP_PASSPHRASE= APP_PUBLIC_URL= APP_DOMAIN=kolabnow.com APP_WEBSITE_DOMAIN=kolabnow.com APP_THEME=default APP_TENANT_ID=5 APP_LOCALE=en APP_LOCALES= APP_WITH_ADMIN=1 APP_WITH_RESELLER=1 APP_WITH_SERVICES=1 APP_HEADER_CSP="connect-src 'self'; child-src 'self'; font-src 'self'; form-action 'self' data:; frame-ancestors 'self'; img-src blob: data: 'self' *; media-src 'self'; object-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-eval' 'unsafe-inline'; default-src 'self';" APP_HEADER_XFO=sameorigin SIGNUP_LIMIT_EMAIL=0 SIGNUP_LIMIT_IP=0 ASSET_URL=http://127.0.0.1:8000 WEBMAIL_URL=/apps SUPPORT_URL=/support SUPPORT_EMAIL= LOG_CHANNEL=stack LOG_SLOW_REQUESTS=5 +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug DB_CONNECTION=mysql DB_DATABASE=kolabdev DB_HOST=127.0.0.1 DB_PASSWORD=kolab DB_PORT=3306 DB_USERNAME=kolabdev BROADCAST_DRIVER=redis CACHE_DRIVER=redis QUEUE_CONNECTION=redis SESSION_DRIVER=file SESSION_LIFETIME=120 OPENEXCHANGERATES_API_KEY="from openexchangerates.org" MFA_DSN=mysql://roundcube:Welcome2KolabSystems@127.0.0.1/roundcube MFA_TOTP_DIGITS=6 MFA_TOTP_INTERVAL=30 MFA_TOTP_DIGEST=sha1 IMAP_URI=ssl://127.0.0.1:11993 IMAP_ADMIN_LOGIN=cyrus-admin IMAP_ADMIN_PASSWORD=Welcome2KolabSystems IMAP_VERIFY_HOST=false IMAP_VERIFY_PEER=false LDAP_BASE_DN="dc=mgmt,dc=com" LDAP_DOMAIN_BASE_DN="ou=Domains,dc=mgmt,dc=com" LDAP_HOSTS=127.0.0.1 LDAP_PORT=389 LDAP_SERVICE_BIND_DN="uid=kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_SERVICE_BIND_PW="Welcome2KolabSystems" LDAP_USE_SSL=false LDAP_USE_TLS=false # Administrative LDAP_ADMIN_BIND_DN="cn=Directory Manager" LDAP_ADMIN_BIND_PW="Welcome2KolabSystems" LDAP_ADMIN_ROOT_DN="dc=mgmt,dc=com" # Hosted (public registration) LDAP_HOSTED_BIND_DN="uid=hosted-kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_HOSTED_BIND_PW="Welcome2KolabSystems" LDAP_HOSTED_ROOT_DN="dc=hosted,dc=com" OPENVIDU_API_PASSWORD=MY_SECRET OPENVIDU_API_URL=http://localhost:8080/api/ OPENVIDU_API_USERNAME=OPENVIDUAPP OPENVIDU_API_VERIFY_TLS=true OPENVIDU_COTURN_IP=127.0.0.1 OPENVIDU_COTURN_REDIS_DATABASE=2 OPENVIDU_COTURN_REDIS_IP=127.0.0.1 OPENVIDU_COTURN_REDIS_PASSWORD=turn # Used as COTURN_IP, TURN_PUBLIC_IP, for KMS_TURN_URL OPENVIDU_PUBLIC_IP=127.0.0.1 OPENVIDU_PUBLIC_PORT=3478 OPENVIDU_SERVER_PORT=8080 OPENVIDU_WEBHOOK=true OPENVIDU_WEBHOOK_ENDPOINT=http://127.0.0.1:8000/webhooks/meet/openvidu # "CDR" events, see https://docs.openvidu.io/en/2.13.0/reference-docs/openvidu-server-cdr/ #OPENVIDU_WEBHOOK_EVENTS=[sessionCreated,sessionDestroyed,participantJoined,participantLeft,webrtcConnectionCreated,webrtcConnectionDestroyed,recordingStatusChanged,filterEventDispatched,mediaNodeStatusChanged] #OPENVIDU_WEBHOOK_HEADERS=[\"Authorization:\ Basic\ SOMETHING\"] PGP_ENABLED= PGP_BINARY= PGP_AGENT= PGP_GPGCONF= PGP_LENGTH= # Set these to IP addresses you serve WOAT with. # Have the domain owner point _woat. NS RRs refer to ns0{1,2}. WOAT_NS1=ns01.domain.tld WOAT_NS2=ns02.domain.tld REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 -SWOOLE_HOT_RELOAD_ENABLE=true -SWOOLE_HTTP_ACCESS_LOG=true -SWOOLE_HTTP_HOST=127.0.0.1 -SWOOLE_HTTP_PORT=8000 -SWOOLE_HTTP_REACTOR_NUM=1 -SWOOLE_HTTP_WEBSOCKET=true -SWOOLE_HTTP_WORKER_NUM=1 -SWOOLE_OB_OUTPUT=true +OCTANE_HTTP_HOST=127.0.0.1 PAYMENT_PROVIDER= MOLLIE_KEY= STRIPE_KEY= STRIPE_PUBLIC_KEY= STRIPE_WEBHOOK_SECRET= -MAIL_DRIVER=smtp +MAIL_MAILER=smtp MAIL_HOST=smtp.mailtrap.io MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS="noreply@example.com" MAIL_FROM_NAME="Example.com" MAIL_REPLYTO_ADDRESS="replyto@example.com" MAIL_REPLYTO_NAME=null DNS_TTL=3600 DNS_SPF="v=spf1 mx -all" DNS_STATIC="%s. MX 10 ext-mx01.mykolab.com." DNS_COPY_FROM=null AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false PUSHER_APP_ID= PUSHER_APP_KEY= PUSHER_APP_SECRET= PUSHER_APP_CLUSTER=mt1 MIX_ASSET_PATH='/' MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" # Generate with ./artisan passport:client --password #PASSPORT_PROXY_OAUTH_CLIENT_ID= #PASSPORT_PROXY_OAUTH_CLIENT_SECRET= PASSPORT_PRIVATE_KEY= PASSPORT_PUBLIC_KEY= PASSWORD_POLICY= COMPANY_NAME= COMPANY_ADDRESS= COMPANY_DETAILS= COMPANY_EMAIL= COMPANY_LOGO= COMPANY_FOOTER= VAT_COUNTRIES=CH,LI VAT_RATE=7.7 KB_ACCOUNT_DELETE= KB_ACCOUNT_SUSPENDED= diff --git a/src/.gitattributes b/src/.gitattributes index 967315dd..88d03b3b 100644 --- a/src/.gitattributes +++ b/src/.gitattributes @@ -1,5 +1,9 @@ * text=auto -*.css linguist-vendored -*.scss linguist-vendored -*.js linguist-vendored + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + CHANGELOG.md export-ignore diff --git a/src/.styleci.yml b/src/.styleci.yml index 1db61d96..79f63b44 100644 --- a/src/.styleci.yml +++ b/src/.styleci.yml @@ -1,13 +1,12 @@ php: preset: laravel disabled: - - unused_use + - no_unused_imports finder: not-name: - index.php - - server.php js: finder: not-name: - webpack.mix.js css: true diff --git a/src/app/Auth/LDAPUserProvider.php b/src/app/Auth/LDAPUserProvider.php deleted file mode 100644 index 3ee1d3e7..00000000 --- a/src/app/Auth/LDAPUserProvider.php +++ /dev/null @@ -1,54 +0,0 @@ -get(); - - $count = $entries->count(); - - if ($count == 1) { - return $entries->first(); - } - - if ($count > 1) { - \Log::warning("Multiple entries for {$credentials['email']}"); - } else { - \Log::warning("No entries for {$credentials['email']}"); - } - - return null; - } - - /** - * Validate the credentials for a user. - * - * @param Authenticatable $user The user. - * @param array $credentials The credentials. - * - * @return bool - */ - public function validateCredentials(Authenticatable $user, array $credentials): bool - { - return $user->validateCredentials($credentials['email'], $credentials['password']); - } -} diff --git a/src/app/Auth/SecondFactor.php b/src/app/Auth/SecondFactor.php index ca066d6a..ed83377a 100644 --- a/src/app/Auth/SecondFactor.php +++ b/src/app/Auth/SecondFactor.php @@ -1,321 +1,322 @@ [], ]; /** * Class constructor * * @param \App\User $user User object */ public function __construct($user) { $this->user = $user; parent::__construct(); } /** * Validate 2-factor authentication code * * @param string $secondfactor The 2-factor authentication code. * * @throws \Exception on validation failure */ public function validate($secondfactor): void { // get list of configured authentication factors $factors = $this->factors(); // do nothing if no factors configured if (empty($factors)) { return; } if (empty($secondfactor) || !is_string($secondfactor)) { throw new \Exception(\trans('validation.2fareq')); } // try to verify each configured factor foreach ($factors as $factor) { // verify the submitted code if ($this->verify($factor, $secondfactor)) { return; } } + throw new \Exception(\trans('validation.2fainvalid')); } /** * Validate 2-factor authentication code * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse|null */ public function requestHandler(\Illuminate\Http\Request $request) { try { $this->validate($request->secondfactor); } catch (\Exception $e) { $errors = ['secondfactor' => $e->getMessage()]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } return null; } /** * Remove all configured 2FA methods for the current user * * @return bool True on success, False otherwise */ public function removeFactors(): bool { $this->cache = []; $prefs = []; $prefs[$this->key2property('blob')] = null; $prefs[$this->key2property('factors')] = null; return $this->savePrefs($prefs); } /** * Returns a list of 2nd factor methods configured for the user */ public function factors(): array { // First check if the user has the 2FA SKU if ($this->user->hasSku('2fa')) { $factors = (array) $this->enumerate(); $factors = array_unique($factors); return $factors; } return []; } /** * Helper method to verify the given method/code tuple * * @param string $factor Factor identifier (:) * @param string $code Authentication code * * @return bool True on successful validation */ protected function verify($factor, $code): bool { $driver = $this->getDriver($factor); return $driver->verify($code, time()); } /** * Load driver class for the given authentication factor * * @param string $factor Factor identifier (:) * * @return \Kolab2FA\Driver\Base */ protected function getDriver(string $factor) { list($method) = explode(':', $factor, 2); $config = \config('2fa.' . $method, []); $driver = \Kolab2FA\Driver\Base::factory($factor, $config); // configure driver $driver->storage = $this; $driver->username = $this->user->email; return $driver; } /** * Helper for seeding a Roundcube account with 2FA setup * for testing. * * @param string $email Email address */ public static function seed(string $email): void { $config = [ 'kolab_2fa_blob' => [ 'totp:8132a46b1f741f88de25f47e' => [ 'label' => 'Mobile app (TOTP)', 'created' => 1584573552, 'secret' => 'UAF477LDHZNWVLNA', 'active' => true, ], // 'dummy:dummy' => [ // 'active' => true, // ], ], 'kolab_2fa_factors' => [ 'totp:8132a46b1f741f88de25f47e', // 'dummy:dummy', ] ]; self::dbh()->table('users')->updateOrInsert( ['username' => $email, 'mail_host' => '127.0.0.1'], ['preferences' => serialize($config)] ); } /** * Helper for generating current TOTP code for a test user * * @param string $email Email address * * @return string Generated code */ public static function code(string $email): string { $sf = new self(\App\User::where('email', $email)->first()); $driver = $sf->getDriver('totp:8132a46b1f741f88de25f47e'); return (string) $driver->get_code(); } //****************************************************** // Methods required by Kolab2FA Storage Base //****************************************************** /** * Initialize the storage driver with the given config options */ public function init(array $config) { $this->config = array_merge($this->config, $config); } /** * List methods activated for this user */ public function enumerate() { if ($factors = $this->getFactors()) { return array_keys(array_filter($factors, function ($prop) { return !empty($prop['active']); })); } return []; } /** * Read data for the given key */ public function read($key) { if (!isset($this->cache[$key])) { $factors = $this->getFactors(); $this->cache[$key] = isset($factors[$key]) ? $factors[$key] : null; } return $this->cache[$key]; } /** * Save data for the given key */ public function write($key, $value) { \Log::debug(__METHOD__ . ' ' . @json_encode($value)); // TODO: Not implemented return false; } /** * Remove the data stored for the given key */ public function remove($key) { return $this->write($key, null); } /** * */ protected function getFactors(): array { $prefs = $this->getPrefs(); $key = $this->key2property('blob'); return isset($prefs[$key]) ? (array) $prefs[$key] : []; } /** * */ protected function key2property($key) { // map key to configured property name if (is_array($this->config['keymap']) && isset($this->config['keymap'][$key])) { return $this->config['keymap'][$key]; } // default return 'kolab_2fa_' . $key; } /** * Gets user preferences from Roundcube users table */ protected function getPrefs() { $user = $this->dbh()->table('users') ->select('preferences') ->where('username', strtolower($this->user->email)) ->first(); return $user ? (array) unserialize($user->preferences) : null; } /** * Saves user preferences in Roundcube users table. * This will merge into old preferences */ protected function savePrefs($prefs) { $old_prefs = $this->getPrefs(); if (!is_array($old_prefs)) { return false; } $prefs = array_merge($old_prefs, $prefs); $this->dbh()->table('users') ->where('username', strtolower($this->user->email)) ->update(['preferences' => serialize($prefs)]); return true; } /** * Init connection to the Roundcube database */ public static function dbh() { return \App\Backends\Roundcube::dbh(); } } diff --git a/src/app/AuthAttempt.php b/src/app/AuthAttempt.php index 432c4bc8..954198f2 100644 --- a/src/app/AuthAttempt.php +++ b/src/app/AuthAttempt.php @@ -1,194 +1,195 @@ The attributes that can be not set */ + protected $nullable = ['reason']; + /** @var array The attributes that are mass assignable */ protected $fillable = [ 'ip', 'user_id', 'status', 'reason', 'expires_at', 'last_seen', ]; + /** @var array The attributes that should be cast */ protected $casts = [ 'expires_at' => 'datetime', 'last_seen' => 'datetime' ]; /** * Prepare a date for array / JSON serialization. * * Required to not omit timezone and match the format of update_at/created_at timestamps. * * @param \DateTimeInterface $date * @return string */ protected function serializeDate(\DateTimeInterface $date): string { return Carbon::instance($date)->toIso8601ZuluString('microseconds'); } /** * Returns true if the authentication attempt is accepted. * * @return bool */ public function isAccepted(): bool { return $this->status == self::STATUS_ACCEPTED && Carbon::now() < $this->expires_at; } /** * Returns true if the authentication attempt is denied. * * @return bool */ public function isDenied(): bool { return $this->status == self::STATUS_DENIED; } /** * Accept the authentication attempt. */ public function accept($reason = AuthAttempt::REASON_NONE) { $this->expires_at = Carbon::now()->addHours(8); $this->status = self::STATUS_ACCEPTED; $this->reason = $reason; $this->save(); } /** * Deny the authentication attempt. */ public function deny($reason = AuthAttempt::REASON_NONE) { $this->status = self::STATUS_DENIED; $this->reason = $reason; $this->save(); } /** * Notify the user of this authentication attempt. * * @return bool false if there was no means to notify */ public function notify(): bool { - return \App\CompanionApp::notifyUser($this->user_id, ['token' => $this->id]); + return CompanionApp::notifyUser($this->user_id, ['token' => $this->id]); } /** * Notify the user and wait for a confirmation. */ private function notifyAndWait() { if (!$this->notify()) { //FIXME if the webclient can confirm too we don't need to abort here. \Log::warning("There is no 2fa device to notify."); return false; } \Log::debug("Authentication attempt: {$this->id}"); $confirmationTimeout = 120; $timeout = Carbon::now()->addSeconds($confirmationTimeout); do { if ($this->isDenied()) { \Log::debug("The authentication attempt was denied {$this->id}"); return false; } if ($this->isAccepted()) { \Log::debug("The authentication attempt was accepted {$this->id}"); return true; } if ($timeout < Carbon::now()) { \Log::debug("The authentication attempt timed-out: {$this->id}"); return false; } sleep(2); $this->refresh(); } while (true); } /** * Record a new authentication attempt or update an existing one. * * @param \App\User $user The user attempting to authenticate. * @param string $clientIP The ip the authentication attempt is coming from. * * @return \App\AuthAttempt */ - public static function recordAuthAttempt(\App\User $user, $clientIP) + public static function recordAuthAttempt(User $user, $clientIP) { - $authAttempt = \App\AuthAttempt::where('ip', $clientIP)->where('user_id', $user->id)->first(); + $authAttempt = AuthAttempt::where('ip', $clientIP)->where('user_id', $user->id)->first(); if (!$authAttempt) { - $authAttempt = new \App\AuthAttempt(); + $authAttempt = new AuthAttempt(); $authAttempt->ip = $clientIP; $authAttempt->user_id = $user->id; } $authAttempt->last_seen = Carbon::now(); $authAttempt->save(); return $authAttempt; } /** * Trigger a notification if necessary and wait for confirmation. * * @return bool Returns true if the attempt is accepted on confirmation */ public function waitFor2FA(): bool { if ($this->isAccepted()) { return true; } if ($this->isDenied()) { return false; } if (!$this->notifyAndWait()) { return false; } return $this->isAccepted(); } } diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php index 894d9d0c..522a7bc4 100644 --- a/src/app/Backends/LDAP.php +++ b/src/app/Backends/LDAP.php @@ -1,1386 +1,1386 @@ close(); self::$ldap = null; } } /** * Create a domain in LDAP. * * @param \App\Domain $domain The domain to create. * * @throws \Exception */ public static function createDomain(Domain $domain): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $mgmtRootDN = \config('ldap.admin.root_dn'); $hostedRootDN = \config('ldap.hosted.root_dn'); $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; $aci = [ '(targetattr = "*")' . '(version 3.0; acl "Deny Unauthorized"; deny (all)' . '(userdn != "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)") ' . 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmtRootDN . '";)', '(targetattr != "userPassword")' . '(version 3.0;acl "Search Access";allow (read,compare,search)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)");)', '(targetattr = "*")' . '(version 3.0;acl "Kolab Administrators";allow (all)' . '(roledn = "ldap:///cn=kolab-admin,' . $domainBaseDN . ' || ldap:///cn=kolab-admin,' . $mgmtRootDN . '");)' ]; $entry = [ 'aci' => $aci, 'associateddomain' => $domain->namespace, 'inetdomainbasedn' => $domainBaseDN, 'objectclass' => [ 'top', 'domainrelatedobject', 'inetdomain' ], ]; $dn = "associateddomain={$domain->namespace},{$config['domain_base_dn']}"; self::setDomainAttributes($domain, $entry); if (!$ldap->get_entry($dn)) { self::addEntry( $ldap, $dn, $entry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } // create ou, roles, ous $entry = [ 'description' => $domain->namespace, 'objectclass' => [ 'top', 'organizationalunit' ], 'ou' => $domain->namespace, ]; $entry['aci'] = array( '(targetattr = "*")' . '(version 3.0;acl "Deny Unauthorized"; deny (all)' . '(userdn != "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)") ' . 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmtRootDN . '";)', '(targetattr != "userPassword")' . '(version 3.0;acl "Search Access";allow (read,compare,search,write)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)");)', '(targetattr = "*")' . '(version 3.0;acl "Kolab Administrators";allow (all)' . '(roledn = "ldap:///cn=kolab-admin,' . $domainBaseDN . ' || ldap:///cn=kolab-admin,' . $mgmtRootDN . '");)', '(target = "ldap:///ou=*,' . $domainBaseDN . '")' . '(targetattr="objectclass || aci || ou")' . '(version 3.0;acl "Allow Domain sub-OU Registration"; allow (add)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', '(target = "ldap:///uid=*,ou=People,' . $domainBaseDN . '")(targetattr="*")' . '(version 3.0;acl "Allow Domain First User Registration"; allow (add)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', '(target = "ldap:///cn=*,' . $domainBaseDN . '")(targetattr="objectclass || cn")' . '(version 3.0;acl "Allow Domain Role Registration"; allow (add)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', ); if (!$ldap->get_entry($domainBaseDN)) { self::addEntry( $ldap, $domainBaseDN, $entry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } foreach (['Groups', 'People', 'Resources', 'Shared Folders'] as $item) { $itemDN = "ou={$item},{$domainBaseDN}"; if (!$ldap->get_entry($itemDN)) { $itemEntry = [ 'ou' => $item, 'description' => $item, 'objectclass' => [ 'top', 'organizationalunit' ] ]; self::addEntry( $ldap, $itemDN, $itemEntry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } } foreach (['kolab-admin'] as $item) { $itemDN = "cn={$item},{$domainBaseDN}"; if (!$ldap->get_entry($itemDN)) { $itemEntry = [ 'cn' => $item, 'description' => "{$item} role", 'objectclass' => [ 'top', 'ldapsubentry', 'nsmanagedroledefinition', 'nsroledefinition', 'nssimpleroledefinition' ] ]; self::addEntry( $ldap, $itemDN, $itemEntry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } } // TODO: Assign kolab-admin role to the owner? if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a group in LDAP. * * @param \App\Group $group The group to create. * * @throws \Exception */ public static function createGroup(Group $group): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainName = explode('@', $group->email, 2)[1]; $cn = $ldap->quote_string($group->name); $dn = "cn={$cn}," . self::baseDN($ldap, $domainName, 'Groups'); $entry = [ 'mail' => $group->email, 'objectclass' => [ 'top', 'groupofuniquenames', 'kolabgroupofuniquenames' ], ]; self::setGroupAttributes($ldap, $group, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create group {$group->email} in LDAP (" . __LINE__ . ")" ); if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a resource in LDAP. * * @param \App\Resource $resource The resource to create. * * @throws \Exception */ public static function createResource(Resource $resource): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainName = explode('@', $resource->email, 2)[1]; $cn = $ldap->quote_string($resource->name); $dn = "cn={$cn}," . self::baseDN($ldap, $domainName, 'Resources'); $entry = [ 'mail' => $resource->email, 'objectclass' => [ 'top', 'kolabresource', 'kolabsharedfolder', 'mailrecipient', ], 'kolabfoldertype' => 'event', ]; self::setResourceAttributes($ldap, $resource, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create resource {$resource->email} in LDAP (" . __LINE__ . ")" ); if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a shared folder in LDAP. * * @param \App\SharedFolder $folder The shared folder to create. * * @throws \Exception */ public static function createSharedFolder(SharedFolder $folder): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainName = explode('@', $folder->email, 2)[1]; $cn = $ldap->quote_string($folder->name); $dn = "cn={$cn}," . self::baseDN($ldap, $domainName, 'Shared Folders'); $entry = [ 'mail' => $folder->email, 'objectclass' => [ 'top', 'kolabsharedfolder', 'mailrecipient', ], ]; self::setSharedFolderAttributes($ldap, $folder, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create shared folder {$folder->id} in LDAP (" . __LINE__ . ")" ); if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a user in LDAP. * * Only need to add user if in any of the local domains? Figure that out here for now. Should * have Context-Based Access Controls before the job is queued though, probably. * * Use one of three modes; * * 1) The authenticated user account. * * * Only valid if the authenticated user is a domain admin. * * We don't know the originating user here. * * We certainly don't have its password anymore. * * 2) The hosted kolab account. * * 3) The Directory Manager account. * * @param \App\User $user The user account to create. * * @throws \Exception */ public static function createUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $entry = [ 'objectclass' => [ 'top', 'inetorgperson', 'inetuser', 'kolabinetorgperson', 'mailrecipient', 'person' ], 'mail' => $user->email, 'uid' => $user->email, 'nsroledn' => [] ]; if (!self::getUserEntry($ldap, $user->email, $dn)) { if (empty($dn)) { self::throwException($ldap, "Failed to create user {$user->email} in LDAP (" . __LINE__ . ")"); } self::setUserAttributes($user, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create user {$user->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a domain from LDAP. * * @param \App\Domain $domain The domain to delete * * @throws \Exception */ public static function deleteDomain(Domain $domain): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainBaseDN = self::baseDN($ldap, $domain->namespace); if ($ldap->get_entry($domainBaseDN)) { $result = $ldap->delete_entry_recursive($domainBaseDN); if (!$result) { self::throwException( $ldap, "Failed to delete domain {$domain->namespace} from LDAP (" . __LINE__ . ")" ); } } if ($ldap_domain = $ldap->find_domain($domain->namespace)) { if ($ldap->get_entry($ldap_domain['dn'])) { $result = $ldap->delete_entry($ldap_domain['dn']); if (!$result) { self::throwException( $ldap, "Failed to delete domain {$domain->namespace} from LDAP (" . __LINE__ . ")" ); } } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a group from LDAP. * * @param \App\Group $group The group to delete. * * @throws \Exception */ public static function deleteGroup(Group $group): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getGroupEntry($ldap, $group->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete group {$group->email} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a resource from LDAP. * * @param \App\Resource $resource The resource to delete. * * @throws \Exception */ public static function deleteResource(Resource $resource): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getResourceEntry($ldap, $resource->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete resource {$resource->email} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a shared folder from LDAP. * * @param \App\SharedFolder $folder The shared folder to delete. * * @throws \Exception */ public static function deleteSharedFolder(SharedFolder $folder): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getSharedFolderEntry($ldap, $folder->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete shared folder {$folder->id} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a user from LDAP. * * @param \App\User $user The user account to delete. * * @throws \Exception */ public static function deleteUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getUserEntry($ldap, $user->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete user {$user->email} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Get a domain data from LDAP. * * @param string $namespace The domain name * * @return array|false|null * @throws \Exception */ public static function getDomain(string $namespace) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $ldapDomain = $ldap->find_domain($namespace); if ($ldapDomain) { $domain = $ldap->get_entry($ldapDomain['dn']); } if (empty(self::$ldap)) { $ldap->close(); } return $domain ?? null; } /** * Get a group data from LDAP. * * @param string $email The group email. * * @return array|false|null * @throws \Exception */ public static function getGroup(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $group = self::getGroupEntry($ldap, $email, $dn); if (empty(self::$ldap)) { $ldap->close(); } return $group; } /** * Get a resource data from LDAP. * * @param string $email The resource email. * * @return array|false|null * @throws \Exception */ public static function getResource(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $resource = self::getResourceEntry($ldap, $email, $dn); if (empty(self::$ldap)) { $ldap->close(); } return $resource; } /** * Get a shared folder data from LDAP. * * @param string $email The resource email. * * @return array|false|null * @throws \Exception */ public static function getSharedFolder(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $folder = self::getSharedFolderEntry($ldap, $email, $dn); if (empty(self::$ldap)) { $ldap->close(); } return $folder; } /** * Get a user data from LDAP. * * @param string $email The user email. * * @return array|false|null * @throws \Exception */ public static function getUser(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $user = self::getUserEntry($ldap, $email, $dn, true); if (empty(self::$ldap)) { $ldap->close(); } return $user; } /** * Update a domain in LDAP. * * @param \App\Domain $domain The domain to update. * * @throws \Exception */ public static function updateDomain(Domain $domain): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $ldapDomain = $ldap->find_domain($domain->namespace); if (!$ldapDomain) { self::throwException( $ldap, "Failed to update domain {$domain->namespace} in LDAP (domain not found)" ); } $oldEntry = $ldap->get_entry($ldapDomain['dn']); $newEntry = $oldEntry; self::setDomainAttributes($domain, $newEntry); if (array_key_exists('inetdomainstatus', $newEntry)) { $newEntry['inetdomainstatus'] = (string) $newEntry['inetdomainstatus']; } $result = $ldap->modify_entry($ldapDomain['dn'], $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a group in LDAP. * * @param \App\Group $group The group to update * * @throws \Exception */ public static function updateGroup(Group $group): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getGroupEntry($ldap, $group->email, $dn); if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update group {$group->email} in LDAP (group not found)" ); } self::setGroupAttributes($ldap, $group, $newEntry); $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update group {$group->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a resource in LDAP. * * @param \App\Resource $resource The resource to update * * @throws \Exception */ public static function updateResource(Resource $resource): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getResourceEntry($ldap, $resource->email, $dn); if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update resource {$resource->email} in LDAP (resource not found)" ); } self::setResourceAttributes($ldap, $resource, $newEntry); $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update resource {$resource->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a shared folder in LDAP. * * @param \App\SharedFolder $folder The shared folder to update * * @throws \Exception */ public static function updateSharedFolder(SharedFolder $folder): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getSharedFolderEntry($ldap, $folder->email, $dn); if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update shared folder {$folder->id} in LDAP (folder not found)" ); } self::setSharedFolderAttributes($ldap, $folder, $newEntry); $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update shared folder {$folder->id} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a user in LDAP. * * @param \App\User $user The user account to update. * * @throws \Exception */ public static function updateUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getUserEntry($ldap, $user->email, $dn, true); if (!$oldEntry) { self::throwException( $ldap, "Failed to update user {$user->email} in LDAP (user not found)" ); } self::setUserAttributes($user, $newEntry); if (array_key_exists('objectclass', $newEntry)) { if (!in_array('inetuser', $newEntry['objectclass'])) { $newEntry['objectclass'][] = 'inetuser'; } } if (array_key_exists('inetuserstatus', $newEntry)) { $newEntry['inetuserstatus'] = (string) $newEntry['inetuserstatus']; } if (array_key_exists('mailquota', $newEntry)) { $newEntry['mailquota'] = (string) $newEntry['mailquota']; } $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update user {$user->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Initialize connection to LDAP */ private static function initLDAP(array $config, string $privilege = 'admin') { if (self::$ldap) { return self::$ldap; } $ldap = new \Net_LDAP3($config); $connected = $ldap->connect(); if (!$connected) { throw new \Exception("Failed to connect to LDAP"); } $bound = $ldap->bind( \config("ldap.{$privilege}.bind_dn"), \config("ldap.{$privilege}.bind_pw") ); if (!$bound) { throw new \Exception("Failed to bind to LDAP"); } return $ldap; } /** * Set domain attributes */ private static function setDomainAttributes(Domain $domain, array &$entry) { $entry['inetdomainstatus'] = $domain->status; } /** * Convert group member addresses in to valid entries. */ private static function setGroupAttributes($ldap, Group $group, &$entry) { $settings = $group->getSettings(['sender_policy']); $entry['kolaballowsmtpsender'] = json_decode($settings['sender_policy'] ?: '[]', true); $entry['cn'] = $group->name; $entry['uniquemember'] = []; $groupDomain = explode('@', $group->email, 2)[1]; $domainBaseDN = self::baseDN($ldap, $groupDomain); $validMembers = []; foreach ($group->members as $member) { list($local, $domainName) = explode('@', $member); $memberDN = "uid={$member},ou=People,{$domainBaseDN}"; $memberEntry = $ldap->get_entry($memberDN); // if the member is in the local domain but doesn't exist, drop it if ($domainName == $groupDomain && !$memberEntry) { continue; } // add the member if not in the local domain if (!$memberEntry) { $memberEntry = [ 'cn' => $member, 'mail' => $member, 'objectclass' => [ 'top', 'inetorgperson', 'organizationalperson', 'person' ], 'sn' => 'unknown' ]; $ldap->add_entry($memberDN, $memberEntry); } $entry['uniquemember'][] = $memberDN; $validMembers[] = $member; } // Update members in sql (some might have been removed), // skip model events to not invoke another update job if ($group->members !== $validMembers) { $group->members = $validMembers; Group::withoutEvents(function () use ($group) { $group->save(); }); } } /** * Set common resource attributes */ private static function setResourceAttributes($ldap, Resource $resource, &$entry) { $entry['cn'] = $resource->name; $entry['owner'] = null; $entry['kolabinvitationpolicy'] = null; $entry['acl'] = ''; $settings = $resource->getSettings(['invitation_policy', 'folder']); $entry['kolabtargetfolder'] = $settings['folder'] ?? ''; // Here's how Wallace's resources module works: // - if policy is ACT_MANUAL and owner mail specified: a tentative response is sent, event saved, // and mail sent to the owner to accept/decline the request. // - if policy is ACT_ACCEPT_AND_NOTIFY and owner mail specified: an accept response is sent, // event saved, and notification (not confirmation) mail sent to the owner. // - if there's no owner (policy irrelevant): an accept response is sent, event saved. // - if policy is ACT_REJECT: a decline response is sent // - note that the notification email is being send if COND_NOTIFY policy is set or saving failed. // - all above assume there's no conflict, if there's a conflict the decline response is sent automatically // (notification is sent if policy = ACT_ACCEPT_AND_NOTIFY). // - the only supported policies are: 'ACT_MANUAL', 'ACT_ACCEPT' (defined but not used anywhere), // 'ACT_REJECT', 'ACT_ACCEPT_AND_NOTIFY'. // For now we ignore the notifications feature if (!empty($settings['invitation_policy'])) { if ($settings['invitation_policy'] === 'accept') { $entry['kolabinvitationpolicy'] = 'ACT_ACCEPT'; } elseif ($settings['invitation_policy'] === 'reject') { $entry['kolabinvitationpolicy'] = 'ACT_REJECT'; } elseif (preg_match('/^manual:(\S+@\S+)$/', $settings['invitation_policy'], $m)) { if (self::getUserEntry($ldap, $m[1], $userDN)) { $entry['owner'] = $userDN; $entry['acl'] = $m[1] . ', full'; $entry['kolabinvitationpolicy'] = 'ACT_MANUAL'; } else { $entry['kolabinvitationpolicy'] = 'ACT_ACCEPT'; } } } } /** * Set common shared folder attributes */ private static function setSharedFolderAttributes($ldap, SharedFolder $folder, &$entry) { $settings = $folder->getSettings(['acl', 'folder']); $entry['cn'] = $folder->name; $entry['kolabfoldertype'] = $folder->type; $entry['kolabtargetfolder'] = $settings['folder'] ?? ''; $entry['acl'] = !empty($settings['acl']) ? json_decode($settings['acl'], true) : ''; $entry['alias'] = $folder->aliases()->pluck('alias')->all(); } /** * Set common user attributes */ private static function setUserAttributes(User $user, array &$entry) { $isDegraded = $user->isDegraded(true); $settings = $user->getSettings(['first_name', 'last_name', 'organization']); $firstName = $settings['first_name']; $lastName = $settings['last_name']; $cn = "unknown"; $displayname = ""; if ($firstName) { if ($lastName) { $cn = "{$firstName} {$lastName}"; $displayname = "{$lastName}, {$firstName}"; } else { $lastName = "unknown"; $cn = "{$firstName}"; $displayname = "{$firstName}"; } } else { $firstName = ""; if ($lastName) { $cn = "{$lastName}"; $displayname = "{$lastName}"; } else { $lastName = "unknown"; } } $entry['cn'] = $cn; $entry['displayname'] = $displayname; $entry['givenname'] = $firstName; $entry['sn'] = $lastName; $entry['userpassword'] = $user->password_ldap; $entry['inetuserstatus'] = $user->status; $entry['o'] = $settings['organization']; $entry['mailquota'] = 0; $entry['alias'] = $user->aliases()->pluck('alias')->all(); $roles = []; foreach ($user->entitlements as $entitlement) { \Log::debug("Examining {$entitlement->sku->title}"); switch ($entitlement->sku->title) { case "mailbox": break; case "storage": $entry['mailquota'] += 1048576; break; default: $roles[] = $entitlement->sku->title; break; } } $hostedRootDN = \config('ldap.hosted.root_dn'); $entry['nsroledn'] = []; if (in_array("2fa", $roles)) { $entry['nsroledn'][] = "cn=2fa-user,{$hostedRootDN}"; } if ($isDegraded) { $entry['nsroledn'][] = "cn=degraded-user,{$hostedRootDN}"; $entry['mailquota'] = \config('app.storage.min_qty') * 1048576; } else { if (in_array("activesync", $roles)) { $entry['nsroledn'][] = "cn=activesync-user,{$hostedRootDN}"; } if (!in_array("groupware", $roles)) { $entry['nsroledn'][] = "cn=imap-user,{$hostedRootDN}"; } } } /** * Get LDAP configuration for specified access level */ private static function getConfig(string $privilege) { $config = [ 'domain_base_dn' => \config('ldap.domain_base_dn'), 'domain_filter' => \config('ldap.domain_filter'), 'domain_name_attribute' => \config('ldap.domain_name_attribute'), 'hosts' => \config('ldap.hosts'), 'sort' => false, 'vlv' => false, 'log_hook' => 'App\Backends\LDAP::logHook', ]; return $config; } /** * Get group entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email Group email (mail) * @param string $dn Reference to group DN * * @return null|array Group entry, False on error, NULL if not found */ private static function getGroupEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($ldap, $domainName, 'Groups'); $attrs = ['dn', 'cn', 'mail', 'uniquemember', 'objectclass', 'kolaballowsmtpsender']; // For groups we're using search() instead of get_entry() because // a group name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** * Get a resource entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email Resource email (mail) * @param string $dn Reference to the resource DN * * @return null|array Resource entry, NULL if not found */ private static function getResourceEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($ldap, $domainName, 'Resources'); $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'kolabinvitationpolicy', 'owner', 'acl']; // For resources we're using search() instead of get_entry() because // a resource name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** * Get a shared folder entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email Resource email (mail) * @param string $dn Reference to the shared folder DN * * @return null|array Shared folder entry, NULL if not found */ private static function getSharedFolderEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($ldap, $domainName, 'Shared Folders'); $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'acl', 'alias']; // For shared folders we're using search() instead of get_entry() because // a folder name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** * Get user entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email User email (uid) * @param string $dn Reference to user DN * @param bool $full Get extra attributes, e.g. nsroledn * * @return ?array User entry, NULL if not found */ private static function getUserEntry($ldap, $email, &$dn = null, $full = false) { $domainName = explode('@', $email, 2)[1]; $dn = "uid={$email}," . self::baseDN($ldap, $domainName, 'People'); $entry = $ldap->get_entry($dn); if ($entry && $full) { if (!array_key_exists('nsroledn', $entry)) { $roles = $ldap->get_entry_attributes($dn, ['nsroledn']); if (!empty($roles)) { $entry['nsroledn'] = (array) $roles['nsroledn']; } } } return $entry ?: null; } /** * Logging callback */ public static function logHook($level, $msg): void { if ( ( $level == LOG_INFO || $level == LOG_DEBUG || $level == LOG_NOTICE ) && !\config('app.debug') ) { return; } switch ($level) { case LOG_CRIT: $function = 'critical'; break; case LOG_EMERG: $function = 'emergency'; break; case LOG_ERR: $function = 'error'; break; case LOG_ALERT: $function = 'alert'; break; case LOG_WARNING: $function = 'warning'; break; case LOG_INFO: $function = 'info'; break; case LOG_DEBUG: $function = 'debug'; break; case LOG_NOTICE: $function = 'notice'; break; default: $function = 'info'; } if (is_array($msg)) { $msg = implode("\n", $msg); } $msg = '[LDAP] ' . $msg; \Log::{$function}($msg); } /** * A wrapper for Net_LDAP3::add_entry() with error handler * * @param \Net_LDAP3 $ldap Ldap connection * @param string $dn Entry DN * @param array $entry Entry attributes * @param ?string $errorMsg A message to throw as an exception on error * * @throws \Exception */ private static function addEntry($ldap, string $dn, array $entry, $errorMsg = null) { // try/catch because Laravel converts warnings into exceptions // and we want more human-friendly error message than that try { $result = $ldap->add_entry($dn, $entry); } catch (\Exception $e) { $result = false; } if (!$result) { if (!$errorMsg) { $errorMsg = "LDAP Error (" . __LINE__ . ")"; } if (isset($e)) { $errorMsg .= ": " . $e->getMessage(); } self::throwException($ldap, $errorMsg); } } /** * Find a single entry in LDAP by using search. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $base_dn Base DN * @param string $filter Search filter * @param array $attrs Result attributes * @param string $dn Reference to a DN of the found entry * * @return null|array LDAP entry, NULL if not found */ private static function searchEntry($ldap, $base_dn, $filter, $attrs, &$dn = null) { $result = $ldap->search($base_dn, $filter, 'sub', $attrs); if ($result && $result->count() == 1) { $entries = $result->entries(true); $dn = key($entries); $entry = $entries[$dn]; $entry['dn'] = $dn; return $entry; } return null; } /** * Throw exception and close the connection when needed * * @param \Net_LDAP3 $ldap Ldap connection * @param string $message Exception message * * @throws \Exception */ private static function throwException($ldap, string $message): void { - if (empty(self::$ldap) && !empty($ldap)) { + if (empty(self::$ldap)) { $ldap->close(); } throw new \Exception($message); } /** * Create a base DN string for a specified object. * Note: It makes sense with an existing domain only. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $domainName Domain namespace * @param ?string $ouName Optional name of the sub-tree (OU) * * @return string Full base DN */ private static function baseDN($ldap, string $domainName, string $ouName = null): string { $dn = $ldap->domain_root_dn($domainName); if ($ouName) { $dn = "ou={$ouName},{$dn}"; } return $dn; } } diff --git a/src/app/CompanionApp.php b/src/app/CompanionApp.php index 1720223b..4b444846 100644 --- a/src/app/CompanionApp.php +++ b/src/app/CompanionApp.php @@ -1,83 +1,84 @@ The attributes that are mass assignable */ protected $fillable = [ 'name', 'user_id', 'device_id', 'notification_token', 'mfa_enabled', ]; /** * Send a notification via firebase. * * @param array $deviceIds A list of device id's to send the notification to * @param array $data The data to include in the notification. * * @throws \Exception on notification failure * @return bool true if a notification has been sent */ private static function pushFirebaseNotification($deviceIds, $data): bool { \Log::debug("sending notification to " . var_export($deviceIds, true)); $apiKey = \config('firebase.api_key'); $client = new \GuzzleHttp\Client( [ 'verify' => \config('firebase.api_verify_tls') ] ); $response = $client->request( 'POST', \config('firebase.api_url'), [ 'headers' => [ 'Authorization' => "key={$apiKey}", ], 'json' => [ 'registration_ids' => $deviceIds, 'data' => $data ] ] ); if ($response->getStatusCode() != 200) { throw new \Exception('FCM Send Error: ' . $response->getStatusCode()); } return true; } /** * Send a notification to a user. * * @throws \Exception on notification failure * @return bool true if a notification has been sent */ public static function notifyUser($userId, $data): bool { - $notificationTokens = \App\CompanionApp::where('user_id', $userId) + $notificationTokens = CompanionApp::where('user_id', $userId) ->where('mfa_enabled', true) ->pluck('notification_token') ->all(); if (empty($notificationTokens)) { \Log::debug("There is no 2fa device to notify."); return false; } self::pushFirebaseNotification($notificationTokens, $data); return true; } } diff --git a/src/app/Console/Command.php b/src/app/Console/Command.php index 088aec66..ca6f1104 100644 --- a/src/app/Console/Command.php +++ b/src/app/Console/Command.php @@ -1,262 +1,262 @@ output->createProgressBar($count); $bar->setFormat( '%current:7s%/%max:7s% [%bar%] %percent:3s%% %elapsed:7s%/%estimated:-7s% %message% ' ); if ($message) { $bar->setMessage("{$message}..."); } $bar->start(); return $bar; } /** * Find the domain. * * @param string $domain Domain ID or namespace * @param bool $withDeleted Include deleted * * @return \App\Domain|null */ public function getDomain($domain, $withDeleted = false) { return $this->getObject(\App\Domain::class, $domain, 'namespace', $withDeleted); } /** * Find a group. * * @param string $group Group ID or email * @param bool $withDeleted Include deleted * * @return \App\Group|null */ public function getGroup($group, $withDeleted = false) { return $this->getObject(\App\Group::class, $group, 'email', $withDeleted); } /** * Find an object. * * @param string $objectClass The name of the class * @param string $objectIdOrTitle The name of a database field to match. * @param string|null $objectTitle An additional database field to match. * @param bool $withDeleted Act as if --with-deleted was used * * @return mixed */ public function getObject($objectClass, $objectIdOrTitle, $objectTitle = null, $withDeleted = false) { if (!$withDeleted) { $withDeleted = $this->hasOption('with-deleted') && $this->option('with-deleted'); } $object = $this->getObjectModel($objectClass, $withDeleted)->find($objectIdOrTitle); if (!$object && !empty($objectTitle)) { $object = $this->getObjectModel($objectClass, $withDeleted) ->where($objectTitle, $objectIdOrTitle)->first(); } return $object; } /** * Returns a preconfigured Model object for a specified class. * * @param string $objectClass The name of the class * @param bool $withDeleted Include withTrashed() query * * @return mixed */ protected function getObjectModel($objectClass, $withDeleted = false) { if ($withDeleted) { $model = $objectClass::withTrashed(); } else { $model = new $objectClass(); } if ($this->commandPrefix == 'scalpel') { return $model; } $modelsWithOwner = [ \App\Wallet::class, ]; $tenantId = \config('app.tenant_id'); // Add tenant filter if (in_array(\App\Traits\BelongsToTenantTrait::class, class_uses($objectClass))) { $model = $model->withEnvTenantContext(); } elseif (in_array($objectClass, $modelsWithOwner)) { $model = $model->whereExists(function ($query) use ($tenantId) { $query->select(DB::raw(1)) ->from('users') ->whereRaw('wallets.user_id = users.id') ->whereRaw('users.tenant_id ' . ($tenantId ? "= $tenantId" : 'is null')); }); } return $model; } /** * Find a resource. * * @param string $resource Resource ID or email * @param bool $withDeleted Include deleted * * @return \App\Resource|null */ public function getResource($resource, $withDeleted = false) { return $this->getObject(\App\Resource::class, $resource, 'email', $withDeleted); } /** * Find a shared folder. * * @param string $folder Folder ID or email * @param bool $withDeleted Include deleted * * @return \App\SharedFolder|null */ public function getSharedFolder($folder, $withDeleted = false) { return $this->getObject(\App\SharedFolder::class, $folder, 'email', $withDeleted); } /** * Find the user. * * @param string $user User ID or email * @param bool $withDeleted Include deleted * * @return \App\User|null */ public function getUser($user, $withDeleted = false) { return $this->getObject(\App\User::class, $user, 'email', $withDeleted); } /** * Find the wallet. * * @param string $wallet Wallet ID * * @return \App\Wallet|null */ public function getWallet($wallet) { return $this->getObject(\App\Wallet::class, $wallet, null); } public function handle() { if ($this->dangerous) { $this->warn( "This command is a dangerous scalpel command with potentially significant unintended consequences" ); $confirmation = $this->confirm("Are you sure you understand what's about to happen?"); if (!$confirmation) { $this->info("Better safe than sorry."); return false; } $this->info("Vámonos!"); } return true; } /** * Return a string for output, with any additional attributes specified as well. * * @param mixed $entry An object * * @return string */ protected function toString($entry) { /** * Haven't figured out yet, how to test if this command implements an option for additional * attributes. if (!in_array('attr', $this->options())) { return $entry->{$entry->getKeyName()}; } */ $str = [ $entry->{$entry->getKeyName()} ]; foreach ($this->option('attr') as $attr) { if ($attr == $entry->getKeyName()) { $this->warn("Specifying {$attr} is not useful."); continue; } if (!array_key_exists($attr, $entry->toArray())) { $this->error("Attribute {$attr} isn't available"); continue; } if (is_numeric($entry->{$attr})) { $str[] = $entry->{$attr}; } else { $str[] = !empty($entry->{$attr}) ? $entry->{$attr} : "null"; } } return implode(" ", $str); } } diff --git a/src/app/Console/Commands/Data/Import/LdifCommand.php b/src/app/Console/Commands/Data/Import/LdifCommand.php index cd971d68..a24e6ea3 100644 --- a/src/app/Console/Commands/Data/Import/LdifCommand.php +++ b/src/app/Console/Commands/Data/Import/LdifCommand.php @@ -1,1009 +1,1010 @@ bigIncrements('id'); $table->text('dn')->index(); $table->string('type')->nullable()->index(); $table->text('data')->nullable(); $table->text('error')->nullable(); $table->text('warning')->nullable(); } ); // Import data from the file to the temp table $this->loadFromFile(); // Check for errors in the data, print them and abort (if not using --force) if ($this->printErrors()) { return 1; } // Prepare packages/skus information $this->preparePackagesAndSkus(); // Import the account owner first $this->importOwner(); // Import domains first $this->importDomains(); // Import other objects $this->importUsers(); $this->importSharedFolders(); $this->importResources(); $this->importGroups(); // Print warnings collected in the whole process $this->printWarnings(); // Finally, drop the temp table Schema::dropIfExists(self::$table); } /** * Check if a domain exists */ protected function domainExists($domain): bool { return in_array($domain, $this->domains); } /** * Load data from the LDIF file into the temp table */ protected function loadFromFile(): void { $file = $this->argument('file'); $numLines = \App\Utils::countLines($file); $bar = $this->createProgressBar($numLines, "Parsing input file"); $fh = fopen($file, 'r'); $inserts = []; $entry = []; $lastAttr = null; $insertFunc = function ($limit = 0) use (&$entry, &$inserts) { + // @phpstan-ignore-next-line if (!empty($entry)) { if ($entry = $this->parseLDAPEntry($entry)) { $inserts[] = $entry; } $entry = []; } if (count($inserts) > $limit) { DB::table(self::$table)->insert($inserts); $inserts = []; } }; while (!feof($fh)) { $line = rtrim(fgets($fh)); $bar->advance(); if (trim($line) === '' || $line[0] === '#') { continue; } if (substr($line, 0, 3) == 'dn:') { $insertFunc(20); $entry['dn'] = strtolower(substr($line, 4)); $lastAttr = 'dn'; } elseif (substr($line, 0, 1) == ' ') { if (is_array($entry[$lastAttr])) { $elemNum = count($entry[$lastAttr]) - 1; $entry[$lastAttr][$elemNum] .= ltrim($line); } else { $entry[$lastAttr] .= ltrim($line); } } else { list ($attr, $remainder) = explode(':', $line, 2); $attr = strtolower($attr); if ($remainder[0] === ':') { $remainder = base64_decode(substr($remainder, 2)); } else { $remainder = ltrim($remainder); } if (array_key_exists($attr, $entry)) { if (!is_array($entry[$attr])) { $entry[$attr] = [$entry[$attr]]; } $entry[$attr][] = $remainder; } else { $entry[$attr] = $remainder; } $lastAttr = $attr; } } $insertFunc(); $bar->finish(); $this->info("DONE"); } /** * Import domains from the temp table */ protected function importDomains(): void { $domains = DB::table(self::$table)->where('type', 'domain')->whereNull('error')->get(); $bar = $this->createProgressBar(count($domains), "Importing domains"); foreach ($domains as $_domain) { $bar->advance(); $data = json_decode($_domain->data); $domain = \App\Domain::withTrashed()->where('namespace', $data->namespace)->first(); if ($domain) { $this->setImportWarning($_domain->id, "Domain already exists"); continue; } $domain = \App\Domain::create([ 'namespace' => $data->namespace, 'type' => \App\Domain::TYPE_EXTERNAL, ]); // Entitlements $domain->assignPackageAndWallet($this->packages['domain'], $this->wallet); $this->domains[] = $domain->namespace; if (!empty($data->aliases)) { foreach ($data->aliases as $alias) { $alias = strtolower($alias); $domain = \App\Domain::withTrashed()->where('namespace', $alias)->first(); if ($domain) { $this->setImportWarning($_domain->id, "Domain already exists"); continue; } $domain = \App\Domain::create([ 'namespace' => $alias, 'type' => \App\Domain::TYPE_EXTERNAL, ]); // Entitlements $domain->assignPackageAndWallet($this->packages['domain'], $this->wallet); $this->domains[] = $domain->namespace; } } } $bar->finish(); $this->info("DONE"); } /** * Import groups from the temp table */ protected function importGroups(): void { $groups = DB::table(self::$table)->where('type', 'group')->whereNull('error')->get(); $bar = $this->createProgressBar(count($groups), "Importing groups"); foreach ($groups as $_group) { $bar->advance(); $data = json_decode($_group->data); // Collect group member email addresses $members = $this->resolveUserDNs($data->members); if (empty($members)) { $this->setImportWarning($_group->id, "Members resolve to an empty array"); continue; } $group = \App\Group::withTrashed()->where('email', $data->email)->first(); if ($group) { $this->setImportWarning($_group->id, "Group already exists"); continue; } // Make sure the domain exists if (!$this->domainExists($data->domain)) { $this->setImportWarning($_group->id, "Domain not found"); continue; } $group = \App\Group::create([ 'name' => $data->name, 'email' => $data->email, 'members' => $members, ]); $group->assignToWallet($this->wallet); // Sender policy if (!empty($data->sender_policy)) { $group->setSetting('sender_policy', json_encode($data->sender_policy)); } } $bar->finish(); $this->info("DONE"); } /** * Import resources from the temp table */ protected function importResources(): void { $resources = DB::table(self::$table)->where('type', 'resource')->whereNull('error')->get(); $bar = $this->createProgressBar(count($resources), "Importing resources"); foreach ($resources as $_resource) { $bar->advance(); $data = json_decode($_resource->data); $resource = \App\Resource::withTrashed() ->where('name', $data->name) ->where('email', 'like', '%@' . $data->domain) ->first(); if ($resource) { $this->setImportWarning($_resource->id, "Resource already exists"); continue; } // Resource invitation policy if (!empty($data->invitation_policy) && $data->invitation_policy == 'manual') { $members = empty($data->owner) ? [] : $this->resolveUserDNs([$data->owner]); if (empty($members)) { $this->setImportWarning($_resource->id, "Failed to resolve the resource owner"); $data->invitation_policy = null; } else { $data->invitation_policy = 'manual:' . $members[0]; } } // Make sure the domain exists if (!$this->domainExists($data->domain)) { $this->setImportWarning($_resource->id, "Domain not found"); continue; } $resource = new \App\Resource(); $resource->name = $data->name; $resource->domainName = $data->domain; $resource->save(); $resource->assignToWallet($this->wallet); // Invitation policy if (!empty($data->invitation_policy)) { $resource->setSetting('invitation_policy', $data->invitation_policy); } // Target folder if (!empty($data->folder)) { $resource->setSetting('folder', $data->folder); } } $bar->finish(); $this->info("DONE"); } /** * Import shared folders from the temp table */ protected function importSharedFolders(): void { $folders = DB::table(self::$table)->where('type', 'sharedFolder')->whereNull('error')->get(); $bar = $this->createProgressBar(count($folders), "Importing shared folders"); foreach ($folders as $_folder) { $bar->advance(); $data = json_decode($_folder->data); $folder = \App\SharedFolder::withTrashed() ->where('name', $data->name) ->where('email', 'like', '%@' . $data->domain) ->first(); if ($folder) { $this->setImportWarning($_folder->id, "Folder already exists"); continue; } // Make sure the domain exists if (!$this->domainExists($data->domain)) { $this->setImportWarning($_folder->id, "Domain not found"); continue; } $folder = new \App\SharedFolder(); $folder->name = $data->name; $folder->type = $data->type ?? 'mail'; $folder->domainName = $data->domain; $folder->save(); $folder->assignToWallet($this->wallet); // Invitation policy if (!empty($data->acl)) { $folder->setSetting('acl', json_encode($data->acl)); } // Target folder if (!empty($data->folder)) { $folder->setSetting('folder', $data->folder); } // Import aliases if (!empty($data->aliases)) { $this->setObjectAliases($folder, $data->aliases); } } $bar->finish(); $this->info("DONE"); } /** * Import users from the temp table */ protected function importUsers(): void { $users = DB::table(self::$table)->where('type', 'user')->whereNull('error'); // Skip the (already imported) account owner if ($this->ownerDN) { $users->whereNotIn('dn', [$this->ownerDN]); } // Import aliases of the owner, we got from importOwner() call if (!empty($this->aliases) && $this->wallet) { $this->setObjectAliases($this->wallet->owner, $this->aliases); } $bar = $this->createProgressBar($users->count(), "Importing users"); foreach ($users->cursor() as $_user) { $bar->advance(); $this->importSingleUser($_user); } $bar->finish(); $this->info("DONE"); } /** * Import the account owner (or find it among the existing accounts) */ protected function importOwner(): void { // The owner email not found in the import data, try existing users $user = $this->getUser($this->argument('owner')); if (!$user && $this->ownerDN) { // The owner email found in the import data $bar = $this->createProgressBar(1, "Importing account owner"); $user = DB::table(self::$table)->where('dn', $this->ownerDN)->first(); $user = $this->importSingleUser($user); // TODO: We should probably make sure the user's domain is to be imported too // and/or create it automatically. $bar->advance(); $bar->finish(); $this->info("DONE"); } if (!$user) { $this->error("Unable to find the specified account owner"); exit(1); } $this->wallet = $user->wallets->first(); } /** * A helper that imports a single user record */ protected function importSingleUser($ldap_user) { $data = json_decode($ldap_user->data); $user = \App\User::withTrashed()->where('email', $data->email)->first(); if ($user) { $this->setImportWarning($ldap_user->id, "User already exists"); return; } // Make sure the domain exists if ($this->wallet && !$this->domainExists($data->domain)) { $this->setImportWarning($ldap_user->id, "Domain not found"); return; } $user = \App\User::create(['email' => $data->email]); // Entitlements $user->assignPackageAndWallet($this->packages['user'], $this->wallet ?: $user->wallets()->first()); if (!empty($data->quota)) { $quota = ceil($data->quota / 1024 / 1024) - $this->packages['quota']; if ($quota > 0) { $user->assignSku($this->packages['storage'], $quota); } } // User settings if (!empty($data->settings)) { $settings = []; foreach ($data->settings as $key => $value) { $settings[] = [ 'user_id' => $user->id, 'key' => $key, 'value' => $value, ]; } DB::table('user_settings')->insert($settings); } // Update password if ($data->password != $user->password_ldap) { \App\User::where('id', $user->id)->update(['password_ldap' => $data->password]); } // Import aliases if (!empty($data->aliases)) { if (!$this->wallet) { // This is the account owner creation, at this point we likely do not have // domain records yet, save the aliases to be inserted later (in importUsers()) $this->aliases = $data->aliases; } else { $this->setObjectAliases($user, $data->aliases); } } return $user; } /** * Convert LDAP entry into an object supported by the migration tool * * @param array $entry LDAP entry attributes * * @return array Record data for inserting to the temp table */ protected function parseLDAPEntry(array $entry): array { $type = null; $data = null; $error = null; $ouTypeMap = [ 'Shared Folders' => 'sharedfolder', 'Resources' => 'resource', 'Groups' => 'group', 'People' => 'user', 'Domains' => 'domain', ]; foreach ($ouTypeMap as $ou => $_type) { if (stripos($entry['dn'], ",ou={$ou}")) { $type = $_type; break; } } if (!$type) { $error = "Unknown record type"; } if (empty($error)) { $method = 'parseLDAP' . ucfirst($type); list($data, $error) = $this->{$method}($entry); if (empty($data['domain']) && !empty($data['email'])) { $data['domain'] = explode('@', $data['email'])[1]; } } return [ 'dn' => $entry['dn'], 'type' => $type, 'data' => json_encode($data), 'error' => $error, ]; } /** * Convert LDAP domain data into Kolab4 "format" */ protected function parseLDAPDomain($entry) { $error = null; $result = []; if (empty($entry['associateddomain'])) { $error = "Missing 'associatedDomain' attribute"; } elseif (!empty($entry['inetdomainstatus']) && $entry['inetdomainstatus'] == 'deleted') { $error = "Domain deleted"; } else { $result['namespace'] = strtolower($this->attrStringValue($entry, 'associateddomain')); if (is_array($entry['associateddomain']) && count($entry['associateddomain']) > 1) { $result['aliases'] = array_slice($entry['associateddomain'], 1); } // TODO: inetdomainstatus = suspended ??? } return [$result, $error]; } /** * Convert LDAP group data into Kolab4 "format" */ protected function parseLDAPGroup($entry) { $error = null; $result = []; if (empty($entry['cn'])) { $error = "Missing 'cn' attribute"; } elseif (empty($entry['mail'])) { $error = "Missing 'mail' attribute"; } elseif (empty($entry['uniquemember'])) { $error = "Missing 'uniqueMember' attribute"; } else { $result['name'] = $this->attrStringValue($entry, 'cn'); $result['email'] = strtolower($this->attrStringValue($entry, 'mail')); $result['members'] = $this->attrArrayValue($entry, 'uniquemember'); if (!empty($entry['kolaballowsmtpsender'])) { $policy = $this->attrArrayValue($entry, 'kolaballowsmtpsender'); $result['sender_policy'] = $this->parseSenderPolicy($policy); } } return [$result, $error]; } /** * Convert LDAP resource data into Kolab4 "format" */ protected function parseLDAPResource($entry) { $error = null; $result = []; if (empty($entry['cn'])) { $error = "Missing 'cn' attribute"; } elseif (empty($entry['mail'])) { $error = "Missing 'mail' attribute"; } else { $result['name'] = $this->attrStringValue($entry, 'cn'); $result['email'] = strtolower($this->attrStringValue($entry, 'mail')); if (!empty($entry['kolabtargetfolder'])) { $result['folder'] = $this->attrStringValue($entry, 'kolabtargetfolder'); } if (!empty($entry['owner'])) { $result['owner'] = $this->attrStringValue($entry, 'owner'); } if (!empty($entry['kolabinvitationpolicy'])) { $policy = $this->attrArrayValue($entry, 'kolabinvitationpolicy'); $result['invitation_policy'] = $this->parseInvitationPolicy($policy); } } return [$result, $error]; } /** * Convert LDAP shared folder data into Kolab4 "format" */ protected function parseLDAPSharedFolder($entry) { $error = null; $result = []; if (empty($entry['cn'])) { $error = "Missing 'cn' attribute"; } elseif (empty($entry['mail'])) { $error = "Missing 'mail' attribute"; } else { $result['name'] = $this->attrStringValue($entry, 'cn'); $result['email'] = strtolower($this->attrStringValue($entry, 'mail')); if (!empty($entry['kolabfoldertype'])) { $result['type'] = $this->attrStringValue($entry, 'kolabfoldertype'); } if (!empty($entry['kolabtargetfolder'])) { $result['folder'] = $this->attrStringValue($entry, 'kolabtargetfolder'); } if (!empty($entry['acl'])) { $result['acl'] = $this->parseACL($this->attrArrayValue($entry, 'acl')); } if (!empty($entry['alias'])) { $result['aliases'] = $this->attrArrayValue($entry, 'alias'); } } return [$result, $error]; } /** * Convert LDAP user data into Kolab4 "format" */ protected function parseLDAPUser($entry) { $error = null; $result = []; $settingAttrs = [ 'givenname' => 'first_name', 'sn' => 'last_name', 'telephonenumber' => 'phone', 'mailalternateaddress' => 'external_email', 'mobile' => 'phone', 'o' => 'organization', // 'address' => 'billing_address' ]; if (empty($entry['mail'])) { $error = "Missing 'mail' attribute"; } else { $result['email'] = strtolower($this->attrStringValue($entry, 'mail')); $result['settings'] = []; $result['aliases'] = []; foreach ($settingAttrs as $attr => $setting) { if (!empty($entry[$attr])) { $result['settings'][$setting] = $this->attrStringValue($entry, $attr); } } if (!empty($entry['alias'])) { $result['aliases'] = $this->attrArrayValue($entry, 'alias'); } if (!empty($entry['userpassword'])) { $result['password'] = $this->attrStringValue($entry, 'userpassword'); } if (!empty($entry['mailquota'])) { $result['quota'] = $this->attrStringValue($entry, 'mailquota'); } if ($result['email'] == $this->argument('owner')) { $this->ownerDN = $entry['dn']; } } return [$result, $error]; } /** * Print import errors */ protected function printErrors(): bool { if ($this->option('force')) { return false; } $errors = DB::table(self::$table)->whereNotNull('error')->orderBy('id') ->get() ->map(function ($record) { $this->error("ERROR {$record->dn}: {$record->error}"); return $record->id; }) ->all(); return !empty($errors); } /** * Print import warnings (for records that do not have an error specified) */ protected function printWarnings(): void { DB::table(self::$table)->whereNotNull('warning')->whereNull('error')->orderBy('id') ->each(function ($record) { $this->warn("WARNING {$record->dn}: {$record->warning}"); return $record->id; }); } /** * Convert ldap attribute value to an array */ protected static function attrArrayValue($entry, $attribute) { return is_array($entry[$attribute]) ? $entry[$attribute] : [$entry[$attribute]]; } /** * Convert ldap attribute to a string */ protected static function attrStringValue($entry, $attribute) { return is_array($entry[$attribute]) ? $entry[$attribute][0] : $entry[$attribute]; } /** * Resolve a list of user DNs into email addresses. Makes sure * the returned addresses exist in Kolab4 database. */ protected function resolveUserDNs($user_dns): array { // Get email addresses from the import data $users = DB::table(self::$table)->whereIn('dn', $user_dns) ->where('type', 'user') ->whereNull('error') ->get() ->map(function ($user) { $mdata = json_decode($user->data); return $mdata->email; }) // Make sure to skip these with unknown domains ->filter(function ($email) { return $this->domainExists(explode('@', $email)[1]); }) ->all(); // Get email addresses for existing Kolab4 users if (!empty($users)) { $users = \App\User::whereIn('email', $users)->get()->pluck('email')->all(); } return $users; } /** * Validate/convert acl to Kolab4 format */ protected static function parseACL(array $acl): array { $map = [ 'lrswipkxtecdn' => 'full', 'lrs' => 'read-only', 'read' => 'read-only', 'lrswitedn' => 'read-write', ]; $supportedRights = ['full', 'read-only', 'read-write']; foreach ($acl as $idx => $entry) { $parts = explode(',', $entry); $entry = null; if (count($parts) == 2) { $label = trim($parts[0]); $rights = trim($parts[1]); $rights = $map[$rights] ?? $rights; if (in_array($rights, $supportedRights) && ($label === 'anyone' || strpos($label, '@'))) { $entry = "{$label}, {$rights}"; } // TODO: Throw an error or log a warning on unsupported acl entry? } $acl[$idx] = $entry; } return array_values(array_filter($acl)); } /** * Validate/convert invitation policy to Kolab4 format */ protected static function parseInvitationPolicy(array $policies): ?string { foreach ($policies as $policy) { if ($policy == 'ACT_MANUAL') { // 'owner' attribute handling in another place return 'manual'; } if ($policy == 'ACT_ACCEPT_AND_NOTIFY') { break; // use the default 'accept' (null) policy } if ($policy == 'ACT_REJECT') { return 'reject'; } } return null; } /** * Validate/convert sender policy to Kolab4 format */ protected static function parseSenderPolicy(array $rules): array { foreach ($rules as $idx => $rule) { $entry = trim($rule); $rule = null; // 'deny' rules aren't supported if (isset($entry[0]) && $entry[0] !== '-') { $rule = $entry; } $rules[$idx] = $rule; } $rules = array_values(array_filter($rules)); if (!empty($rules) && $rules[count($rules) - 1] != '-') { $rules[] = '-'; } return $rules; } /** * Get/prepare packages/skus information */ protected function preparePackagesAndSkus(): void { // Find the tenant if (empty($this->ownerDN)) { if ($user = $this->getUser($this->argument('owner'))) { $tenant_id = $user->tenant_id; } } // TODO: Tenant id could be a command option if (empty($tenant_id)) { $tenant_id = \config('app.tenant_id'); } // TODO: We should probably make package titles configurable with command options $this->packages = [ 'user' => \App\Package::where('title', 'kolab')->where('tenant_id', $tenant_id)->first(), 'domain' => \App\Package::where('title', 'domain-hosting')->where('tenant_id', $tenant_id)->first(), ]; // Count storage skus $sku = $this->packages['user']->skus()->where('title', 'storage')->first(); $this->packages['quota'] = $sku ? $sku->pivot->qty : 0; $this->packages['storage'] = \App\Sku::where('title', 'storage')->where('tenant_id', $tenant_id)->first(); } /** * Set aliases for for an object */ protected function setObjectAliases($object, array $aliases = []) { if (!empty($aliases)) { // Some users might have alias entry with their main address, remove it $aliases = array_map('strtolower', $aliases); $aliases = array_diff(array_unique($aliases), [$object->email]); // Remove aliases for domains that do not exist if (!empty($aliases)) { $aliases = array_filter( $aliases, function ($alias) { return $this->domainExists(explode('@', $alias)[1]); } ); } if (!empty($aliases)) { $object->setAliases($aliases); } } } /** * Set error message for specified import data record */ protected static function setImportError($id, $error): void { DB::table(self::$table)->where('id', $id)->update(['error' => $error]); } /** * Set warning message for specified import data record */ protected static function setImportWarning($id, $warning): void { DB::table(self::$table)->where('id', $id)->update(['warning' => $warning]); } } diff --git a/src/app/Console/Kernel.php b/src/app/Console/Kernel.php index d34b568d..b2933d98 100644 --- a/src/app/Console/Kernel.php +++ b/src/app/Console/Kernel.php @@ -1,57 +1,48 @@ command('data:import')->dailyAt('05:00'); $schedule->command('wallet:charge')->dailyAt('00:00'); $schedule->command('wallet:charge')->dailyAt('04:00'); $schedule->command('wallet:charge')->dailyAt('08:00'); $schedule->command('wallet:charge')->dailyAt('12:00'); $schedule->command('wallet:charge')->dailyAt('16:00'); $schedule->command('wallet:charge')->dailyAt('20:00'); // this is a laravel 8-ism //$schedule->command('wallet:charge')->everyFourHours(); } /** * Register the commands for the application. * * @return void */ protected function commands() { $this->load(__DIR__ . '/Commands'); if (\app('env') == 'local') { $this->load(__DIR__ . '/Development'); } include base_path('routes/console.php'); } } diff --git a/src/app/Console/ObjectCommand.php b/src/app/Console/ObjectCommand.php index 52750b96..18d129ca 100644 --- a/src/app/Console/ObjectCommand.php +++ b/src/app/Console/ObjectCommand.php @@ -1,80 +1,80 @@ geese). * * @var string */ protected $objectNamePlural; /** * A column name other than the primary key can be used to identify an object, such as 'email' for users, * 'namespace' for domains, and 'title' for SKUs. * * @var string */ protected $objectTitle; /** * Placeholder for column name attributes for objects, from which command-line switches and attribute names can be * generated. * * @var array */ protected $properties; /** * List of cache keys to refresh after updating/creating an object * * @var array */ protected $cacheKeys = []; /** * Reset the cache for specified object using defined cacheKeys. * * @param object $object The object that was updated/created */ protected function cacheRefresh($object): void { foreach ($this->cacheKeys as $cacheKey) { foreach ($object->toArray() as $propKey => $propValue) { if (!is_object($propValue)) { $cacheKey = str_replace('%' . $propKey . '%', $propValue, $cacheKey); } } Cache::forget($cacheKey); } } } diff --git a/src/app/Console/ObjectCreateCommand.php b/src/app/Console/ObjectCreateCommand.php index 81ef536d..76f9a8b1 100644 --- a/src/app/Console/ObjectCreateCommand.php +++ b/src/app/Console/ObjectCreateCommand.php @@ -1,67 +1,65 @@ description = "Create a {$this->objectName}"; $this->signature = sprintf( "%s%s:create", $this->commandPrefix ? $this->commandPrefix . ":" : "", $this->objectName ); $class = new $this->objectClass(); foreach ($class->getFillable() as $fillable) { $this->signature .= " {--{$fillable}=}"; } parent::__construct(); } public function getProperties() { if (!empty($this->properties)) { return $this->properties; } $class = new $this->objectClass(); $this->properties = []; foreach ($class->getFillable() as $fillable) { $this->properties[$fillable] = $this->option($fillable); } return $this->properties; } /** * Execute the console command. * * @return mixed */ public function handle() { $this->getProperties(); $class = new $this->objectClass(); $object = $this->objectClass::create($this->properties); if ($object) { $this->cacheRefresh($object); $this->info($object->{$class->getKeyName()}); } else { $this->error("Object could not be created."); } - - return $object; } } diff --git a/src/app/Discount.php b/src/app/Discount.php index 1061d1a4..2dc875b4 100644 --- a/src/app/Discount.php +++ b/src/app/Discount.php @@ -1,72 +1,67 @@ 'integer', ]; - protected $fillable = [ - 'active', - 'code', - 'description', - 'discount', - ]; + /** @var array The attributes that are mass assignable */ + protected $fillable = ['active', 'code', 'description', 'discount']; + + /** @var array Translatable properties */ + public $translatable = ['description']; - /** @var array Translatable properties */ - public $translatable = [ - 'description', - ]; /** * Discount value mutator * * @throws \Exception */ public function setDiscountAttribute($discount) { $discount = (int) $discount; if ($discount < 0) { \Log::warning("Expecting a discount rate >= 0"); $discount = 0; } if ($discount > 100) { \Log::warning("Expecting a discount rate <= 100"); $discount = 100; } $this->attributes['discount'] = $discount; } /** * List of wallets with this discount assigned. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { - return $this->hasMany('App\Wallet'); + return $this->hasMany(Wallet::class); } } diff --git a/src/app/Domain.php b/src/app/Domain.php index d60f0e82..a97e681b 100644 --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -1,417 +1,420 @@ The attributes that should be cast */ + protected $casts = [ + 'created_at' => 'datetime:Y-m-d H:i:s', + 'deleted_at' => 'datetime:Y-m-d H:i:s', + 'updated_at' => 'datetime:Y-m-d H:i:s', ]; + /** @var array The attributes that are mass assignable */ + protected $fillable = ['namespace', 'status', 'type']; + /** * Assign a package to a domain. The domain should not belong to any existing entitlements. * * @param \App\Package $package The package to assign. * @param \App\User $user The wallet owner. * * @return \App\Domain Self */ public function assignPackage($package, $user) { // If this domain is public it can not be assigned to a user. if ($this->isPublic()) { return $this; } // See if this domain is already owned by another user. $wallet = $this->wallet(); if ($wallet) { \Log::error( "Domain {$this->namespace} is already assigned to {$wallet->owner->email}" ); return $this; } return $this->assignPackageAndWallet($package, $user->wallets()->first()); } /** * Return list of public+active domain names (for current tenant) */ public static function getPublicDomains(): array { return self::withEnvTenantContext() - ->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC)) - ->get(['namespace'])->pluck('namespace')->toArray(); + ->where('type', '&', Domain::TYPE_PUBLIC) + ->pluck('namespace')->all(); } /** * Returns whether this domain is confirmed the ownership of. * * @return bool */ public function isConfirmed(): bool { return ($this->status & self::STATUS_CONFIRMED) > 0; } /** * Returns whether this domain is registered with us. * * @return bool */ public function isExternal(): bool { return ($this->type & self::TYPE_EXTERNAL) > 0; } /** * Returns whether this domain is hosted with us. * * @return bool */ public function isHosted(): bool { return ($this->type & self::TYPE_HOSTED) > 0; } /** * Returns whether this domain is public. * * @return bool */ public function isPublic(): bool { return ($this->type & self::TYPE_PUBLIC) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isVerified(): bool { return ($this->status & self::STATUS_VERIFIED) > 0; } /** * Ensure the namespace is appropriately cased. */ public function setNamespaceAttribute($namespace) { $this->attributes['namespace'] = strtolower($namespace); } /** * Domain status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_CONFIRMED, self::STATUS_VERIFIED, self::STATUS_LDAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid domain status: {$status}"); } if ($this->isPublic()) { $this->attributes['status'] = $new_status; return; } if ($new_status & self::STATUS_CONFIRMED) { // if we have confirmed ownership of or management access to the domain, then we have // also confirmed the domain exists in DNS. $new_status |= self::STATUS_VERIFIED; $new_status |= self::STATUS_ACTIVE; } if ($new_status & self::STATUS_DELETED && $new_status & self::STATUS_ACTIVE) { $new_status ^= self::STATUS_ACTIVE; } if ($new_status & self::STATUS_SUSPENDED && $new_status & self::STATUS_ACTIVE) { $new_status ^= self::STATUS_ACTIVE; } // if the domain is now active, it is not new anymore. if ($new_status & self::STATUS_ACTIVE && $new_status & self::STATUS_NEW) { $new_status ^= self::STATUS_NEW; } $this->attributes['status'] = $new_status; } /** * Ownership verification by checking for a TXT (or CNAME) record * in the domain's DNS (that matches the verification hash). * * @return bool True if verification was successful, false otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function confirm(): bool { if ($this->isConfirmed()) { return true; } $hash = $this->hash(self::HASH_TEXT); $confirmed = false; // Get DNS records and find a matching TXT entry $records = \dns_get_record($this->namespace, DNS_TXT); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $record) { if ($record['txt'] === $hash) { $confirmed = true; break; } } // Get DNS records and find a matching CNAME entry // Note: some servers resolve every non-existing name // so we need to define left and right side of the CNAME record // i.e.: kolab-verify IN CNAME .domain.tld. if (!$confirmed) { $cname = $this->hash(self::HASH_CODE) . '.' . $this->namespace; $records = \dns_get_record('kolab-verify.' . $this->namespace, DNS_CNAME); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $records) { if ($records['target'] === $cname) { $confirmed = true; break; } } } if ($confirmed) { $this->status |= Domain::STATUS_CONFIRMED; $this->save(); } return $confirmed; } /** * Generate a verification hash for this domain * * @param int $mod One of: HASH_CNAME, HASH_CODE (Default), HASH_TEXT * * @return string Verification hash */ public function hash($mod = null): string { $cname = 'kolab-verify'; if ($mod === self::HASH_CNAME) { return $cname; } $hash = \md5('hkccp-verify-' . $this->namespace); return $mod === self::HASH_TEXT ? "$cname=$hash" : $hash; } /** * Checks if there are any objects (users/aliases/groups) in a domain. * Note: Public domains are always reported not empty. * * @return bool True if there are no objects assigned, False otherwise */ public function isEmpty(): bool { if ($this->isPublic()) { return false; } // FIXME: These queries will not use indexes, so maybe we should consider // wallet/entitlements to search in objects that belong to this domain account? $suffix = '@' . $this->namespace; $suffixLen = strlen($suffix); return !( - \App\User::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() - || \App\UserAlias::whereRaw('substr(alias, ?) = ?', [-$suffixLen, $suffix])->exists() - || \App\Group::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() - || \App\Resource::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() - || \App\SharedFolder::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() + User::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() + || UserAlias::whereRaw('substr(alias, ?) = ?', [-$suffixLen, $suffix])->exists() + || Group::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() + || Resource::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() + || SharedFolder::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() ); } /** * Unsuspend this domain. * * The domain is unsuspended through either of the following courses of actions; * * * The account balance has been topped up, or * * a suspected spammer has resolved their issues, or * * the command-line is triggered. * * Therefore, we can also confidently set the domain status to 'active' should the ownership of or management * access to have been confirmed before. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= Domain::STATUS_SUSPENDED; if ($this->isConfirmed() && $this->isVerified()) { $this->status |= Domain::STATUS_ACTIVE; } $this->save(); } /** * List the users of a domain, so long as the domain is not a public registration domain. * Note: It returns only users with a mailbox. * * @return \App\User[] A list of users */ public function users(): array { if ($this->isPublic()) { return []; } $wallet = $this->wallet(); if (!$wallet) { return []; } - $mailboxSKU = \App\Sku::withObjectTenantContext($this)->where('title', 'mailbox')->first(); + $mailboxSKU = Sku::withObjectTenantContext($this)->where('title', 'mailbox')->first(); if (!$mailboxSKU) { \Log::error("No mailbox SKU available."); return []; } return $wallet->entitlements() - ->where('entitleable_type', \App\User::class) + ->where('entitleable_type', User::class) ->where('sku_id', $mailboxSKU->id) ->get() ->pluck('entitleable') ->all(); } /** * Verify if a domain exists in DNS * * @return bool True if registered, False otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function verify(): bool { if ($this->isVerified()) { return true; } $records = \dns_get_record($this->namespace, DNS_ANY); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } // It may happen that result contains other domains depending on the host DNS setup // that's why in_array() and not just !empty() if (in_array($this->namespace, array_column($records, 'host'))) { $this->status |= Domain::STATUS_VERIFIED; $this->save(); return true; } return false; } } diff --git a/src/app/DomainSetting.php b/src/app/DomainSetting.php index 0cc55b2b..d16a184c 100644 --- a/src/app/DomainSetting.php +++ b/src/app/DomainSetting.php @@ -1,34 +1,33 @@ The attributes that are mass assignable */ + protected $fillable = ['domain_id', 'key', 'value']; /** * The domain to which this setting belongs. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function domain() { return $this->belongsTo( - '\App\Domain', + Domain::class, 'domain_id', /* local */ 'id' /* remote */ ); } } diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php index ec14dd04..82674cf9 100644 --- a/src/app/Entitlement.php +++ b/src/app/Entitlement.php @@ -1,174 +1,171 @@ The attributes that are mass assignable */ protected $fillable = [ 'sku_id', 'wallet_id', 'entitleable_id', 'entitleable_type', 'cost', 'description', 'fee', ]; + /** @var array The attributes that should be cast */ protected $casts = [ 'cost' => 'integer', 'fee' => 'integer' ]; /** * Return the costs per day for this entitlement. * * @return float */ public function costsPerDay() { if ($this->cost == 0) { return (float) 0; } $discount = $this->wallet->getDiscountRate(); $daysInLastMonth = \App\Utils::daysInLastMonth(); $costsPerDay = (float) ($this->cost * $discount) / $daysInLastMonth; return $costsPerDay; } /** * Create a transaction record for this entitlement. * * @param string $type The type of transaction ('created', 'billed', 'deleted'), but use the * \App\Transaction constants. * @param int $amount The amount involved in cents * * @return string The transaction ID */ public function createTransaction($type, $amount = null) { - $transaction = \App\Transaction::create( + $transaction = Transaction::create( [ 'object_id' => $this->id, - 'object_type' => \App\Entitlement::class, + 'object_type' => Entitlement::class, 'type' => $type, 'amount' => $amount ] ); return $transaction->id; } /** * Principally entitleable object such as Domain, User, Group. * Note that it may be trashed (soft-deleted). * * @return mixed */ public function entitleable() { return $this->morphTo()->withTrashed(); // @phpstan-ignore-line } /** * Returns entitleable object title (e.g. email or domain name). * * @return string|null An object title/name */ public function entitleableTitle(): ?string { - if ($this->entitleable instanceof \App\Domain) { + if ($this->entitleable instanceof Domain) { return $this->entitleable->namespace; } return $this->entitleable->email; } /** * Simplified Entitlement/SKU information for a specified entitleable object * * @param object $object Entitleable object * * @return array Skus list with some metadata */ public static function objectEntitlementsSummary($object): array { $skus = []; // TODO: I agree this format may need to be extended in future foreach ($object->entitlements as $ent) { $sku = $ent->sku; if (!isset($skus[$sku->id])) { $skus[$sku->id] = ['costs' => [], 'count' => 0]; } $skus[$sku->id]['count']++; $skus[$sku->id]['costs'][] = $ent->cost; } return $skus; } /** * The SKU concerned. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function sku() { - return $this->belongsTo('App\Sku'); + return $this->belongsTo(Sku::class); } /** * The wallet this entitlement is being billed to * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function wallet() { - return $this->belongsTo('App\Wallet'); + return $this->belongsTo(Wallet::class); } /** * Cost mutator. Make sure cost is integer. */ public function setCostAttribute($cost): void { $this->attributes['cost'] = round($cost); } } diff --git a/src/app/Exceptions/Handler.php b/src/app/Exceptions/Handler.php index 01c463bb..fa2beae0 100644 --- a/src/app/Exceptions/Handler.php +++ b/src/app/Exceptions/Handler.php @@ -1,78 +1,54 @@ 0) { - DB::rollBack(); - } - - return parent::render($request, $exception); + $this->reportable(function (\Throwable $e) { + // Rollback uncommitted transactions + while (DB::transactionLevel() > 0) { + DB::rollBack(); + } + }); } /** * Convert an authentication exception into a response. * * @param \Illuminate\Http\Request $request * @param \Illuminate\Auth\AuthenticationException $exception * * @return \Symfony\Component\HttpFoundation\Response */ protected function unauthenticated($request, AuthenticationException $exception) { if ($request->expectsJson()) { return response()->json(['status' => 'error', 'message' => $exception->getMessage()], 401); } abort(401); } } diff --git a/src/app/Group.php b/src/app/Group.php index e56a625c..7450c1a8 100644 --- a/src/app/Group.php +++ b/src/app/Group.php @@ -1,81 +1,88 @@ The attributes that should be cast */ + protected $casts = [ + 'created_at' => 'datetime:Y-m-d H:i:s', + 'deleted_at' => 'datetime:Y-m-d H:i:s', + 'updated_at' => 'datetime:Y-m-d H:i:s', + ]; + + /** @var array The attributes that are mass assignable */ protected $fillable = [ 'email', 'members', 'name', 'status', ]; /** * Group members propert accessor. Converts internal comma-separated list into an array * * @param string $members Comma-separated list of email addresses * * @return array Email addresses of the group members, as an array */ public function getMembersAttribute($members): array { return $members ? explode(',', $members) : []; } /** * Ensure the members are appropriately formatted. * * @param array $members Email addresses of the group members */ public function setMembersAttribute(array $members): void { $members = array_unique(array_filter(array_map('strtolower', $members))); sort($members); $this->attributes['members'] = implode(',', $members); } } diff --git a/src/app/GroupSetting.php b/src/app/GroupSetting.php index 904bade7..5685000a 100644 --- a/src/app/GroupSetting.php +++ b/src/app/GroupSetting.php @@ -1,30 +1,29 @@ The attributes that are mass assignable */ + protected $fillable = ['group_id', 'key', 'value']; /** * The group to which this setting belongs. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function group() { - return $this->belongsTo(\App\Group::class, 'group_id', 'id'); + return $this->belongsTo(Group::class, 'group_id', 'id'); } } diff --git a/src/app/Http/Controllers/API/PasswordResetController.php b/src/app/Http/Controllers/API/PasswordResetController.php index 0274dd8e..415cdbd7 100644 --- a/src/app/Http/Controllers/API/PasswordResetController.php +++ b/src/app/Http/Controllers/API/PasswordResetController.php @@ -1,208 +1,204 @@ all(), ['email' => 'required|email']); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Find a user by email $user = User::findByEmail($request->email); if (!$user) { $errors = ['email' => \trans('validation.usernotexists')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } if (!$user->getSetting('external_email')) { $errors = ['email' => \trans('validation.noextemail')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Generate the verification code $code = new VerificationCode(['mode' => 'password-reset']); $user->verificationcodes()->save($code); // Send email/sms message PasswordResetEmail::dispatch($code); return response()->json(['status' => 'success', 'code' => $code->code]); } /** * Validation of the verification code. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function verify(Request $request) { // Validate the request args $v = Validator::make( $request->all(), [ 'code' => 'required', 'short_code' => 'required', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Validate the verification code $code = VerificationCode::where('code', $request->code)->where('active', true)->first(); if ( empty($code) || $code->isExpired() || $code->mode !== 'password-reset' || Str::upper($request->short_code) !== Str::upper($code->short_code) ) { $errors = ['short_code' => "The code is invalid or expired."]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } // For last-step remember the code object, so we can delete it // with single SQL query (->delete()) instead of two (::destroy()) - $this->code = $code; + $request->code = $code; return response()->json([ 'status' => 'success', // we need user's ID for e.g. password policy checks 'userId' => $code->user_id, ]); } /** * Password change * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function reset(Request $request) { $v = $this->verify($request); if ($v->status() !== 200) { return $v; } - $user = $this->code->user; + $user = $request->code->user; // Validate the password $v = Validator::make( $request->all(), ['password' => ['required', 'confirmed', new Password($user->walletOwner())]] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Change the user password $user->setPasswordAttribute($request->password); $user->save(); // Remove the verification code - $this->code->delete(); + $request->code->delete(); return AuthController::logonResponse($user, $request->password); } /** * Create a verification code for the current user. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function codeCreate(Request $request) { // Generate the verification code $code = new VerificationCode(); $code->mode = 'password-reset'; // These codes are valid for 24 hours $code->expires_at = now()->addHours(24); // The code is inactive until it is submitted via a different endpoint $code->active = false; $this->guard()->user()->verificationcodes()->save($code); return response()->json([ 'status' => 'success', 'code' => $code->code, 'short_code' => $code->short_code, 'expires_at' => $code->expires_at->toDateTimeString(), ]); } /** * Delete a verification code. * * @param string $id Code identifier * * @return \Illuminate\Http\JsonResponse The response */ public function codeDelete($id) { // Accept - input if (strpos($id, '-')) { $id = explode('-', $id)[1]; } $code = VerificationCode::find($id); if (!$code) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); if (empty($code->user) || !$current_user->canUpdate($code->user)) { return $this->errorResponse(403); } $code->delete(); return response()->json([ 'status' => 'success', 'message' => \trans('app.password-reset-code-delete-success'), ]); } } diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php index f1f2cbe9..1b82a692 100644 --- a/src/app/Http/Controllers/API/SignupController.php +++ b/src/app/Http/Controllers/API/SignupController.php @@ -1,449 +1,444 @@ orderByDesc('title')->get() ->map(function ($plan) use (&$plans) { $plans[] = [ 'title' => $plan->title, 'name' => $plan->name, 'button' => \trans('app.planbutton', ['plan' => $plan->name]), 'description' => $plan->description, ]; }); return response()->json(['status' => 'success', 'plans' => $plans]); } /** * Starts signup process. * * Verifies user name and email/phone, sends verification email/sms message. * Returns the verification code. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function init(Request $request) { // Check required fields $v = Validator::make( $request->all(), [ 'email' => 'required', 'first_name' => 'max:128', 'last_name' => 'max:128', 'plan' => 'nullable|alpha_num|max:128', 'voucher' => 'max:32', ] ); $is_phone = false; $errors = $v->fails() ? $v->errors()->toArray() : []; // Validate user email (or phone) if (empty($errors['email'])) { if ($error = $this->validatePhoneOrEmail($request->email, $is_phone)) { $errors['email'] = $error; } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Generate the verification code $code = SignupCode::create([ 'email' => $request->email, 'first_name' => $request->first_name, 'last_name' => $request->last_name, 'plan' => $request->plan, 'voucher' => $request->voucher, ]); // Send email/sms message if ($is_phone) { SignupVerificationSMS::dispatch($code); } else { SignupVerificationEmail::dispatch($code); } return response()->json(['status' => 'success', 'code' => $code->code]); } /** * Returns signup invitation information. * * @param string $id Signup invitation identifier * * @return \Illuminate\Http\JsonResponse|void */ public function invitation($id) { $invitation = SignupInvitation::withEnvTenantContext()->find($id); if (empty($invitation) || $invitation->isCompleted()) { return $this->errorResponse(404); } $has_domain = $this->getPlan()->hasDomain(); $result = [ 'id' => $id, 'is_domain' => $has_domain, 'domains' => $has_domain ? [] : Domain::getPublicDomains(), ]; return response()->json($result); } /** * Validation of the verification code. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function verify(Request $request) { // Validate the request args $v = Validator::make( $request->all(), [ 'code' => 'required', 'short_code' => 'required', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Validate the verification code $code = SignupCode::find($request->code); if ( empty($code) || $code->isExpired() || Str::upper($request->short_code) !== Str::upper($code->short_code) ) { $errors = ['short_code' => "The code is invalid or expired."]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } // For signup last-step mode remember the code object, so we can delete it // with single SQL query (->delete()) instead of two (::destroy()) - $this->code = $code; + $request->code = $code; $has_domain = $this->getPlan()->hasDomain(); // Return user name and email/phone/voucher from the codes database, // domains list for selection and "plan type" flag return response()->json([ 'status' => 'success', 'email' => $code->email, 'first_name' => $code->first_name, 'last_name' => $code->last_name, 'voucher' => $code->voucher, 'is_domain' => $has_domain, 'domains' => $has_domain ? [] : Domain::getPublicDomains(), ]); } /** * Finishes the signup process by creating the user account. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function signup(Request $request) { // Validate input $v = Validator::make( $request->all(), [ 'login' => 'required|min:2', 'password' => ['required', 'confirmed', new Password()], 'domain' => 'required', 'voucher' => 'max:32', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Signup via invitation if ($request->invitation) { $invitation = SignupInvitation::withEnvTenantContext()->find($request->invitation); if (empty($invitation) || $invitation->isCompleted()) { return $this->errorResponse(404); } // Check required fields $v = Validator::make( $request->all(), [ 'first_name' => 'max:128', 'last_name' => 'max:128', 'voucher' => 'max:32', ] ); $errors = $v->fails() ? $v->errors()->toArray() : []; if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $settings = [ 'external_email' => $invitation->email, 'first_name' => $request->first_name, 'last_name' => $request->last_name, ]; } else { // Validate verification codes (again) $v = $this->verify($request); if ($v->status() !== 200) { return $v; } // Get user name/email from the verification code database $code_data = $v->getData(); $settings = [ 'external_email' => $code_data->email, 'first_name' => $code_data->first_name, 'last_name' => $code_data->last_name, ]; } // Find the voucher discount if ($request->voucher) { $discount = Discount::where('code', \strtoupper($request->voucher)) ->where('active', true)->first(); if (!$discount) { $errors = ['voucher' => \trans('validation.voucherinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } } // Get the plan $plan = $this->getPlan(); $is_domain = $plan->hasDomain(); $login = $request->login; $domain_name = $request->domain; // Validate login if ($errors = self::validateLogin($login, $domain_name, $is_domain)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // We allow only ASCII, so we can safely lower-case the email address $login = Str::lower($login); $domain_name = Str::lower($domain_name); $domain = null; DB::beginTransaction(); // Create domain record if ($is_domain) { $domain = Domain::create([ 'namespace' => $domain_name, 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); } // Create user record $user = User::create([ 'email' => $login . '@' . $domain_name, 'password' => $request->password, ]); if (!empty($discount)) { $wallet = $user->wallets()->first(); $wallet->discount()->associate($discount); $wallet->save(); } $user->assignPlan($plan, $domain); // Save the external email and plan in user settings $user->setSettings($settings); // Update the invitation if (!empty($invitation)) { $invitation->status = SignupInvitation::STATUS_COMPLETED; $invitation->user_id = $user->id; $invitation->save(); } // Remove the verification code - if ($this->code) { - $this->code->delete(); + if ($request->code) { + $request->code->delete(); } DB::commit(); return AuthController::logonResponse($user, $request->password); } /** * Returns plan for the signup process * * @returns \App\Plan Plan object selected for current signup process */ protected function getPlan() { - if (!$this->plan) { + $request = request(); + + if (!$request->plan || !$request->plan instanceof Plan) { // Get the plan if specified and exists... - if ($this->code && $this->code->plan) { - $plan = Plan::withEnvTenantContext()->where('title', $this->code->plan)->first(); + if ($request->code && $request->code->plan) { + $plan = Plan::withEnvTenantContext()->where('title', $request->code->plan)->first(); } // ...otherwise use the default plan if (empty($plan)) { // TODO: Get default plan title from config $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); } - $this->plan = $plan; + $request->plan = $plan; } - return $this->plan; + return $request->plan; } /** * Checks if the input string is a valid email address or a phone number * * @param string $input Email address or phone number * @param bool $is_phone Will have been set to True if the string is valid phone number * * @return string Error message on validation error */ protected static function validatePhoneOrEmail($input, &$is_phone = false): ?string { $is_phone = false; $v = Validator::make( ['email' => $input], ['email' => ['required', 'string', new SignupExternalEmail()]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // TODO: Phone number support /* $input = str_replace(array('-', ' '), '', $input); if (!preg_match('/^\+?[0-9]{9,12}$/', $input)) { return \trans('validation.noemailorphone'); } $is_phone = true; */ return null; } /** * Login (kolab identity) validation * * @param string $login Login (local part of an email address) * @param string $domain Domain name * @param bool $external Enables additional checks for domain part * * @return array Error messages on validation error */ protected static function validateLogin($login, $domain, $external = false): ?array { // Validate login part alone $v = Validator::make( ['login' => $login], ['login' => ['required', 'string', new UserEmailLocal($external)]] ); if ($v->fails()) { return ['login' => $v->errors()->toArray()['login'][0]]; } $domains = $external ? null : Domain::getPublicDomains(); // Validate the domain $v = Validator::make( ['domain' => $domain], ['domain' => ['required', 'string', new UserEmailDomain($domains)]] ); if ($v->fails()) { return ['domain' => $v->errors()->toArray()['domain'][0]]; } $domain = Str::lower($domain); // Check if domain is already registered with us if ($external) { if (Domain::where('namespace', $domain)->first()) { return ['domain' => \trans('validation.domainexists')]; } } // Check if user with specified login already exists $email = $login . '@' . $domain; if (User::emailExists($email) || User::aliasExists($email) || \App\Group::emailExists($email)) { return ['login' => \trans('validation.loginexists')]; } return null; } } diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php index 929323fe..7a31e6dc 100644 --- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php @@ -1,346 +1,348 @@ errorResponse(404); } /** * Searching of user accounts. * * @return \Illuminate\Http\JsonResponse */ public function index() { $search = trim(request()->input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { $owner = User::find($owner); if ($owner) { $result = $owner->users(false)->orderBy('email')->get(); } } elseif (strpos($search, '@')) { // Search by email $result = User::withTrashed()->where('email', $search) ->orderBy('email') ->get(); if ($result->isEmpty()) { // Search by an alias $user_ids = \App\UserAlias::where('alias', $search)->get()->pluck('user_id'); // Search by an external email $ext_user_ids = \App\UserSetting::where('key', 'external_email') ->where('value', $search) ->get() ->pluck('user_id'); $user_ids = $user_ids->merge($ext_user_ids)->unique(); // Search by an email of a group, resource, shared folder, etc. if ($group = \App\Group::withTrashed()->where('email', $search)->first()) { $user_ids = $user_ids->merge([$group->wallet()->user_id])->unique(); } elseif ($resource = \App\Resource::withTrashed()->where('email', $search)->first()) { $user_ids = $user_ids->merge([$resource->wallet()->user_id])->unique(); } elseif ($folder = \App\SharedFolder::withTrashed()->where('email', $search)->first()) { $user_ids = $user_ids->merge([$folder->wallet()->user_id])->unique(); } elseif ($alias = \App\SharedFolderAlias::where('alias', $search)->first()) { $user_ids = $user_ids->merge([$alias->sharedFolder->wallet()->user_id])->unique(); } if (!$user_ids->isEmpty()) { $result = User::withTrashed()->whereIn('id', $user_ids) ->orderBy('email') ->get(); } } } elseif (is_numeric($search)) { // Search by user ID $user = User::withTrashed()->where('id', $search) ->first(); if ($user) { $result->push($user); } } elseif (strpos($search, '.') !== false) { // Search by domain $domain = Domain::withTrashed()->where('namespace', $search) ->first(); if ($domain) { if (($wallet = $domain->wallet()) && ($owner = $wallet->owner()->withTrashed()->first())) { $result->push($owner); } } // A mollie customer ID } elseif (substr($search, 0, 4) == 'cst_') { $setting = \App\WalletSetting::where( [ 'key' => 'mollie_id', 'value' => $search ] )->first(); if ($setting) { if ($wallet = $setting->wallet) { if ($owner = $wallet->owner()->withTrashed()->first()) { $result->push($owner); } } } // A mollie transaction ID } elseif (substr($search, 0, 3) == 'tr_') { $payment = \App\Payment::find($search); if ($payment) { if ($owner = $payment->wallet->owner()->withTrashed()->first()) { $result->push($owner); } } } elseif (!empty($search)) { $wallet = Wallet::find($search); if ($wallet) { if ($owner = $wallet->owner()->withTrashed()->first()) { $result->push($owner); } } } // Process the result $result = $result->map( function ($user) { return $this->objectToClient($user, true); } ); $result = [ 'list' => $result, 'count' => count($result), 'message' => \trans('app.search-foundxusers', ['x' => count($result)]), ]; return response()->json($result); } /** * Reset 2-Factor Authentication for the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function reset2FA(Request $request, $id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(403); } $sku = Sku::withObjectTenantContext($user)->where('title', '2fa')->first(); // Note: we do select first, so the observer can delete // 2FA preferences from Roundcube database, so don't // be tempted to replace first() with delete() below $entitlement = $user->entitlements()->where('sku_id', $sku->id)->first(); $entitlement->delete(); return response()->json([ 'status' => 'success', 'message' => \trans('app.user-reset-2fa-success'), ]); } /** * Set/Add a SKU for the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * @param string $sku SKU title * * @return \Illuminate\Http\JsonResponse The response */ public function setSku(Request $request, $id, $sku) { // For now we allow adding the 'beta' SKU only if ($sku != 'beta') { return $this->errorResponse(404); } $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(403); } $sku = Sku::withObjectTenantContext($user)->where('title', $sku)->first(); if (!$sku) { return $this->errorResponse(404); } if ($user->entitlements()->where('sku_id', $sku->id)->first()) { return $this->errorResponse(422, \trans('app.user-set-sku-already-exists')); } $user->assignSku($sku); + + /** @var \App\Entitlement $entitlement */ $entitlement = $user->entitlements()->where('sku_id', $sku->id)->first(); return response()->json([ 'status' => 'success', 'message' => \trans('app.user-set-sku-success'), 'sku' => [ 'cost' => $entitlement->cost, 'name' => $sku->name, 'id' => $sku->id, ] ]); } /** * Create a new user record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { return $this->errorResponse(404); } /** * Suspend the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function suspend(Request $request, $id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(403); } $user->suspend(); return response()->json([ 'status' => 'success', 'message' => \trans('app.user-suspend-success'), ]); } /** * Un-Suspend the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function unsuspend(Request $request, $id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(403); } $user->unsuspend(); return response()->json([ 'status' => 'success', 'message' => \trans('app.user-unsuspend-success'), ]); } /** * Update user data. * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(403); } // For now admins can change only user external email address $rules = []; if (array_key_exists('external_email', $request->input())) { $rules['external_email'] = 'email'; } // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Update user settings $settings = $request->only(array_keys($rules)); if (!empty($settings)) { $user->setSettings($settings); } return response()->json([ 'status' => 'success', 'message' => \trans('app.user-update-success'), ]); } } diff --git a/src/app/Http/Controllers/API/V4/AuthAttemptsController.php b/src/app/Http/Controllers/API/V4/AuthAttemptsController.php index ba885e4a..81abe2ff 100644 --- a/src/app/Http/Controllers/API/V4/AuthAttemptsController.php +++ b/src/app/Http/Controllers/API/V4/AuthAttemptsController.php @@ -1,120 +1,119 @@ errorResponse(404); } $user = $this->guard()->user(); if ($user->id != $authAttempt->user_id) { return $this->errorResponse(403); } \Log::debug("Confirm on {$authAttempt->id}"); $authAttempt->accept(); return response()->json([], 200); } /** * Deny the authentication attempt. * * @param string $id Id of AuthAttempt attempt * * @return \Illuminate\Http\JsonResponse */ public function deny($id) { $authAttempt = AuthAttempt::find($id); if (!$authAttempt) { return $this->errorResponse(404); } $user = $this->guard()->user(); if ($user->id != $authAttempt->user_id) { return $this->errorResponse(403); } \Log::debug("Deny on {$authAttempt->id}"); $authAttempt->deny(); return response()->json([], 200); } /** * Return details of authentication attempt. * * @param string $id Id of AuthAttempt attempt * * @return \Illuminate\Http\JsonResponse */ public function details($id) { $authAttempt = AuthAttempt::find($id); if (!$authAttempt) { return $this->errorResponse(404); } $user = $this->guard()->user(); if ($user->id != $authAttempt->user_id) { return $this->errorResponse(403); } return response()->json([ 'status' => 'success', 'username' => $user->email, 'country' => \App\Utils::countryForIP($authAttempt->ip), 'entry' => $authAttempt->toArray() ]); } /** * Listing of client authAttempts. * * All authAttempt attempts from the current user * * @return \Illuminate\Http\JsonResponse */ public function index(Request $request) { $user = $this->guard()->user(); $pageSize = 10; $page = intval($request->input('page')) ?: 1; $hasMore = false; $result = \App\AuthAttempt::where('user_id', $user->id) ->orderBy('updated_at', 'desc') ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } $result = $result->map(function ($authAttempt) { return $authAttempt->toArray(); }); return response()->json($result); } } diff --git a/src/app/Http/Controllers/API/V4/WalletsController.php b/src/app/Http/Controllers/API/V4/WalletsController.php index 0c99857a..d97b1787 100644 --- a/src/app/Http/Controllers/API/V4/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/WalletsController.php @@ -1,273 +1,274 @@ checkTenant($wallet->owner)) { return $this->errorResponse(404); } // Only owner (or admin) has access to the wallet if (!$this->guard()->user()->canRead($wallet)) { return $this->errorResponse(403); } $result = $wallet->toArray(); $provider = \App\Providers\PaymentProvider::factory($wallet); $result['provider'] = $provider->name(); $result['notice'] = $this->getWalletNotice($wallet); return response()->json($result); } /** * Download a receipt in pdf format. * * @param string $id Wallet identifier * @param string $receipt Receipt identifier (YYYY-MM) * * @return \Illuminate\Http\Response */ public function receiptDownload($id, $receipt) { $wallet = Wallet::find($id); if (empty($wallet) || !$this->checkTenant($wallet->owner)) { abort(404); } // Only owner (or admin) has access to the wallet if (!$this->guard()->user()->canRead($wallet)) { abort(403); } list ($year, $month) = explode('-', $receipt); if (empty($year) || empty($month) || $year < 2000 || $month < 1 || $month > 12) { abort(404); } if ($receipt >= date('Y-m')) { abort(404); } $params = [ 'id' => sprintf('%04d-%02d', $year, $month), 'site' => \config('app.name') ]; $filename = \trans('documents.receipt-filename', $params); $receipt = new \App\Documents\Receipt($wallet, (int) $year, (int) $month); $content = $receipt->pdfOutput(); return response($content) ->withHeaders([ 'Content-Type' => 'application/pdf', 'Content-Disposition' => 'attachment; filename="' . $filename . '"', 'Content-Length' => strlen($content), ]); } /** * Fetch wallet receipts list. * * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse */ public function receipts($id) { $wallet = Wallet::find($id); if (empty($wallet) || !$this->checkTenant($wallet->owner)) { return $this->errorResponse(404); } // Only owner (or admin) has access to the wallet if (!$this->guard()->user()->canRead($wallet)) { return $this->errorResponse(403); } $result = $wallet->payments() ->selectRaw('distinct date_format(updated_at, "%Y-%m") as ident') ->where('status', PaymentProvider::STATUS_PAID) ->where('amount', '<>', 0) ->orderBy('ident', 'desc') ->get() ->whereNotIn('ident', [date('Y-m')]) // exclude current month ->pluck('ident'); return response()->json([ 'status' => 'success', 'list' => $result, 'count' => count($result), 'hasMore' => false, 'page' => 1, ]); } /** * Fetch wallet transactions. * * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse */ public function transactions($id) { $wallet = Wallet::find($id); if (empty($wallet) || !$this->checkTenant($wallet->owner)) { return $this->errorResponse(404); } // Only owner (or admin) has access to the wallet if (!$this->guard()->user()->canRead($wallet)) { return $this->errorResponse(403); } $pageSize = 10; $page = intval(request()->input('page')) ?: 1; $hasMore = false; $isAdmin = $this instanceof Admin\WalletsController; if ($transaction = request()->input('transaction')) { // Get sub-transactions for the specified transaction ID, first // check access rights to the transaction's wallet + /** @var ?\App\Transaction $transaction */ $transaction = $wallet->transactions()->where('id', $transaction)->first(); if (!$transaction) { return $this->errorResponse(404); } $result = Transaction::where('transaction_id', $transaction->id)->get(); } else { // Get main transactions (paged) $result = $wallet->transactions() // FIXME: Do we know which (type of) transaction has sub-transactions // without the sub-query? ->selectRaw("*, (SELECT count(*) FROM transactions sub " . "WHERE sub.transaction_id = transactions.id) AS cnt") ->whereNull('transaction_id') ->latest() ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } } $result = $result->map(function ($item) use ($isAdmin, $wallet) { $entry = [ 'id' => $item->id, 'createdAt' => $item->created_at->format('Y-m-d H:i'), 'type' => $item->type, 'description' => $item->shortDescription(), 'amount' => $item->amount, 'currency' => $wallet->currency, 'hasDetails' => !empty($item->cnt), ]; if ($isAdmin && $item->user_email) { $entry['user'] = $item->user_email; } return $entry; }); return response()->json([ 'status' => 'success', 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, 'page' => $page, ]); } /** * Returns human readable notice about the wallet state. * * @param \App\Wallet $wallet The wallet */ protected function getWalletNotice(Wallet $wallet): ?string { // there is no credit if ($wallet->balance < 0) { return \trans('app.wallet-notice-nocredit'); } // the discount is 100%, no credit is needed if ($wallet->discount && $wallet->discount->discount == 100) { return null; } // the owner was created less than a month ago if ($wallet->owner->created_at > Carbon::now()->subMonthsWithoutOverflow(1)) { // but more than two weeks ago, notice of trial ending if ($wallet->owner->created_at <= Carbon::now()->subWeeks(2)) { return \trans('app.wallet-notice-trial-end'); } return \trans('app.wallet-notice-trial'); } if ($until = $wallet->balanceLastsUntil()) { if ($until->isToday()) { return \trans('app.wallet-notice-today'); } // Once in a while we got e.g. "3 weeks" instead of expected "4 weeks". // It's because $until uses full seconds, but $now is more precise. // We make sure both have the same time set. $now = Carbon::now()->setTimeFrom($until); $diffOptions = [ 'syntax' => Carbon::DIFF_ABSOLUTE, 'parts' => 1, ]; if ($now->diff($until)->days > 31) { $diffOptions['parts'] = 2; } $params = [ 'date' => $until->toDateString(), 'days' => $now->diffForHumans($until, $diffOptions), ]; return \trans('app.wallet-notice-date', $params); } return null; } } diff --git a/src/app/Http/Kernel.php b/src/app/Http/Kernel.php index a5c0625a..a03a1631 100644 --- a/src/app/Http/Kernel.php +++ b/src/app/Http/Kernel.php @@ -1,105 +1,86 @@ */ protected $middleware = [ + // \App\Http\Middleware\TrustHosts::class, \App\Http\Middleware\RequestLogger::class, \App\Http\Middleware\TrustProxies::class, - \App\Http\Middleware\CheckForMaintenanceMode::class, + \App\Http\Middleware\PreventRequestsDuringMaintenance::class, \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, \App\Http\Middleware\TrimStrings::class, \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, \App\Http\Middleware\DevelConfig::class, \App\Http\Middleware\Locale::class, \App\Http\Middleware\ContentSecurityPolicy::class, // FIXME: CORS handling added here, I didn't find a nice way // to add this only to the API routes // \App\Http\Middleware\Cors::class, ]; /** * The application's route middleware groups. * - * @var array + * @var array */ protected $middlewareGroups = [ 'web' => [ // \App\Http\Middleware\EncryptCookies::class, // \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, // \Illuminate\Session\Middleware\StartSession::class, // \Illuminate\Session\Middleware\AuthenticateSession::class, // \Illuminate\View\Middleware\ShareErrorsFromSession::class, // \App\Http\Middleware\VerifyCsrfToken::class, // \Illuminate\Routing\Middleware\SubstituteBindings::class, ], 'api' => [ - //'throttle:120,1', - 'bindings', + // 'throttle:api', + \Illuminate\Routing\Middleware\SubstituteBindings::class, ], ]; /** * The application's route middleware. * * These middleware may be assigned to groups or used individually. * - * @var array + * @var array */ protected $routeMiddleware = [ 'admin' => \App\Http\Middleware\AuthenticateAdmin::class, 'auth' => \App\Http\Middleware\Authenticate::class, 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, - 'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class, 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, 'can' => \Illuminate\Auth\Middleware\Authorize::class, 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 'reseller' => \App\Http\Middleware\AuthenticateReseller::class, 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, ]; - /** - * The priority-sorted list of middleware. - * - * This forces non-global middleware to always be in the given order. - * - * @var array - */ - protected $middlewarePriority = [ - \Illuminate\Session\Middleware\StartSession::class, - \Illuminate\View\Middleware\ShareErrorsFromSession::class, - \App\Http\Middleware\AuthenticateAdmin::class, - \App\Http\Middleware\AuthenticateReseller::class, - \App\Http\Middleware\Authenticate::class, - \Illuminate\Session\Middleware\AuthenticateSession::class, - \Illuminate\Routing\Middleware\SubstituteBindings::class, - \Illuminate\Auth\Middleware\Authorize::class, - \App\Http\Middleware\ContentSecurityPolicy::class, - ]; - /** * Handle an incoming HTTP request. * * @param \Illuminate\Http\Request $request HTTP Request object * * @return \Illuminate\Http\Response */ public function handle($request) { // Overwrite the http request object return parent::handle(Request::createFrom($request)); } } diff --git a/src/app/Http/Middleware/EncryptCookies.php b/src/app/Http/Middleware/EncryptCookies.php index 033136ad..867695bd 100644 --- a/src/app/Http/Middleware/EncryptCookies.php +++ b/src/app/Http/Middleware/EncryptCookies.php @@ -1,17 +1,17 @@ */ protected $except = [ // ]; } diff --git a/src/app/Http/Middleware/CheckForMaintenanceMode.php b/src/app/Http/Middleware/PreventRequestsDuringMaintenance.php similarity index 51% rename from src/app/Http/Middleware/CheckForMaintenanceMode.php rename to src/app/Http/Middleware/PreventRequestsDuringMaintenance.php index 35b9824b..74cbd9a9 100644 --- a/src/app/Http/Middleware/CheckForMaintenanceMode.php +++ b/src/app/Http/Middleware/PreventRequestsDuringMaintenance.php @@ -1,17 +1,17 @@ */ protected $except = [ // ]; } diff --git a/src/app/Http/Middleware/RedirectIfAuthenticated.php b/src/app/Http/Middleware/RedirectIfAuthenticated.php index 8bf24bd1..aa1ad444 100644 --- a/src/app/Http/Middleware/RedirectIfAuthenticated.php +++ b/src/app/Http/Middleware/RedirectIfAuthenticated.php @@ -1,27 +1,32 @@ check()) { - return redirect('/dashboard'); + $guards = empty($guards) ? [null] : $guards; + + foreach ($guards as $guard) { + if (Auth::guard($guard)->check()) { + return redirect('/dashboard'); + } } return $next($request); } } diff --git a/src/app/Http/Middleware/TrustHosts.php b/src/app/Http/Middleware/TrustHosts.php new file mode 100644 index 00000000..7186414c --- /dev/null +++ b/src/app/Http/Middleware/TrustHosts.php @@ -0,0 +1,20 @@ + + */ + public function hosts() + { + return [ + $this->allSubdomainsOfApplicationUrl(), + ]; + } +} diff --git a/src/app/Http/Middleware/TrustProxies.php b/src/app/Http/Middleware/TrustProxies.php index c8fa0d98..c1ee7619 100644 --- a/src/app/Http/Middleware/TrustProxies.php +++ b/src/app/Http/Middleware/TrustProxies.php @@ -1,28 +1,32 @@ |string|null */ protected $proxies = [ '10.0.0.0/8', '127.0.0.1/8', '172.16.0.0/12', '192.168.0.0/16' ]; /** * The headers that should be used to detect proxies. * * @var int */ - protected $headers = Request::HEADER_X_FORWARDED_ALL; + protected $headers = Request::HEADER_X_FORWARDED_FOR | + Request::HEADER_X_FORWARDED_HOST | + Request::HEADER_X_FORWARDED_PORT | + Request::HEADER_X_FORWARDED_PROTO | + Request::HEADER_X_FORWARDED_AWS_ELB; } diff --git a/src/app/Http/Middleware/VerifyCsrfToken.php b/src/app/Http/Middleware/VerifyCsrfToken.php index 324a166b..9e865217 100644 --- a/src/app/Http/Middleware/VerifyCsrfToken.php +++ b/src/app/Http/Middleware/VerifyCsrfToken.php @@ -1,24 +1,17 @@ */ protected $except = [ // ]; } diff --git a/src/app/IP4Net.php b/src/app/IP4Net.php index a506b4ed..64607e3c 100644 --- a/src/app/IP4Net.php +++ b/src/app/IP4Net.php @@ -1,30 +1,31 @@ The attributes that are mass assignable */ protected $fillable = [ 'rir_name', 'net_number', 'net_mask', 'net_broadcast', 'country', 'serial', 'created_at', 'updated_at' ]; public static function getNet($ip) { $where = 'INET_ATON(net_number) <= INET_ATON(?) and INET_ATON(net_broadcast) >= INET_ATON(?)'; return IP4Net::whereRaw($where, [$ip, $ip]) ->orderByRaw('INET_ATON(net_number), net_mask DESC') ->first(); } } diff --git a/src/app/IP6Net.php b/src/app/IP6Net.php index ba1b4931..9de61452 100644 --- a/src/app/IP6Net.php +++ b/src/app/IP6Net.php @@ -1,30 +1,31 @@ The attributes that are mass assignable */ protected $fillable = [ 'rir_name', 'net_number', 'net_mask', 'net_broadcast', 'country', 'serial', 'created_at', 'updated_at' ]; public static function getNet($ip) { $where = 'INET6_ATON(net_number) <= INET6_ATON(?) and INET6_ATON(net_broadcast) >= INET6_ATON(?)'; return IP6Net::whereRaw($where, [$ip, $ip]) ->orderByRaw('INET6_ATON(net_number), net_mask DESC') ->first(); } } diff --git a/src/app/Jobs/PaymentEmail.php b/src/app/Jobs/PaymentEmail.php index a1391b5f..423c4e5f 100644 --- a/src/app/Jobs/PaymentEmail.php +++ b/src/app/Jobs/PaymentEmail.php @@ -1,102 +1,102 @@ payment = $payment; $this->controller = $controller; } /** * Execute the job. * * @return void */ public function handle() { $wallet = $this->payment->wallet; if (empty($this->controller)) { $this->controller = $wallet->owner; } if (empty($this->controller)) { return; } if ($this->payment->status == PaymentProvider::STATUS_PAID) { $mail = new \App\Mail\PaymentSuccess($this->payment, $this->controller); $label = "Success"; } elseif ( $this->payment->status == PaymentProvider::STATUS_EXPIRED || $this->payment->status == PaymentProvider::STATUS_FAILED ) { $mail = new \App\Mail\PaymentFailure($this->payment, $this->controller); $label = "Failure"; } else { return; } list($to, $cc) = \App\Mail\Helper::userEmails($this->controller); if (!empty($to)) { $params = [ 'to' => $to, 'cc' => $cc, 'add' => " for {$wallet->id}", ]; \App\Mail\Helper::sendMail($mail, $this->controller->tenant_id, $params); } /* // Send the email to all wallet controllers too if ($wallet->owner->id == $this->controller->id) { $this->wallet->controllers->each(function ($controller) { self::dispatch($this->payment, $controller); } }); */ } } diff --git a/src/app/Jobs/PaymentMandateDisabledEmail.php b/src/app/Jobs/PaymentMandateDisabledEmail.php index a7fd82d9..4a2d97cf 100644 --- a/src/app/Jobs/PaymentMandateDisabledEmail.php +++ b/src/app/Jobs/PaymentMandateDisabledEmail.php @@ -1,89 +1,89 @@ wallet = $wallet; $this->controller = $controller; } /** * Execute the job. * * @return void */ public function handle() { if (empty($this->controller)) { $this->controller = $this->wallet->owner; } if (empty($this->controller)) { return; } $mail = new PaymentMandateDisabled($this->wallet, $this->controller); list($to, $cc) = \App\Mail\Helper::userEmails($this->controller); if (!empty($to)) { $params = [ 'to' => $to, 'cc' => $cc, 'add' => " for {$this->wallet->id}", ]; \App\Mail\Helper::sendMail($mail, $this->controller->tenant_id, $params); } /* // Send the email to all controllers too if ($this->controller->id == $this->wallet->owner->id) { $this->wallet->controllers->each(function ($controller) { self::dispatch($this->wallet, $controller); } }); */ } } diff --git a/src/app/Jobs/SignupInvitationEmail.php b/src/app/Jobs/SignupInvitationEmail.php index 6a8a8e00..931db27a 100644 --- a/src/app/Jobs/SignupInvitationEmail.php +++ b/src/app/Jobs/SignupInvitationEmail.php @@ -1,78 +1,78 @@ invitation = $invitation; } /** * Execute the job. * * @return void */ public function handle() { \App\Mail\Helper::sendMail( new SignupInvitationMail($this->invitation), $this->invitation->tenant_id, ['to' => $this->invitation->email] ); // Update invitation status $this->invitation->status = SignupInvitation::STATUS_SENT; $this->invitation->save(); } /** * The job failed to process. * * @param \Exception $exception * * @return void */ public function failed(\Exception $exception) { if ($this->attempts() >= $this->tries) { // Update invitation status $this->invitation->status = SignupInvitation::STATUS_FAILED; $this->invitation->save(); } } } diff --git a/src/app/Jobs/WalletCharge.php b/src/app/Jobs/WalletCharge.php index 48a9a4a1..8e9bd74b 100644 --- a/src/app/Jobs/WalletCharge.php +++ b/src/app/Jobs/WalletCharge.php @@ -1,54 +1,54 @@ wallet = $wallet; } /** * Execute the job. * * @return void */ public function handle() { PaymentsController::topUpWallet($this->wallet); } } diff --git a/src/app/Jobs/WalletCheck.php b/src/app/Jobs/WalletCheck.php index 702bbdd0..9c2cac77 100644 --- a/src/app/Jobs/WalletCheck.php +++ b/src/app/Jobs/WalletCheck.php @@ -1,402 +1,402 @@ wallet = $wallet; } /** * Execute the job. * * @return ?string Executed action (THRESHOLD_*) */ public function handle() { if ($this->wallet->balance >= 0) { return null; } $now = Carbon::now(); /* // Steps for old "first suspend then delete" approach $steps = [ // Send the initial reminder self::THRESHOLD_INITIAL => 'initialReminder', // Try to top-up the wallet before the second reminder self::THRESHOLD_BEFORE_REMINDER => 'topUpWallet', // Send the second reminder self::THRESHOLD_REMINDER => 'secondReminder', // Try to top-up the wallet before suspending the account self::THRESHOLD_BEFORE_SUSPEND => 'topUpWallet', // Suspend the account self::THRESHOLD_SUSPEND => 'suspendAccount', // Warn about the upcomming account deletion self::THRESHOLD_BEFORE_DELETE => 'warnBeforeDelete', // Delete the account self::THRESHOLD_DELETE => 'deleteAccount', ]; */ // Steps for "demote instead of suspend+delete" approach $steps = [ // Send the initial reminder self::THRESHOLD_INITIAL => 'initialReminderForDegrade', // Try to top-up the wallet before the second reminder self::THRESHOLD_BEFORE_REMINDER => 'topUpWallet', // Send the second reminder self::THRESHOLD_REMINDER => 'secondReminderForDegrade', // Try to top-up the wallet before the account degradation self::THRESHOLD_BEFORE_DEGRADE => 'topUpWallet', // Degrade the account self::THRESHOLD_DEGRADE => 'degradeAccount', ]; if ($this->wallet->owner && $this->wallet->owner->isDegraded()) { $this->degradedReminder(); return self::THRESHOLD_DEGRADE_REMINDER; } foreach (array_reverse($steps, true) as $type => $method) { if (self::threshold($this->wallet, $type) < $now) { $this->{$method}(); return $type; } } return null; } /** * Send the initial reminder (for the suspend+delete process) */ protected function initialReminder() { if ($this->wallet->getSetting('balance_warning_initial')) { return; } // TODO: Should we check if the account is already suspended? $this->sendMail(\App\Mail\NegativeBalance::class, false); $now = \Carbon\Carbon::now()->toDateTimeString(); $this->wallet->setSetting('balance_warning_initial', $now); } /** * Send the initial reminder (for the process of degrading a account) */ protected function initialReminderForDegrade() { if ($this->wallet->getSetting('balance_warning_initial')) { return; } if (!$this->wallet->owner || $this->wallet->owner->isDegraded()) { return; } $this->sendMail(\App\Mail\NegativeBalance::class, false); $now = \Carbon\Carbon::now()->toDateTimeString(); $this->wallet->setSetting('balance_warning_initial', $now); } /** * Send the second reminder (for the suspend+delete process) */ protected function secondReminder() { if ($this->wallet->getSetting('balance_warning_reminder')) { return; } // TODO: Should we check if the account is already suspended? $this->sendMail(\App\Mail\NegativeBalanceReminder::class, false); $now = \Carbon\Carbon::now()->toDateTimeString(); $this->wallet->setSetting('balance_warning_reminder', $now); } /** * Send the second reminder (for the process of degrading a account) */ protected function secondReminderForDegrade() { if ($this->wallet->getSetting('balance_warning_reminder')) { return; } if (!$this->wallet->owner || $this->wallet->owner->isDegraded()) { return; } $this->sendMail(\App\Mail\NegativeBalanceReminderDegrade::class, true); $now = \Carbon\Carbon::now()->toDateTimeString(); $this->wallet->setSetting('balance_warning_reminder', $now); } /** * Suspend the account (and send the warning) */ protected function suspendAccount() { if ($this->wallet->getSetting('balance_warning_suspended')) { return; } // Sanity check, already deleted if (!$this->wallet->owner) { return; } // Suspend the account $this->wallet->owner->suspend(); foreach ($this->wallet->entitlements as $entitlement) { if (method_exists($entitlement->entitleable_type, 'suspend')) { $entitlement->entitleable->suspend(); } } $this->sendMail(\App\Mail\NegativeBalanceSuspended::class, true); $now = \Carbon\Carbon::now()->toDateTimeString(); $this->wallet->setSetting('balance_warning_suspended', $now); } /** * Send the last warning before delete */ protected function warnBeforeDelete() { if ($this->wallet->getSetting('balance_warning_before_delete')) { return; } // Sanity check, already deleted if (!$this->wallet->owner) { return; } $this->sendMail(\App\Mail\NegativeBalanceBeforeDelete::class, true); $now = \Carbon\Carbon::now()->toDateTimeString(); $this->wallet->setSetting('balance_warning_before_delete', $now); } /** * Send the periodic reminder to the degraded account owners */ protected function degradedReminder() { // Sanity check if (!$this->wallet->owner || !$this->wallet->owner->isDegraded()) { return; } $now = \Carbon\Carbon::now(); $last = $this->wallet->getSetting('degraded_last_reminder'); if ($last) { $last = new Carbon($last); $period = 14; if ($last->addDays($period) > $now) { return; } $this->sendMail(\App\Mail\DegradedAccountReminder::class, true); } $this->wallet->setSetting('degraded_last_reminder', $now->toDateTimeString()); } /** * Degrade the account */ protected function degradeAccount() { // The account may be already deleted, or degraded if (!$this->wallet->owner || $this->wallet->owner->isDegraded()) { return; } $email = $this->wallet->owner->email; // The dirty work will be done by UserObserver $this->wallet->owner->degrade(); \Log::info( sprintf( "[WalletCheck] Account degraded %s (%s)", $this->wallet->id, $email ) ); $this->sendMail(\App\Mail\NegativeBalanceDegraded::class, true); } /** * Delete the account */ protected function deleteAccount() { // TODO: This will not work when we actually allow multiple-wallets per account // but in this case we anyway have to change the whole thing // and calculate summarized balance from all wallets. // The dirty work will be done by UserObserver if ($this->wallet->owner) { $email = $this->wallet->owner->email; $this->wallet->owner->delete(); \Log::info( sprintf( "[WalletCheck] Account deleted %s (%s)", $this->wallet->id, $email ) ); } } /** * Send the email * * @param string $class Mailable class name * @param bool $with_external Use users's external email */ protected function sendMail($class, $with_external = false): void { // TODO: Send the email to all wallet controllers? $mail = new $class($this->wallet, $this->wallet->owner); list($to, $cc) = \App\Mail\Helper::userEmails($this->wallet->owner, $with_external); if (!empty($to) || !empty($cc)) { $params = [ 'to' => $to, 'cc' => $cc, 'add' => " for {$this->wallet->id}", ]; \App\Mail\Helper::sendMail($mail, $this->wallet->owner->tenant_id, $params); } } /** * Get the date-time for an action threshold. Calculated using * the date when a wallet balance turned negative. * * @param \App\Wallet $wallet A wallet * @param string $type Action type (one of self::THRESHOLD_*) * * @return \Carbon\Carbon The threshold date-time object */ public static function threshold(Wallet $wallet, string $type): ?Carbon { $negative_since = $wallet->getSetting('balance_negative_since'); // Migration scenario: balance<0, but no balance_negative_since set if (!$negative_since) { // 2h back from now, so first run can sent the initial notification $negative_since = Carbon::now()->subHours(2); $wallet->setSetting('balance_negative_since', $negative_since->toDateTimeString()); } else { $negative_since = new Carbon($negative_since); } // Initial notification // Give it an hour so the async recurring payment has a chance to be finished if ($type == self::THRESHOLD_INITIAL) { return $negative_since->addHours(1); } $thresholds = [ // A day before the second reminder self::THRESHOLD_BEFORE_REMINDER => 7 - 1, // Second notification self::THRESHOLD_REMINDER => 7, // A day before account suspension self::THRESHOLD_BEFORE_SUSPEND => 14 + 7 - 1, // Account suspension self::THRESHOLD_SUSPEND => 14 + 7, // Warning about the upcomming account deletion self::THRESHOLD_BEFORE_DELETE => 21 + 14 + 7 - 3, // Acount deletion self::THRESHOLD_DELETE => 21 + 14 + 7, // Last chance to top-up the wallet self::THRESHOLD_BEFORE_DEGRADE => 13, // Account degradation self::THRESHOLD_DEGRADE => 14, ]; if (!empty($thresholds[$type])) { return $negative_since->addDays($thresholds[$type]); } return null; } /** * Try to automatically top-up the wallet */ protected function topUpWallet(): void { PaymentsController::topUpWallet($this->wallet); } } diff --git a/src/app/Observers/OpenVidu/ConnectionObserver.php b/src/app/Observers/OpenVidu/ConnectionObserver.php index 94c8091e..41e9add9 100644 --- a/src/app/Observers/OpenVidu/ConnectionObserver.php +++ b/src/app/Observers/OpenVidu/ConnectionObserver.php @@ -1,71 +1,50 @@ role != $connection->getOriginal('role')) { $params['role'] = $connection->role; // TODO: When demoting publisher to subscriber maybe we should // destroy all streams using REST API. For now we trust the // participant browser to do this. } // Detect metadata changes for specified properties $keys = [ 'hand' => 'bool', 'language' => '', ]; foreach ($keys as $key => $type) { $newState = $connection->metadata[$key] ?? null; - $oldState = $this->getOriginal($connection, 'metadata')[$key] ?? null; + $oldState = $connection->getOriginal('metadata')[$key] ?? null; if ($newState !== $oldState) { $params[$key] = $type == 'bool' ? !empty($newState) : $newState; } } // Send the signal to all participants if (!empty($params)) { $params['connectionId'] = $connection->id; $connection->room->signal('connectionUpdate', $params); } } - - /** - * A wrapper to getOriginal() on an object - * - * @param \App\OpenVidu\Connection $connection The connection. - * @param string $property The property name - * - * @return mixed - */ - private function getOriginal($connection, $property) - { - $original = $connection->getOriginal($property); - - // The original value for a property is in a format stored in database - // I.e. for 'metadata' it is a JSON string instead of an array - if ($property == 'metadata') { - $original = json_decode($original, true); - } - - return $original; - } } diff --git a/src/app/OpenVidu/Room.php b/src/app/OpenVidu/Room.php index 5a483390..669147b7 100644 --- a/src/app/OpenVidu/Room.php +++ b/src/app/OpenVidu/Room.php @@ -1,427 +1,427 @@ false, // No exceptions from Guzzle 'base_uri' => \config('openvidu.api_url'), 'verify' => \config('openvidu.api_verify_tls'), 'auth' => [ \config('openvidu.api_username'), \config('openvidu.api_password') ], 'on_stats' => function (\GuzzleHttp\TransferStats $stats) { $threshold = \config('logging.slow_log'); if ($threshold && ($sec = $stats->getTransferTime()) > $threshold) { $url = $stats->getEffectiveUri(); $method = $stats->getRequest()->getMethod(); \Log::warning(sprintf("[STATS] %s %s: %.4f sec.", $method, $url, $sec)); } }, ] ); } return self::$client; } /** * Destroy a OpenVidu connection * * @param string $conn Connection identifier * * @return bool True on success, False otherwise * @throws \Exception if session does not exist */ public function closeOVConnection($conn): bool { if (!$this->session_id) { throw new \Exception("The room session does not exist"); } $url = 'sessions/' . $this->session_id . '/connection/' . urlencode($conn); $response = $this->client()->request('DELETE', $url); return $response->getStatusCode() == 204; } /** * Fetch a OpenVidu connection information. * * @param string $conn Connection identifier * * @return ?array Connection data on success, Null otherwise * @throws \Exception if session does not exist */ public function getOVConnection($conn): ?array { // Note: getOVConnection() not getConnection() because Eloquent\Model::getConnection() exists // TODO: Maybe use some other name? getParticipant? if (!$this->session_id) { throw new \Exception("The room session does not exist"); } $url = 'sessions/' . $this->session_id . '/connection/' . urlencode($conn); $response = $this->client()->request('GET', $url); if ($response->getStatusCode() == 200) { return json_decode($response->getBody(), true); } return null; } /** * Create a OpenVidu session * * @return array|null Session data on success, NULL otherwise */ public function createSession(): ?array { $response = $this->client()->request( 'POST', "sessions", [ 'json' => [ 'mediaMode' => 'ROUTED', 'recordingMode' => 'MANUAL' ] ] ); if ($response->getStatusCode() !== 200) { $this->session_id = null; $this->save(); return null; } $session = json_decode($response->getBody(), true); $this->session_id = $session['id']; $this->save(); return $session; } /** * Delete a OpenVidu session * * @return bool */ public function deleteSession(): bool { if (!$this->session_id) { return true; } $response = $this->client()->request( 'DELETE', "sessions/" . $this->session_id, ); if ($response->getStatusCode() == 204) { $this->session_id = null; $this->save(); return true; } return false; } /** * Returns metadata for every connection in a session. * * @return array Connections metadata, indexed by connection identifier * @throws \Exception if session does not exist */ public function getSessionConnections(): array { if (!$this->session_id) { throw new \Exception("The room session does not exist"); } return Connection::where('session_id', $this->session_id) // Ignore screen sharing connection for now ->whereRaw("(role & " . self::ROLE_SCREEN . ") = 0") ->get() ->keyBy('id') ->map(function ($item) { // Warning: Make sure to not return all metadata here as it might contain sensitive data. return [ 'role' => $item->role, 'hand' => $item->metadata['hand'] ?? 0, 'language' => $item->metadata['language'] ?? null, ]; }) // Sort by order in the queue, so UI can re-build the existing queue in order ->sort(function ($a, $b) { return $a['hand'] <=> $b['hand']; }) ->all(); } /** * Create a OpenVidu session (connection) token * * @param int $role User role (see self::ROLE_* constants) * * @return array|null Token data on success, NULL otherwise * @throws \Exception if session does not exist */ public function getSessionToken($role = self::ROLE_SUBSCRIBER): ?array { if (!$this->session_id) { throw new \Exception("The room session does not exist"); } // FIXME: Looks like passing the role in 'data' param is the only way // to make it visible for everyone in a room. So, for example we can // handle/style subscribers/publishers/moderators differently on the // client-side. Is this a security issue? $data = ['role' => $role]; $url = 'sessions/' . $this->session_id . '/connection'; $post = [ 'json' => [ 'role' => self::OV_ROLE_PUBLISHER, 'data' => json_encode($data) ] ]; $response = $this->client()->request('POST', $url, $post); if ($response->getStatusCode() == 200) { $json = json_decode($response->getBody(), true); $authToken = base64_encode($json['id'] . ':' . \random_bytes(16)); // Extract the 'token' part of the token, it will be used to authenticate the connection. // It will be needed in next iterations e.g. to authenticate moderators that aren't // Kolab4 users (or are just not logged in to Kolab4). // FIXME: we could as well generate our own token for auth purposes parse_str(parse_url($json['token'], PHP_URL_QUERY), $url); // Create the connection reference in our database $conn = new Connection(); $conn->id = $json['id']; $conn->session_id = $this->session_id; $conn->room_id = $this->id; $conn->role = $role; $conn->metadata = ['token' => $url['token'], 'authToken' => $authToken]; $conn->save(); return [ 'session' => $this->session_id, 'token' => $json['token'], 'authToken' => $authToken, 'connectionId' => $json['id'], 'role' => $role, ]; } return null; } /** * Check if the room has an active session * * @return bool True when the session exists, False otherwise */ public function hasSession(): bool { if (!$this->session_id) { return false; } $response = $this->client()->request('GET', "sessions/{$this->session_id}"); return $response->getStatusCode() == 200; } /** * The room owner. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function owner() { return $this->belongsTo('\App\User', 'user_id', 'id'); } /** * Accept the join request. * * @param string $id Request identifier * * @return bool True on success, False on failure */ public function requestAccept(string $id): bool { $request = Cache::get($this->session_id . '-' . $id); if ($request) { $request['status'] = self::REQUEST_ACCEPTED; return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1)); } return false; } /** * Deny the join request. * * @param string $id Request identifier * * @return bool True on success, False on failure */ public function requestDeny(string $id): bool { $request = Cache::get($this->session_id . '-' . $id); if ($request) { $request['status'] = self::REQUEST_DENIED; return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1)); } return false; } /** * Get the join request data. * * @param string $id Request identifier * * @return array|null Request data (e.g. nickname, status, picture?) */ public function requestGet(string $id): ?array { return Cache::get($this->session_id . '-' . $id); } /** * Save the join request. * * @param string $id Request identifier * @param array $request Request data * * @return bool True on success, False on failure */ public function requestSave(string $id, array $request): bool { // We don't really need the picture in the cache // As we use this cache for the request status only unset($request['picture']); return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1)); } /** * Any (additional) properties of this room. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\OpenVidu\RoomSetting', 'room_id'); } /** * Send a OpenVidu signal to the session participants (connections) * * @param string $name Signal name (type) * @param array $data Signal data array * @param null|int|string[] $target List of target connections, Null for all connections. * It can be also a participant role. * * @return bool True on success, False on failure * @throws \Exception if session does not exist */ public function signal(string $name, array $data = [], $target = null): bool { if (!$this->session_id) { throw new \Exception("The room session does not exist"); } $post = [ 'session' => $this->session_id, 'type' => $name, 'data' => $data ? json_encode($data) : '', ]; // Get connection IDs by participant role if (is_int($target)) { $connections = Connection::where('session_id', $this->session_id) ->whereRaw("(role & $target)") ->pluck('id') ->all(); if (empty($connections)) { return false; } $target = $connections; } if (!empty($target)) { $post['to'] = $target; } $response = $this->client()->request('POST', 'signal', ['json' => $post]); return $response->getStatusCode() == 200; } } diff --git a/src/app/Package.php b/src/app/Package.php index 90ad37c7..30c7c0a9 100644 --- a/src/app/Package.php +++ b/src/app/Package.php @@ -1,108 +1,106 @@ The attributes that are mass assignable */ protected $fillable = [ 'description', 'discount_rate', 'name', 'title', ]; - /** @var array Translatable properties */ + /** @var array Translatable properties */ public $translatable = [ 'name', 'description', ]; /** * The costs of this package at its pre-defined, existing configuration. * * @return int The costs in cents. */ public function cost() { $costs = 0; foreach ($this->skus as $sku) { $units = $sku->pivot->qty - $sku->units_free; if ($units < 0) { \Log::debug("Package {$this->id} is misconfigured for more free units than qty."); $units = 0; } $ppu = $sku->cost * ((100 - $this->discount_rate) / 100); $costs += $units * $ppu; } return $costs; } /** * Checks whether the package contains a domain SKU. */ public function isDomain(): bool { foreach ($this->skus as $sku) { - if ($sku->handler_class::entitleableClass() == \App\Domain::class) { + if ($sku->handler_class::entitleableClass() == Domain::class) { return true; } } return false; } /** * SKUs of this package. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function skus() { - return $this->belongsToMany( - 'App\Sku', - 'package_skus' - )->using('App\PackageSku')->withPivot( - ['qty'] - ); + return $this->belongsToMany(Sku::class, 'package_skus') + ->using(PackageSku::class) + ->withPivot(['qty']); } } diff --git a/src/app/PackageSku.php b/src/app/PackageSku.php index f52c1ecd..3817a6cf 100644 --- a/src/app/PackageSku.php +++ b/src/app/PackageSku.php @@ -1,86 +1,88 @@ The attributes that are mass assignable */ protected $fillable = [ 'package_id', 'sku_id', 'cost', 'qty' ]; + /** @var array The attributes that should be cast */ protected $casts = [ 'cost' => 'integer', 'qty' => 'integer' ]; /** * Under this package, how much does this SKU cost? * * @return int The costs of this SKU under this package in cents. */ public function cost() { $units = $this->qty - $this->sku->units_free; if ($units < 0) { $units = 0; } // FIXME: Why package_skus.cost value is not used anywhere? $ppu = $this->sku->cost * ((100 - $this->package->discount_rate) / 100); return $units * $ppu; } /** * Under this package, what fee this SKU has? * * @return int The fee for this SKU under this package in cents. */ public function fee() { $units = $this->qty - $this->sku->units_free; if ($units < 0) { $units = 0; } return $this->sku->fee * $units; } /** * The package for this relation. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function package() { - return $this->belongsTo('App\Package'); + return $this->belongsTo(Package::class); } /** * The SKU for this relation. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function sku() { - return $this->belongsTo('App\Sku'); + return $this->belongsTo(Sku::class); } } diff --git a/src/app/Payment.php b/src/app/Payment.php index d8a18d35..6c24b019 100644 --- a/src/app/Payment.php +++ b/src/app/Payment.php @@ -1,57 +1,59 @@ The attributes that should be cast */ protected $casts = [ 'amount' => 'integer' ]; + /** @var array The attributes that are mass assignable */ protected $fillable = [ 'id', 'wallet_id', 'amount', 'description', 'provider', 'status', 'type', 'currency', 'currency_amount', ]; /** * Ensure the currency is appropriately cased. */ public function setCurrencyAttribute($currency) { $this->attributes['currency'] = strtoupper($currency); } /** * The wallet to which this payment belongs. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function wallet() { - return $this->belongsTo('\App\Wallet', 'wallet_id', 'id'); + return $this->belongsTo(Wallet::class, 'wallet_id', 'id'); } } diff --git a/src/app/Plan.php b/src/app/Plan.php index ced12a89..bdfe80dd 100644 --- a/src/app/Plan.php +++ b/src/app/Plan.php @@ -1,119 +1,118 @@ The attributes that are mass assignable */ protected $fillable = [ 'title', 'name', 'description', // a start and end datetime for this promotion 'promo_from', 'promo_to', // discounts start at this quantity 'discount_qty', // the rate of the discount for this plan 'discount_rate', ]; + /** @var array The attributes that should be cast */ protected $casts = [ - 'promo_from' => 'datetime', - 'promo_to' => 'datetime', + 'promo_from' => 'datetime:Y-m-d H:i:s', + 'promo_to' => 'datetime:Y-m-d H:i:s', 'discount_qty' => 'integer', 'discount_rate' => 'integer' ]; - /** @var array Translatable properties */ + /** @var array Translatable properties */ public $translatable = [ 'name', 'description', ]; /** * The list price for this package at the minimum configuration. * * @return int The costs in cents. */ public function cost() { $costs = 0; foreach ($this->packages as $package) { $costs += $package->pivot->cost(); } return $costs; } /** * The relationship to packages. * * The plan contains one or more packages. Each package may have its minimum number (for * billing) or its maximum (to allow topping out "enterprise" customers on a "small business" * plan). * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function packages() { - return $this->belongsToMany( - 'App\Package', - 'plan_packages' - )->using('App\PlanPackage')->withPivot( - [ - 'qty', - 'qty_min', - 'qty_max', - 'discount_qty', - 'discount_rate' - ] - ); + return $this->belongsToMany(Package::class, 'plan_packages') + ->using(PlanPackage::class) + ->withPivot([ + 'qty', + 'qty_min', + 'qty_max', + 'discount_qty', + 'discount_rate' + ]); } /** * Checks if the plan has any type of domain SKU assigned. * * @return bool */ public function hasDomain(): bool { foreach ($this->packages as $package) { if ($package->isDomain()) { return true; } } return false; } } diff --git a/src/app/PlanPackage.php b/src/app/PlanPackage.php index 484a48b5..a2a5fae8 100644 --- a/src/app/PlanPackage.php +++ b/src/app/PlanPackage.php @@ -1,77 +1,79 @@ The attributes that are mass assignable */ protected $fillable = [ 'plan_id', 'package_id', 'qty', 'qty_max', 'qty_min', 'discount_qty', 'discount_rate' ]; + /** @var array The attributes that should be cast */ protected $casts = [ 'qty' => 'integer', 'qty_max' => 'integer', 'qty_min' => 'integer', 'discount_qty' => 'integer', 'discount_rate' => 'integer' ]; /** * Calculate the costs for this plan. * * @return integer */ public function cost() { $costs = 0; if ($this->qty_min > 0) { $costs += $this->package->cost() * $this->qty_min; } elseif ($this->qty > 0) { $costs += $this->package->cost() * $this->qty; } return $costs; } /** * The package in this relation. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function package() { - return $this->belongsTo('App\Package'); + return $this->belongsTo(Package::class); } /** * The plan in this relation. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function plan() { - return $this->belongsTo('App\Plan'); + return $this->belongsTo(Plan::class); } } diff --git a/src/app/Providers/AuthServiceProvider.php b/src/app/Providers/AuthServiceProvider.php index a046289b..5125d679 100644 --- a/src/app/Providers/AuthServiceProvider.php +++ b/src/app/Providers/AuthServiceProvider.php @@ -1,57 +1,49 @@ */ protected $policies = [ // 'App\Model' => 'App\Policies\ModelPolicy', ]; /** * Register any authentication / authorization services. * * @return void */ public function boot() { $this->registerPolicies(); - Auth::provider( - 'ldap', - function ($app, array $config) { - return new LDAPUserProvider($app['hash'], $config['model']); - } - ); - // Hashes all secrets and thus makes them non-recoverable /* Passport::hashClientSecrets(); */ // Only enable routes for access tokens Passport::routes( function ($router) { $router->forAccessTokens(); // Override the default route to avoid rate-limiting. - \Route::post('/token', [ + Route::post('/token', [ 'uses' => 'AccessTokenController@issueToken', 'as' => 'passport.token', ]); } ); Passport::tokensExpireIn(now()->addMinutes(\config('auth.token_expiry_minutes'))); Passport::refreshTokensExpireIn(now()->addMinutes(\config('auth.refresh_token_expiry_minutes'))); Passport::personalAccessTokensExpireIn(now()->addMonths(6)); } } diff --git a/src/app/Providers/EventServiceProvider.php b/src/app/Providers/EventServiceProvider.php index 6c64e52b..69bd7e0c 100644 --- a/src/app/Providers/EventServiceProvider.php +++ b/src/app/Providers/EventServiceProvider.php @@ -1,34 +1,42 @@ > */ protected $listen = [ Registered::class => [ SendEmailVerificationNotification::class, ], ]; /** * Register any events for your application. * * @return void */ public function boot() { - parent::boot(); - // } + + /** + * Determine if events and listeners should be automatically discovered. + * + * @return bool + */ + public function shouldDiscoverEvents() + { + return false; + } } diff --git a/src/app/Providers/PassportServiceProvider.php b/src/app/Providers/PassportServiceProvider.php index 5e94b860..1a289f55 100644 --- a/src/app/Providers/PassportServiceProvider.php +++ b/src/app/Providers/PassportServiceProvider.php @@ -1,53 +1,52 @@ app->make(Bridge\ClientRepository::class), $this->app->make(Bridge\AccessTokenRepository::class), $this->app->make(Bridge\ScopeRepository::class), $this->makeCryptKey('private'), $this->makeEncryptionKey(app('encrypter')->getKey()) ); } /** * Create a Key instance for encrypting the refresh token * * Based on https://github.com/laravel/passport/pull/820 * * @param string $keyBytes * @return \Defuse\Crypto\Key */ private function makeEncryptionKey($keyBytes) { // First, we will encode Laravel's encryption key into a format that the Defuse\Crypto\Key class can use, // so we can instantiate a new Key object. We need to do this as the Key class has a private constructor method // which means we cannot directly instantiate the class based on our Laravel encryption key. $encryptionKeyAscii = EncryptionEncoding::saveBytesToChecksummedAsciiSafeString( EncryptionKey::KEY_CURRENT_VERSION, $keyBytes ); // Instantiate a Key object so we can take advantage of significantly faster encryption/decryption // from https://github.com/thephpleague/oauth2-server/pull/814. The improvement is 200x-300x faster. return EncryptionKey::loadFromAsciiSafeString($encryptionKeyAscii); } } diff --git a/src/app/Providers/RouteServiceProvider.php b/src/app/Providers/RouteServiceProvider.php index 14d17ef3..8bdbb9a5 100644 --- a/src/app/Providers/RouteServiceProvider.php +++ b/src/app/Providers/RouteServiceProvider.php @@ -1,74 +1,44 @@ configureRateLimiting(); - parent::boot(); - } + $this->routes(function () { + $prefix = \trim(\parse_url(\config('app.url'), PHP_URL_PATH), '/') . '/'; - /** - * Define the routes for the application. - * - * @return void - */ - public function map() - { - $this->mapApiRoutes(); - - $this->mapWebRoutes(); + Route::prefix($prefix . 'api') + ->group(base_path('routes/api.php')); - // + Route::middleware('web') + ->group(base_path('routes/web.php')); + }); } /** - * Define the "web" routes for the application. - * - * These routes all receive session state, CSRF protection, etc. - * - * @return void - */ - protected function mapWebRoutes() - { - Route::middleware('web') - ->namespace($this->namespace) - ->group(base_path('routes/web.php')); - } - - /** - * Define the "api" routes for the application. - * - * These routes are typically stateless. + * Configure the rate limiters for the application. * * @return void */ - protected function mapApiRoutes() + protected function configureRateLimiting() { - // Note: We removed the prefix from here, to have more control - // over it in routes/api.php - Route::middleware('api') - ->namespace($this->namespace) - ->group(base_path('routes/api.php')); + RateLimiter::for('api', function (Request $request) { + return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); + }); } } diff --git a/src/app/Resource.php b/src/app/Resource.php index f7f9a630..9a0316ce 100644 --- a/src/app/Resource.php +++ b/src/app/Resource.php @@ -1,57 +1,60 @@ The attributes that should be cast */ + protected $casts = [ + 'created_at' => 'datetime:Y-m-d H:i:s', + 'deleted_at' => 'datetime:Y-m-d H:i:s', + 'updated_at' => 'datetime:Y-m-d H:i:s', ]; + + /** @var array The attributes that are mass assignable */ + protected $fillable = ['email', 'name', 'status']; } diff --git a/src/app/ResourceSetting.php b/src/app/ResourceSetting.php index 15192a0a..67d29e7d 100644 --- a/src/app/ResourceSetting.php +++ b/src/app/ResourceSetting.php @@ -1,30 +1,29 @@ The attributes that are mass assignable */ + protected $fillable = ['resource_id', 'key', 'value']; /** * The resource to which this setting belongs. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function resource() { - return $this->belongsTo(\App\Resource::class, 'resource_id', 'id'); + return $this->belongsTo(Resource::class, 'resource_id', 'id'); } } diff --git a/src/app/SharedFolder.php b/src/app/SharedFolder.php index 573e5a30..4a1cfa21 100644 --- a/src/app/SharedFolder.php +++ b/src/app/SharedFolder.php @@ -1,78 +1,85 @@ The attributes that should be cast */ + protected $casts = [ + 'created_at' => 'datetime:Y-m-d H:i:s', + 'deleted_at' => 'datetime:Y-m-d H:i:s', + 'updated_at' => 'datetime:Y-m-d H:i:s', + ]; + + /** @var array The attributes that are mass assignable */ protected $fillable = [ 'email', 'name', 'status', 'type', ]; /** * Folder type mutator * * @throws \Exception */ public function setTypeAttribute($type) { if (!in_array($type, self::SUPPORTED_TYPES)) { throw new \Exception("Invalid shared folder type: {$type}"); } $this->attributes['type'] = $type; } } diff --git a/src/app/SharedFolderAlias.php b/src/app/SharedFolderAlias.php index 146f51e6..f3796ee9 100644 --- a/src/app/SharedFolderAlias.php +++ b/src/app/SharedFolderAlias.php @@ -1,39 +1,39 @@ attributes['alias'] = \strtolower($alias); } /** * The shared folder to which this alias belongs. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function sharedFolder() { - return $this->belongsTo('\App\SharedFolder', 'shared_folder_id', 'id'); + return $this->belongsTo(SharedFolder::class, 'shared_folder_id', 'id'); } } diff --git a/src/app/SharedFolderSetting.php b/src/app/SharedFolderSetting.php index a73740e6..fba6b7c5 100644 --- a/src/app/SharedFolderSetting.php +++ b/src/app/SharedFolderSetting.php @@ -1,30 +1,29 @@ The attributes that are mass assignable */ + protected $fillable = ['shared_folder_id', 'key', 'value']; /** * The folder to which this setting belongs. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function folder() { - return $this->belongsTo(\App\SharedFolder::class, 'shared_folder_id', 'id'); + return $this->belongsTo(SharedFolder::class, 'shared_folder_id', 'id'); } } diff --git a/src/app/SignupCode.php b/src/app/SignupCode.php index 0e5e1559..f6fb4199 100644 --- a/src/app/SignupCode.php +++ b/src/app/SignupCode.php @@ -1,106 +1,88 @@ The attributes that are mass assignable */ protected $fillable = [ 'code', 'email', 'expires_at', 'first_name', 'last_name', 'plan', 'short_code', 'voucher' ]; - protected $casts = ['headers' => 'array']; + /** @var array The attributes that should be cast */ + protected $casts = [ + 'expires_at' => 'datetime:Y-m-d H:i:s', + 'headers' => 'array' + ]; - /** - * The attributes that should be mutated to dates. - * - * @var array - */ - protected $dates = ['expires_at']; /** * Check if code is expired. * * @return bool True if code is expired, False otherwise */ public function isExpired() { // @phpstan-ignore-next-line return $this->expires_at ? Carbon::now()->gte($this->expires_at) : false; } /** * Generate a short code (for human). * * @return string */ public static function generateShortCode(): string { $code_length = env('SIGNUP_CODE_LENGTH', self::SHORTCODE_LENGTH); return \App\Utils::randStr($code_length); } } diff --git a/src/app/SignupInvitation.php b/src/app/SignupInvitation.php index 54015065..d5f5ea68 100644 --- a/src/app/SignupInvitation.php +++ b/src/app/SignupInvitation.php @@ -1,90 +1,87 @@ The attributes that are mass assignable */ protected $fillable = ['email']; + /** * Returns whether this invitation process completed (user signed up) * * @return bool */ public function isCompleted(): bool { return ($this->status & self::STATUS_COMPLETED) > 0; } /** * Returns whether this invitation sending failed. * * @return bool */ public function isFailed(): bool { return ($this->status & self::STATUS_FAILED) > 0; } /** * Returns whether this invitation is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this invitation has been sent. * * @return bool */ public function isSent(): bool { return ($this->status & self::STATUS_SENT) > 0; } /** * The account to which the invitation was used for. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function user() { - return $this->belongsTo('App\User', 'user_id', 'id'); + return $this->belongsTo(User::class, 'user_id', 'id'); } } diff --git a/src/app/Sku.php b/src/app/Sku.php index 5bbb2d11..c1d97de6 100644 --- a/src/app/Sku.php +++ b/src/app/Sku.php @@ -1,76 +1,77 @@ The attributes that should be cast */ protected $casts = [ 'units_free' => 'integer' ]; + /** @var array The attributes that are mass assignable */ protected $fillable = [ 'active', 'cost', 'description', 'fee', 'handler_class', 'name', // persist for annual domain registration 'period', 'title', 'units_free', ]; - /** @var array Translatable properties */ + /** @var array Translatable properties */ public $translatable = [ 'name', 'description', ]; /** * List the entitlements that consume this SKU. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { - return $this->hasMany('App\Entitlement'); + return $this->hasMany(Entitlement::class); } /** * List of packages that use this SKU. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function packages() { - return $this->belongsToMany( - 'App\Package', - 'package_skus' - )->using('App\PackageSku')->withPivot(['cost', 'qty']); + return $this->belongsToMany(Package::class, 'package_skus') + ->using(PackageSku::class) + ->withPivot(['cost', 'qty']); } } diff --git a/src/app/Tenant.php b/src/app/Tenant.php index 197c5daa..47eec4b2 100644 --- a/src/app/Tenant.php +++ b/src/app/Tenant.php @@ -1,94 +1,92 @@ The attributes that are mass assignable */ + protected $fillable = ['id', 'title']; /** * Utility method to get tenant-specific system setting. * If the setting is not specified for the tenant a system-wide value will be returned. * * @param int $tenantId Tenant identifier * @param string $key Setting name * * @return mixed Setting value */ public static function getConfig($tenantId, string $key) { // Cache the tenant instance in memory static $tenant; if (empty($tenant) || $tenant->id != $tenantId) { $tenant = null; if ($tenantId) { $tenant = self::findOrFail($tenantId); } } // Supported options (TODO: document this somewhere): // - app.name (tenants.title will be returned) // - app.public_url and app.url // - app.support_url // - mail.from.address and mail.from.name // - mail.reply_to.address and mail.reply_to.name // - app.kb.account_delete and app.kb.account_suspended // - pgp.enable if ($key == 'app.name') { return $tenant ? $tenant->title : \config($key); } $value = $tenant ? $tenant->getSetting($key) : null; return $value !== null ? $value : \config($key); } /** * Discounts assigned to this tenant. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function discounts() { - return $this->hasMany('App\Discount'); + return $this->hasMany(Discount::class); } /** * SignupInvitations assigned to this tenant. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function signupInvitations() { - return $this->hasMany('App\SignupInvitation'); + return $this->hasMany(SignupInvitation::class); } /* * Returns the wallet of the tanant (reseller's wallet). * * @return ?\App\Wallet A wallet object */ public function wallet(): ?Wallet { - $user = \App\User::where('role', 'reseller')->where('tenant_id', $this->id)->first(); + $user = User::where('role', 'reseller')->where('tenant_id', $this->id)->first(); return $user ? $user->wallets->first() : null; } } diff --git a/src/app/TenantSetting.php b/src/app/TenantSetting.php index 0baac623..fe936ae0 100644 --- a/src/app/TenantSetting.php +++ b/src/app/TenantSetting.php @@ -1,30 +1,29 @@ The attributes that are mass assignable */ + protected $fillable = ['tenant_id', 'key', 'value']; /** * The tenant to which this setting belongs. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function tenant() { - return $this->belongsTo('\App\Tenant', 'tenant_id', 'id'); + return $this->belongsTo(Tenant::class, 'tenant_id', 'id'); } } diff --git a/src/app/Traits/EmailPropertyTrait.php b/src/app/Traits/EmailPropertyTrait.php index ac5bf2cd..640f2ed4 100644 --- a/src/app/Traits/EmailPropertyTrait.php +++ b/src/app/Traits/EmailPropertyTrait.php @@ -1,92 +1,92 @@ email) && defined('static::EMAIL_TEMPLATE')) { $template = static::EMAIL_TEMPLATE; // @phpstan-ignore-line $defaults = [ 'type' => 'mail', ]; foreach (['id', 'domainName', 'type'] as $prop) { if (strpos($template, "{{$prop}}") === false) { continue; } $value = $model->{$prop} ?? ($defaults[$prop] ?? ''); if ($value === '' || $value === null) { throw new \Exception("Missing '{$prop}' property for " . static::class); } $template = str_replace("{{$prop}}", $value, $template); } $model->email = strtolower($template); } }); } /** * Returns the object's domain (including soft-deleted). * * @return ?\App\Domain The domain to which the object belongs to, NULL if it does not exist */ public function domain(): ?\App\Domain { if (empty($this->email) && isset($this->domainName)) { $domainName = $this->domainName; } else { list($local, $domainName) = explode('@', $this->email); } return \App\Domain::withTrashed()->where('namespace', $domainName)->first(); } /** * Find whether an email address exists as a model object (including soft-deleted). * * @param string $email Email address * @param bool $return_object Return model instance instead of a boolean * - * @return object|bool True or Model object if found, False otherwise + * @return static|bool True or Model object if found, False otherwise */ public static function emailExists(string $email, bool $return_object = false) { if (strpos($email, '@') === false) { return false; } $email = \strtolower($email); $object = static::withTrashed()->where('email', $email)->first(); if ($object) { return $return_object ? $object : true; } return false; } /** * Ensure the email is appropriately cased. * * @param string $email Email address */ public function setEmailAttribute(string $email): void { $this->attributes['email'] = strtolower($email); } } diff --git a/src/app/Transaction.php b/src/app/Transaction.php index ec936232..054603ed 100644 --- a/src/app/Transaction.php +++ b/src/app/Transaction.php @@ -1,196 +1,190 @@ The attributes that are mass assignable */ protected $fillable = [ // actor, if any 'user_email', - // entitlement, wallet 'object_id', 'object_type', - // entitlement: created, deleted, billed // wallet: debit, credit, award, penalty 'type', - 'amount', - 'description', - // parent, for example wallet debit is parent for entitlements charged. 'transaction_id' ]; - /** @var array Casts properties as type */ + /** @var array Casts properties as type */ protected $casts = [ 'amount' => 'integer', ]; /** * Returns the entitlement to which the transaction is assigned (if any) * * @return \App\Entitlement|null The entitlement */ public function entitlement(): ?Entitlement { if ($this->object_type !== Entitlement::class) { return null; } return Entitlement::withTrashed()->find($this->object_id); } /** * Transaction type mutator * * @throws \Exception */ public function setTypeAttribute($value): void { switch ($value) { case self::ENTITLEMENT_BILLED: case self::ENTITLEMENT_CREATED: case self::ENTITLEMENT_DELETED: // TODO: Must be an entitlement. $this->attributes['type'] = $value; break; case self::WALLET_AWARD: case self::WALLET_CREDIT: case self::WALLET_DEBIT: case self::WALLET_PENALTY: case self::WALLET_REFUND: case self::WALLET_CHARGEBACK: // TODO: This must be a wallet. $this->attributes['type'] = $value; break; default: throw new \Exception("Invalid type value"); } } /** * Returns a short text describing the transaction. * * @return string The description */ public function shortDescription(): string { $label = $this->objectTypeToLabelString() . '-' . $this->{'type'} . '-short'; $result = \trans("transactions.{$label}", $this->descriptionParams()); return trim($result, ': '); } /** * Returns a text describing the transaction. * * @return string The description */ public function toString(): string { $label = $this->objectTypeToLabelString() . '-' . $this->{'type'}; return \trans("transactions.{$label}", $this->descriptionParams()); } /** * Returns a wallet to which the transaction is assigned (if any) * * @return \App\Wallet|null The wallet */ public function wallet(): ?Wallet { if ($this->object_type !== Wallet::class) { return null; } return Wallet::find($this->object_id); } /** * Collect transaction parameters used in (localized) descriptions * * @return array Parameters */ private function descriptionParams(): array { $result = [ 'user_email' => $this->user_email, 'description' => $this->description, ]; $amount = $this->amount * ($this->amount < 0 ? -1 : 1); if ($entitlement = $this->entitlement()) { $wallet = $entitlement->wallet; $cost = $entitlement->cost; $discount = $entitlement->wallet->getDiscountRate(); $result['entitlement_cost'] = $cost * $discount; $result['object'] = $entitlement->entitleableTitle(); $result['sku_title'] = $entitlement->sku->title; } else { $wallet = $this->wallet(); } $result['wallet'] = $wallet->description ?: 'Default wallet'; $result['amount'] = $wallet->money($amount); return $result; } /** * Get a string for use in translation tables derived from the object type. * * @return string|null */ private function objectTypeToLabelString(): ?string { if ($this->object_type == Entitlement::class) { return 'entitlement'; } if ($this->object_type == Wallet::class) { return 'wallet'; } return null; } } diff --git a/src/app/User.php b/src/app/User.php index a9428c74..84009c57 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,709 +1,709 @@ The attributes that are mass assignable */ protected $fillable = [ 'id', 'email', 'password', 'password_ldap', 'status', ]; - /** - * The attributes that should be hidden for arrays. - * - * @var array - */ + /** @var array The attributes that should be hidden for arrays */ protected $hidden = [ 'password', 'password_ldap', 'role' ]; + /** @var array The attributes that can be null */ protected $nullable = [ 'password', 'password_ldap' ]; + /** @var array The attributes that should be cast */ + protected $casts = [ + 'created_at' => 'datetime:Y-m-d H:i:s', + 'deleted_at' => 'datetime:Y-m-d H:i:s', + 'updated_at' => 'datetime:Y-m-d H:i:s', + ]; + /** * Any wallets on which this user is a controller. * * This does not include wallets owned by the user. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function accounts() { return $this->belongsToMany( - 'App\Wallet', // The foreign object definition + Wallet::class, // The foreign object definition 'user_accounts', // The table name 'user_id', // The local foreign key 'wallet_id' // The remote foreign key ); } /** * Assign a package to a user. The user should not have any existing entitlements. * * @param \App\Package $package The package to assign. * @param \App\User|null $user Assign the package to another user. * * @return \App\User */ public function assignPackage($package, $user = null) { if (!$user) { $user = $this; } return $user->assignPackageAndWallet($package, $this->wallets()->first()); } /** * Assign a package plan to a user. * * @param \App\Plan $plan The plan to assign * @param \App\Domain $domain Optional domain object * * @return \App\User Self */ public function assignPlan($plan, $domain = null): User { $this->setSetting('plan_id', $plan->id); foreach ($plan->packages as $package) { if ($package->isDomain()) { $domain->assignPackage($package, $this); } else { $this->assignPackage($package); } } return $this; } /** * Check if current user can delete another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canDelete($object): bool { if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); // TODO: For now controller can delete/update the account owner, // this may change in future, controllers are not 0-regression feature return $wallet && ($wallet->user_id == $this->id || $this->accounts->contains($wallet)); } /** * Check if current user can read data of another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canRead($object): bool { if ($this->role == 'admin') { return true; } if ($object instanceof User && $this->id == $object->id) { return true; } if ($this->role == 'reseller') { if ($object instanceof User && $object->role == 'admin') { return false; } if ($object instanceof Wallet && !empty($object->owner)) { $object = $object->owner; } return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; } if ($object instanceof Wallet) { return $object->user_id == $this->id || $object->controllers->contains($this); } if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); return $wallet && ($wallet->user_id == $this->id || $this->accounts->contains($wallet)); } /** * Check if current user can update data of another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canUpdate($object): bool { if ($object instanceof User && $this->id == $object->id) { return true; } if ($this->role == 'admin') { return true; } if ($this->role == 'reseller') { if ($object instanceof User && $object->role == 'admin') { return false; } if ($object instanceof Wallet && !empty($object->owner)) { $object = $object->owner; } return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; } return $this->canDelete($object); } /** * Degrade the user * * @return void */ public function degrade(): void { if ($this->isDegraded()) { return; } $this->status |= User::STATUS_DEGRADED; $this->save(); } /** * List the domains to which this user is entitled. * * @param bool $with_accounts Include domains assigned to wallets * the current user controls but not owns. * @param bool $with_public Include active public domains (for the user tenant). * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function domains($with_accounts = true, $with_public = true) { $domains = $this->entitleables(Domain::class, $with_accounts); if ($with_public) { $domains->orWhere(function ($query) { if (!$this->tenant_id) { $query->where('tenant_id', $this->tenant_id); } else { $query->withEnvTenantContext(); } - $query->whereRaw(sprintf('(domains.type & %s)', Domain::TYPE_PUBLIC)) - ->whereRaw(sprintf('(domains.status & %s)', Domain::STATUS_ACTIVE)); + $query->where('domains.type', '&', Domain::TYPE_PUBLIC) + ->where('domains.status', '&', Domain::STATUS_ACTIVE); }); } return $domains; } /** * Return entitleable objects of a specified type controlled by the current user. * * @param string $class Object class * @param bool $with_accounts Include objects assigned to wallets * the current user controls, but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ private function entitleables(string $class, bool $with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } $object = new $class(); $table = $object->getTable(); return $object->select("{$table}.*") ->whereExists(function ($query) use ($table, $wallets, $class) { $query->select(DB::raw(1)) ->from('entitlements') ->whereColumn('entitleable_id', "{$table}.id") ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', $class); }); } /** * Helper to find user by email address, whether it is * main email address, alias or an external email. * * If there's more than one alias NULL will be returned. * * @param string $email Email address * @param bool $external Search also for an external email * * @return \App\User|null User model object if found */ public static function findByEmail(string $email, bool $external = false): ?User { if (strpos($email, '@') === false) { return null; } $email = \strtolower($email); $user = self::where('email', $email)->first(); if ($user) { return $user; } - $aliases = \App\UserAlias::where('alias', $email)->get(); + $aliases = UserAlias::where('alias', $email)->get(); if (count($aliases) == 1) { return $aliases->first()->user; } // TODO: External email return null; } /** * Return groups controlled by the current user. * * @param bool $with_accounts Include groups assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function groups($with_accounts = true) { return $this->entitleables(Group::class, $with_accounts); } /** * Returns whether this user (or its wallet owner) is degraded. * * @param bool $owner Check also the wallet owner instead just the user himself * * @return bool */ public function isDegraded(bool $owner = false): bool { if ($this->status & self::STATUS_DEGRADED) { return true; } if ($owner && ($wallet = $this->wallet())) { return $wallet->owner && $wallet->owner->isDegraded(); } return false; } /** * A shortcut to get the user name. * * @param bool $fallback Return " User" if there's no name * * @return string Full user name */ public function name(bool $fallback = false): string { $settings = $this->getSettings(['first_name', 'last_name']); $name = trim($settings['first_name'] . ' ' . $settings['last_name']); if (empty($name) && $fallback) { - return trim(\trans('app.siteuser', ['site' => \App\Tenant::getConfig($this->tenant_id, 'app.name')])); + return trim(\trans('app.siteuser', ['site' => Tenant::getConfig($this->tenant_id, 'app.name')])); } return $name; } /** * Old passwords for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function passwords() { - return $this->hasMany('App\UserPassword'); + return $this->hasMany(UserPassword::class); } /** * Return resources controlled by the current user. * * @param bool $with_accounts Include resources assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function resources($with_accounts = true) { - return $this->entitleables(\App\Resource::class, $with_accounts); + return $this->entitleables(Resource::class, $with_accounts); } /** * Return shared folders controlled by the current user. * * @param bool $with_accounts Include folders assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function sharedFolders($with_accounts = true) { - return $this->entitleables(\App\SharedFolder::class, $with_accounts); + return $this->entitleables(SharedFolder::class, $with_accounts); } public function senderPolicyFrameworkWhitelist($clientName) { $setting = $this->getSetting('spf_whitelist'); if (!$setting) { return false; } $whitelist = json_decode($setting); $matchFound = false; foreach ($whitelist as $entry) { if (substr($entry, 0, 1) == '/') { $match = preg_match($entry, $clientName); if ($match) { $matchFound = true; } continue; } if (substr($entry, 0, 1) == '.') { if (substr($clientName, (-1 * strlen($entry))) == $entry) { $matchFound = true; } continue; } if ($entry == $clientName) { $matchFound = true; continue; } } return $matchFound; } /** * Un-degrade this user. * * @return void */ public function undegrade(): void { if (!$this->isDegraded()) { return; } $this->status ^= User::STATUS_DEGRADED; $this->save(); } /** * Return users controlled by the current user. * * @param bool $with_accounts Include users assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function users($with_accounts = true) { return $this->entitleables(User::class, $with_accounts); } /** * Verification codes for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function verificationcodes() { - return $this->hasMany('App\VerificationCode', 'user_id', 'id'); + return $this->hasMany(VerificationCode::class, 'user_id', 'id'); } /** * Wallets this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { - return $this->hasMany('App\Wallet'); + return $this->hasMany(Wallet::class); } /** * User password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordAttribute($password) { if (!empty($password)) { $this->attributes['password'] = Hash::make($password); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * User LDAP password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordLdapAttribute($password) { $this->setPasswordAttribute($password); } /** * User status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, self::STATUS_IMAP_READY, self::STATUS_DEGRADED, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid user status: {$status}"); } $this->attributes['status'] = $new_status; } /** * Validate the user credentials * * @param string $username The username. * @param string $password The password in plain text. * @param bool $updatePassword Store the password if currently empty * * @return bool true on success */ public function validateCredentials(string $username, string $password, bool $updatePassword = true): bool { $authenticated = false; if ($this->email === \strtolower($username)) { if (!empty($this->password)) { if (Hash::check($password, $this->password)) { $authenticated = true; } } elseif (!empty($this->password_ldap)) { if (substr($this->password_ldap, 0, 6) == "{SSHA}") { $salt = substr(base64_decode(substr($this->password_ldap, 6)), 20); $hash = '{SSHA}' . base64_encode( sha1($password . $salt, true) . $salt ); if ($hash == $this->password_ldap) { $authenticated = true; } } elseif (substr($this->password_ldap, 0, 9) == "{SSHA512}") { $salt = substr(base64_decode(substr($this->password_ldap, 9)), 64); $hash = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password . $salt)) . $salt ); if ($hash == $this->password_ldap) { $authenticated = true; } } } else { \Log::error("Incomplete credentials for {$this->email}"); } } if ($authenticated) { \Log::info("Successful authentication for {$this->email}"); // TODO: update last login time if ($updatePassword && (empty($this->password) || empty($this->password_ldap))) { $this->password = $password; $this->save(); } } else { // TODO: Try actual LDAP? \Log::info("Authentication failed for {$this->email}"); } return $authenticated; } /** * Retrieve and authenticate a user * * @param string $username The username. * @param string $password The password in plain text. * @param string $secondFactor The second factor (secondfactor from current request is used as fallback). * * @return array ['user', 'reason', 'errorMessage'] */ public static function findAndAuthenticate($username, $password, $secondFactor = null): ?array { $user = User::where('email', $username)->first(); if (!$user) { return ['reason' => 'notfound', 'errorMessage' => "User not found."]; } if (!$user->validateCredentials($username, $password)) { return ['reason' => 'credentials', 'errorMessage' => "Invalid password."]; } if (!$secondFactor) { // Check the request if there is a second factor provided // as fallback. $secondFactor = request()->secondfactor; } try { (new \App\Auth\SecondFactor($user))->validate($secondFactor); } catch (\Exception $e) { return ['reason' => 'secondfactor', 'errorMessage' => $e->getMessage()]; } return ['user' => $user]; } /** * Hook for passport * * @throws \Throwable * * @return \App\User User model object if found */ public function findAndValidateForPassport($username, $password): User { $result = self::findAndAuthenticate($username, $password); if (isset($result['reason'])) { if ($result['reason'] == 'secondfactor') { // This results in a json response of {'error': 'secondfactor', 'error_description': '$errorMessage'} throw new OAuthServerException($result['errorMessage'], 6, 'secondfactor', 401); } throw OAuthServerException::invalidCredentials(); } return $result['user']; } } diff --git a/src/app/UserAlias.php b/src/app/UserAlias.php index 46ce47b0..1f744626 100644 --- a/src/app/UserAlias.php +++ b/src/app/UserAlias.php @@ -1,39 +1,38 @@ The attributes that are mass assignable */ + protected $fillable = ['user_id', 'alias']; /** * Ensure the email address is appropriately cased. * * @param string $alias Email address */ public function setAliasAttribute(string $alias) { $this->attributes['alias'] = \strtolower($alias); } /** * The user to which this alias belongs. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function user() { - return $this->belongsTo('\App\User', 'user_id', 'id'); + return $this->belongsTo(User::class, 'user_id', 'id'); } } diff --git a/src/app/UserPassword.php b/src/app/UserPassword.php index 63ab0efa..7766d07b 100644 --- a/src/app/UserPassword.php +++ b/src/app/UserPassword.php @@ -1,37 +1,39 @@ The attributes that should be cast. */ + protected $casts = [ + 'created_at' => 'datetime:Y-m-d H:i:s', + ]; - /** @var array The attributes that are mass assignable. */ + /** @var array The attributes that are mass assignable. */ protected $fillable = ['user_id', 'password']; - /** @var array The attributes that should be hidden for arrays. */ + /** @var array The attributes that should be hidden for arrays. */ protected $hidden = ['password']; /** * The user to which this entry belongs. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function user() { - return $this->belongsTo('\App\User', 'user_id', 'id'); + return $this->belongsTo(User::class, 'user_id', 'id'); } } diff --git a/src/app/UserSetting.php b/src/app/UserSetting.php index d160e504..3d3c2f80 100644 --- a/src/app/UserSetting.php +++ b/src/app/UserSetting.php @@ -1,30 +1,29 @@ The attributes that are mass assignable */ + protected $fillable = ['user_id', 'key', 'value']; /** * The user to which this setting belongs. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function user() { - return $this->belongsTo('\App\User', 'user_id', 'id'); + return $this->belongsTo(User::class, 'user_id', 'id'); } } diff --git a/src/app/VerificationCode.php b/src/app/VerificationCode.php index d226b957..13544011 100644 --- a/src/app/VerificationCode.php +++ b/src/app/VerificationCode.php @@ -1,107 +1,83 @@ Casts properties as type */ protected $casts = [ 'active' => 'boolean', 'expires_at' => 'datetime', ]; - /** - * The attributes that are mass assignable. - * - * @var array - */ + /** @var array The attributes that are mass assignable */ protected $fillable = ['user_id', 'code', 'short_code', 'mode', 'expires_at', 'active']; /** * Generate a short code (for human). * * @return string */ public static function generateShortCode(): string { $code_length = env('VERIFICATION_CODE_LENGTH', self::SHORTCODE_LENGTH); return \App\Utils::randStr($code_length); } /** * Check if code is expired. * * @return bool True if code is expired, False otherwise */ public function isExpired() { // @phpstan-ignore-next-line return $this->expires_at ? Carbon::now()->gte($this->expires_at) : false; } /** * The user to which this code belongs. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function user() { - return $this->belongsTo('\App\User', 'user_id', 'id'); + return $this->belongsTo(User::class, 'user_id', 'id'); } } diff --git a/src/app/Wallet.php b/src/app/Wallet.php index 1187b0e4..36280ded 100644 --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -1,508 +1,491 @@ 0, ]; - /** - * The attributes that are mass assignable. - * - * @var array - */ + /** @var array The attributes that are mass assignable */ protected $fillable = [ 'currency', 'description' ]; - /** - * The attributes that can be not set. - * - * @var array - */ + /** @var array The attributes that can be not set */ protected $nullable = [ 'description', ]; - /** - * The types of attributes to which its values will be cast - * - * @var array - */ + /** @var array The types of attributes to which its values will be cast */ protected $casts = [ 'balance' => 'integer', ]; /** * Add a controller to this wallet. * * @param \App\User $user The user to add as a controller to this wallet. * * @return void */ public function addController(User $user) { if (!$this->controllers->contains($user)) { $this->controllers()->save($user); } } /** * Charge entitlements in the wallet * * @param bool $apply Set to false for a dry-run mode * * @return int Charged amount in cents */ public function chargeEntitlements($apply = true): int { // This wallet has been created less than a month ago, this is the trial period if ($this->owner->created_at >= Carbon::now()->subMonthsWithoutOverflow(1)) { // Move all the current entitlement's updated_at timestamps forward to one month after // this wallet was created. $freeMonthEnds = $this->owner->created_at->copy()->addMonthsWithoutOverflow(1); foreach ($this->entitlements()->get()->fresh() as $entitlement) { if ($entitlement->updated_at < $freeMonthEnds) { $entitlement->updated_at = $freeMonthEnds; $entitlement->save(); } } return 0; } $profit = 0; $charges = 0; $discount = $this->getDiscountRate(); $isDegraded = $this->owner->isDegraded(); if ($apply) { DB::beginTransaction(); } // used to parent individual entitlement billings to the wallet debit. $entitlementTransactions = []; foreach ($this->entitlements()->get() as $entitlement) { // This entitlement has been created less than or equal to 14 days ago (this is at // maximum the fourteenth 24-hour period). if ($entitlement->created_at > Carbon::now()->subDays(14)) { continue; } // This entitlement was created, or billed last, less than a month ago. if ($entitlement->updated_at > Carbon::now()->subMonthsWithoutOverflow(1)) { continue; } // updated last more than a month ago -- was it billed? if ($entitlement->updated_at <= Carbon::now()->subMonthsWithoutOverflow(1)) { $diff = $entitlement->updated_at->diffInMonths(Carbon::now()); $cost = (int) ($entitlement->cost * $discount * $diff); $fee = (int) ($entitlement->fee * $diff); if ($isDegraded) { $cost = 0; } $charges += $cost; $profit += $cost - $fee; // if we're in dry-run, you know... if (!$apply) { continue; } $entitlement->updated_at = $entitlement->updated_at->copy() ->addMonthsWithoutOverflow($diff); $entitlement->save(); if ($cost == 0) { continue; } $entitlementTransactions[] = $entitlement->createTransaction( - \App\Transaction::ENTITLEMENT_BILLED, + Transaction::ENTITLEMENT_BILLED, $cost ); } } if ($apply) { $this->debit($charges, '', $entitlementTransactions); // Credit/debit the reseller if ($profit != 0 && $this->owner->tenant) { // FIXME: Should we have a simpler way to skip this for non-reseller tenant(s) if ($wallet = $this->owner->tenant->wallet()) { $desc = "Charged user {$this->owner->email}"; $method = $profit > 0 ? 'credit' : 'debit'; $wallet->{$method}(abs($profit), $desc); } } DB::commit(); } return $charges; } /** * Calculate for how long the current balance will last. * * Returns NULL for balance < 0 or discount = 100% or on a fresh account * * @return \Carbon\Carbon|null Date */ public function balanceLastsUntil() { if ($this->balance < 0 || $this->getDiscount() == 100) { return null; } // retrieve any expected charges $expectedCharge = $this->expectedCharges(); // get the costs per day for all entitlements billed against this wallet $costsPerDay = $this->costsPerDay(); if (!$costsPerDay) { return null; } // the number of days this balance, minus the expected charges, would last $daysDelta = floor(($this->balance - $expectedCharge) / $costsPerDay); // calculate from the last entitlement billed $entitlement = $this->entitlements()->orderBy('updated_at', 'desc')->first(); $until = $entitlement->updated_at->copy()->addDays($daysDelta); // Don't return dates from the past if ($until < Carbon::now() && !$until->isToday()) { return null; } return $until; } /** * Controllers of this wallet. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function controllers() { return $this->belongsToMany( - 'App\User', // The foreign object definition - 'user_accounts', // The table name + User::class, // The foreign object definition + 'user_accounts', // The table name 'wallet_id', // The local foreign key 'user_id' // The remote foreign key ); } /** * Retrieve the costs per day of everything charged to this wallet. * * @return float */ public function costsPerDay() { $costs = (float) 0; foreach ($this->entitlements as $entitlement) { $costs += $entitlement->costsPerDay(); } return $costs; } /** * Add an amount of pecunia to this wallet's balance. * * @param int $amount The amount of pecunia to add (in cents). * @param string $description The transaction description * * @return Wallet Self */ public function credit(int $amount, string $description = ''): Wallet { $this->balance += $amount; $this->save(); - \App\Transaction::create( + Transaction::create( [ 'object_id' => $this->id, - 'object_type' => \App\Wallet::class, - 'type' => \App\Transaction::WALLET_CREDIT, + 'object_type' => Wallet::class, + 'type' => Transaction::WALLET_CREDIT, 'amount' => $amount, 'description' => $description ] ); return $this; } /** * Deduct an amount of pecunia from this wallet's balance. * * @param int $amount The amount of pecunia to deduct (in cents). * @param string $description The transaction description * @param array $eTIDs List of transaction IDs for the individual entitlements * that make up this debit record, if any. * @return Wallet Self */ public function debit(int $amount, string $description = '', array $eTIDs = []): Wallet { if ($amount == 0) { return $this; } $this->balance -= $amount; $this->save(); - $transaction = \App\Transaction::create( + $transaction = Transaction::create( [ 'object_id' => $this->id, - 'object_type' => \App\Wallet::class, - 'type' => \App\Transaction::WALLET_DEBIT, + 'object_type' => Wallet::class, + 'type' => Transaction::WALLET_DEBIT, 'amount' => $amount * -1, 'description' => $description ] ); if (!empty($eTIDs)) { - \App\Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]); + Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]); } return $this; } /** * The discount assigned to the wallet. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function discount() { - return $this->belongsTo('App\Discount', 'discount_id', 'id'); + return $this->belongsTo(Discount::class, 'discount_id', 'id'); } /** * Entitlements billed to this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { - return $this->hasMany('App\Entitlement'); + return $this->hasMany(Entitlement::class); } /** * Calculate the expected charges to this wallet. * * @return int */ public function expectedCharges() { return $this->chargeEntitlements(false); } /** * Return the exact, numeric version of the discount to be applied. * * Ranges from 0 - 100. * * @return int */ public function getDiscount() { return $this->discount ? $this->discount->discount : 0; } /** * The actual discount rate for use in multiplication * * Ranges from 0.00 to 1.00. */ public function getDiscountRate() { return (100 - $this->getDiscount()) / 100; } /** * A helper to display human-readable amount of money using * the wallet currency and specified locale. * * @param int $amount A amount of money (in cents) * @param string $locale A locale for the output * * @return string String representation, e.g. "9.99 CHF" */ public function money(int $amount, $locale = 'de_DE') { $amount = round($amount / 100, 2); $nf = new \NumberFormatter($locale, \NumberFormatter::CURRENCY); $result = $nf->formatCurrency($amount, $this->currency); // Replace non-breaking space return str_replace("\xC2\xA0", " ", $result); } /** * The owner of the wallet -- the wallet is in his/her back pocket. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function owner() { - return $this->belongsTo('App\User', 'user_id', 'id'); + return $this->belongsTo(User::class, 'user_id', 'id'); } /** * Payments on this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function payments() { - return $this->hasMany('App\Payment'); + return $this->hasMany(Payment::class); } /** * Remove a controller from this wallet. * * @param \App\User $user The user to remove as a controller from this wallet. * * @return void */ public function removeController(User $user) { if ($this->controllers->contains($user)) { $this->controllers()->detach($user); } } /** * Retrieve the transactions against this wallet. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function transactions() { - return \App\Transaction::where( + return Transaction::where( [ 'object_id' => $this->id, - 'object_type' => \App\Wallet::class + 'object_type' => Wallet::class ] ); } /** * Force-update entitlements' updated_at, charge if needed. * * @param bool $withCost When enabled the cost will be charged * * @return int Charged amount in cents */ public function updateEntitlements($withCost = true): int { $charges = 0; $discount = $this->getDiscountRate(); $now = Carbon::now(); DB::beginTransaction(); // used to parent individual entitlement billings to the wallet debit. $entitlementTransactions = []; foreach ($this->entitlements()->get() as $entitlement) { $cost = 0; $diffInDays = $entitlement->updated_at->diffInDays($now); // This entitlement has been created less than or equal to 14 days ago (this is at // maximum the fourteenth 24-hour period). if ($entitlement->created_at > Carbon::now()->subDays(14)) { // $cost=0 } elseif ($withCost && $diffInDays > 0) { // The price per day is based on the number of days in the last month // or the current month if the period does not overlap with the previous month // FIXME: This really should be simplified to constant $daysInMonth=30 if ($now->day >= $diffInDays && $now->month == $entitlement->updated_at->month) { $daysInMonth = $now->daysInMonth; } else { $daysInMonth = \App\Utils::daysInLastMonth(); } $pricePerDay = $entitlement->cost / $daysInMonth; $cost = (int) (round($pricePerDay * $discount * $diffInDays, 0)); } if ($diffInDays > 0) { $entitlement->updated_at = $entitlement->updated_at->setDateFrom($now); $entitlement->save(); } if ($cost == 0) { continue; } $charges += $cost; // FIXME: Shouldn't we store also cost=0 transactions (to have the full history)? $entitlementTransactions[] = $entitlement->createTransaction( - \App\Transaction::ENTITLEMENT_BILLED, + Transaction::ENTITLEMENT_BILLED, $cost ); } if ($charges > 0) { $this->debit($charges, '', $entitlementTransactions); } DB::commit(); return $charges; } } diff --git a/src/app/WalletSetting.php b/src/app/WalletSetting.php index 1f3f6e0a..0940675f 100644 --- a/src/app/WalletSetting.php +++ b/src/app/WalletSetting.php @@ -1,30 +1,29 @@ The attributes that are mass assignable */ + protected $fillable = ['wallet_id', 'key', 'value']; /** * The wallet to which this setting belongs. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function wallet() { - return $this->belongsTo('\App\Wallet', 'wallet_id', 'id'); + return $this->belongsTo(Wallet::class, 'wallet_id', 'id'); } } diff --git a/src/composer.json b/src/composer.json index f700554e..2f91f889 100644 --- a/src/composer.json +++ b/src/composer.json @@ -1,87 +1,86 @@ { - "name": "laravel/laravel", + "name": "kolab/kolab4", "type": "project", - "description": "The Laravel Framework.", + "description": "Kolab 4", "keywords": [ "framework", "laravel" ], "license": "MIT", "repositories": [ { "type": "vcs", "url": "https://git.kolab.org/diffusion/PNL/php-net_ldap3.git" } ], "require": { - "php": "^7.3", - "barryvdh/laravel-dompdf": "^0.8.6", - "doctrine/dbal": "^2.13", - "dyrynda/laravel-nullable-fields": "*", - "fideloper/proxy": "^4.0", - "guzzlehttp/guzzle": "^7.3", + "php": "^8.0", + "barryvdh/laravel-dompdf": "^1.0.0", + "doctrine/dbal": "^3.3.2", + "dyrynda/laravel-nullable-fields": "^4.2.0", + "guzzlehttp/guzzle": "^7.4.1", "kolab/net_ldap3": "dev-master", - "laravel/framework": "6.*", - "laravel/horizon": "^3", - "laravel/passport": "^9", - "laravel/tinker": "^2.4", - "mlocati/spf-lib": "^3.0", - "mollie/laravel-mollie": "^2.9", + "laravel/framework": "^9.2", + "laravel/horizon": "^5.9", + "laravel/octane": "^1.2", + "laravel/passport": "^10.3", + "laravel/tinker": "^2.7", + "mlocati/spf-lib": "^3.1", + "mollie/laravel-mollie": "^2.19", "moontoast/math": "^1.2", - "morrislaptop/laravel-queue-clear": "^1.2", "pear/crypt_gpg": "^1.6.6", - "silviolleite/laravelpwa": "^2.0", - "spatie/laravel-translatable": "^4.2", - "spomky-labs/otphp": "~4.0.0", - "stripe/stripe-php": "^7.29", - "swooletw/laravel-swoole": "^2.6" + "predis/predis": "^1.1.10", + "spatie/laravel-translatable": "^5.2", + "spomky-labs/otphp": "~10.0.0", + "stripe/stripe-php": "^7.29" }, "require-dev": { - "beyondcode/laravel-er-diagram-generator": "^1.3", - "code-lts/doctum": "^5.1", - "kirschbaum-development/mail-intercept": "^0.2.4", - "laravel/dusk": "~6.15.0", - "nunomaduro/larastan": "^0.7", - "phpstan/phpstan": "^0.12", + "code-lts/doctum": "^5.5.1", + "laravel/dusk": "~6.22.0", + "nunomaduro/larastan": "^2.0", + "phpstan/phpstan": "^1.4", "phpunit/phpunit": "^9", - "squizlabs/php_codesniffer": "3.*" + "squizlabs/php_codesniffer": "^3.6" }, "config": { "optimize-autoloader": true, "preferred-install": "dist", "sort-packages": true }, "extra": { "laravel": { "dont-discover": [] } }, "autoload": { "psr-4": { "App\\": "app/" }, "classmap": [ "database/seeds", "include" ] }, "autoload-dev": { "psr-4": { "Tests\\": "tests/" } }, "minimum-stability": "stable", "prefer-stable": true, "scripts": { "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" ], + "post-update-cmd": [ + "@php artisan vendor:publish --tag=laravel-assets --ansi --force" + ], "post-root-package-install": [ "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" ], "post-create-project-cmd": [ "@php artisan key:generate --ansi" ] } } diff --git a/src/config/app.php b/src/config/app.php index cdd69852..9af37f54 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -1,308 +1,273 @@ env('APP_NAME', 'Laravel'), /* |-------------------------------------------------------------------------- | Application Environment |-------------------------------------------------------------------------- | | This value determines the "environment" your application is currently | running in. This may determine how you prefer to configure various | services the application utilizes. Set this in your ".env" file. | */ 'env' => env('APP_ENV', 'production'), /* |-------------------------------------------------------------------------- | Application Debug Mode |-------------------------------------------------------------------------- | | When your application is in debug mode, detailed error messages with | stack traces will be shown on every error that occurs within your | application. If disabled, a simple generic error page is shown. | */ 'debug' => env('APP_DEBUG', false), /* |-------------------------------------------------------------------------- | Application URL |-------------------------------------------------------------------------- | | This URL is used by the console to properly generate URLs when using | the Artisan command line tool. You should set this to the root of | your application so that it is used when running Artisan tasks. */ 'url' => env('APP_URL', 'http://localhost'), 'passphrase' => env('APP_PASSPHRASE', null), 'public_url' => env('APP_PUBLIC_URL', env('APP_URL', 'http://localhost')), - 'asset_url' => env('ASSET_URL', null), + 'asset_url' => env('ASSET_URL'), 'support_url' => env('SUPPORT_URL', null), 'support_email' => env('SUPPORT_EMAIL', null), 'webmail_url' => env('WEBMAIL_URL', null), 'theme' => env('APP_THEME', 'default'), 'tenant_id' => env('APP_TENANT_ID', null), 'currency' => \strtoupper(env('APP_CURRENCY', 'CHF')), /* |-------------------------------------------------------------------------- | Application Domain |-------------------------------------------------------------------------- | | System domain used for user signup (kolab identity) */ 'domain' => env('APP_DOMAIN', 'domain.tld'), 'website_domain' => env('APP_WEBSITE_DOMAIN', env('APP_DOMAIN', 'domain.tld')), /* |-------------------------------------------------------------------------- | Application Timezone |-------------------------------------------------------------------------- | | Here you may specify the default timezone for your application, which | will be used by the PHP date and date-time functions. We have gone | ahead and set this to a sensible default for you out of the box. | */ 'timezone' => 'UTC', /* |-------------------------------------------------------------------------- | Application Locale Configuration |-------------------------------------------------------------------------- | | The application locale determines the default locale that will be used | by the translation service provider. You are free to set this value | to any of the locales which will be supported by the application. | */ 'locale' => env('APP_LOCALE', 'en'), /* |-------------------------------------------------------------------------- | Application Fallback Locale |-------------------------------------------------------------------------- | | The fallback locale determines the locale to use when the current one | is not available. You may change the value to correspond to any of | the language folders that are provided through your application. | */ 'fallback_locale' => 'en', /* |-------------------------------------------------------------------------- | Faker Locale |-------------------------------------------------------------------------- | | This locale will be used by the Faker PHP library when generating fake | data for your database seeds. For example, this will be used to get | localized telephone numbers, street address information and more. | */ 'faker_locale' => 'en_US', /* |-------------------------------------------------------------------------- | Encryption Key |-------------------------------------------------------------------------- | | This key is used by the Illuminate encrypter service and should be set | to a random, 32 character string, otherwise these encrypted strings | will not be safe. Please do this before deploying an application! | */ 'key' => env('APP_KEY'), 'cipher' => 'AES-256-CBC', /* |-------------------------------------------------------------------------- | Autoloaded Service Providers |-------------------------------------------------------------------------- | | The service providers listed here will be automatically loaded on the | request to your application. Feel free to add your own services to | this array to grant expanded functionality to your applications. | */ 'providers' => [ /* * Laravel Framework Service Providers... */ Illuminate\Auth\AuthServiceProvider::class, Illuminate\Broadcasting\BroadcastServiceProvider::class, Illuminate\Bus\BusServiceProvider::class, Illuminate\Cache\CacheServiceProvider::class, Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class, Illuminate\Cookie\CookieServiceProvider::class, Illuminate\Database\DatabaseServiceProvider::class, Illuminate\Encryption\EncryptionServiceProvider::class, Illuminate\Filesystem\FilesystemServiceProvider::class, Illuminate\Foundation\Providers\FoundationServiceProvider::class, Illuminate\Hashing\HashServiceProvider::class, Illuminate\Mail\MailServiceProvider::class, Illuminate\Notifications\NotificationServiceProvider::class, Illuminate\Pagination\PaginationServiceProvider::class, Illuminate\Pipeline\PipelineServiceProvider::class, Illuminate\Queue\QueueServiceProvider::class, Illuminate\Redis\RedisServiceProvider::class, Illuminate\Auth\Passwords\PasswordResetServiceProvider::class, Illuminate\Session\SessionServiceProvider::class, Illuminate\Translation\TranslationServiceProvider::class, Illuminate\Validation\ValidationServiceProvider::class, Illuminate\View\ViewServiceProvider::class, /* * Package Service Providers... */ Barryvdh\DomPDF\ServiceProvider::class, /* * Application Service Providers... */ App\Providers\AppServiceProvider::class, App\Providers\AuthServiceProvider::class, // App\Providers\BroadcastServiceProvider::class, App\Providers\EventServiceProvider::class, App\Providers\HorizonServiceProvider::class, App\Providers\PassportServiceProvider::class, App\Providers\RouteServiceProvider::class, ], /* |-------------------------------------------------------------------------- | Class Aliases |-------------------------------------------------------------------------- | | This array of class aliases will be registered when this application | is started. However, feel free to register as many as you wish as | the aliases are "lazy" loaded so they don't hinder performance. | */ - 'aliases' => [ - 'App' => Illuminate\Support\Facades\App::class, - 'Arr' => Illuminate\Support\Arr::class, - 'Artisan' => Illuminate\Support\Facades\Artisan::class, - 'Auth' => Illuminate\Support\Facades\Auth::class, - 'Blade' => Illuminate\Support\Facades\Blade::class, - 'Broadcast' => Illuminate\Support\Facades\Broadcast::class, - 'Bus' => Illuminate\Support\Facades\Bus::class, - 'Cache' => Illuminate\Support\Facades\Cache::class, - 'Config' => Illuminate\Support\Facades\Config::class, - 'Cookie' => Illuminate\Support\Facades\Cookie::class, - 'Crypt' => Illuminate\Support\Facades\Crypt::class, - 'DB' => Illuminate\Support\Facades\DB::class, - 'Eloquent' => Illuminate\Database\Eloquent\Model::class, - 'Event' => Illuminate\Support\Facades\Event::class, - 'File' => Illuminate\Support\Facades\File::class, - 'Gate' => Illuminate\Support\Facades\Gate::class, - 'Hash' => Illuminate\Support\Facades\Hash::class, - 'Lang' => Illuminate\Support\Facades\Lang::class, - 'Log' => Illuminate\Support\Facades\Log::class, - 'Mail' => Illuminate\Support\Facades\Mail::class, - 'Notification' => Illuminate\Support\Facades\Notification::class, - 'Password' => Illuminate\Support\Facades\Password::class, + 'aliases' => \Illuminate\Support\Facades\Facade::defaultAliases()->merge([ 'PDF' => Barryvdh\DomPDF\Facade::class, - 'Queue' => Illuminate\Support\Facades\Queue::class, - 'Redirect' => Illuminate\Support\Facades\Redirect::class, - 'Redis' => Illuminate\Support\Facades\Redis::class, - 'Request' => Illuminate\Support\Facades\Request::class, - 'Response' => Illuminate\Support\Facades\Response::class, - 'Route' => Illuminate\Support\Facades\Route::class, - 'Schema' => Illuminate\Support\Facades\Schema::class, - 'Session' => Illuminate\Support\Facades\Session::class, - 'Storage' => Illuminate\Support\Facades\Storage::class, - 'Str' => Illuminate\Support\Str::class, - 'URL' => Illuminate\Support\Facades\URL::class, - 'Validator' => Illuminate\Support\Facades\Validator::class, - 'View' => Illuminate\Support\Facades\View::class, - ], + ])->toArray(), 'headers' => [ 'csp' => env('APP_HEADER_CSP', ""), 'xfo' => env('APP_HEADER_XFO', ""), ], // Locations of knowledge base articles 'kb' => [ // An article about suspended accounts 'account_suspended' => env('KB_ACCOUNT_SUSPENDED'), // An article about a way to delete an owned account 'account_delete' => env('KB_ACCOUNT_DELETE'), ], 'company' => [ 'name' => env('COMPANY_NAME'), 'address' => env('COMPANY_ADDRESS'), 'details' => env('COMPANY_DETAILS'), 'email' => env('COMPANY_EMAIL'), 'logo' => env('COMPANY_LOGO'), 'footer' => env('COMPANY_FOOTER', env('COMPANY_DETAILS')), ], 'storage' => [ 'min_qty' => (int) env('STORAGE_MIN_QTY', 5), // in GB ], 'vat' => [ 'countries' => env('VAT_COUNTRIES'), 'rate' => (float) env('VAT_RATE'), ], 'password_policy' => env('PASSWORD_POLICY') ?: 'min:6,max:255', 'payment' => [ 'methods_oneoff' => env('PAYMENT_METHODS_ONEOFF', 'creditcard,paypal,banktransfer'), 'methods_recurring' => env('PAYMENT_METHODS_RECURRING', 'creditcard'), ], 'with_admin' => (bool) env('APP_WITH_ADMIN', false), 'with_reseller' => (bool) env('APP_WITH_RESELLER', false), 'with_services' => (bool) env('APP_WITH_SERVICES', false), 'signup' => [ 'email_limit' => (int) env('SIGNUP_LIMIT_EMAIL', 0), 'ip_limit' => (int) env('SIGNUP_LIMIT_IP', 0), ], 'woat_ns1' => env('WOAT_NS1', 'ns01.' . env('APP_DOMAIN')), 'woat_ns2' => env('WOAT_NS2', 'ns02.' . env('APP_DOMAIN')), 'ratelimit_whitelist' => explode(',', env('RATELIMIT_WHITELIST', '')) ]; diff --git a/src/config/auth.php b/src/config/auth.php index d72f2dc1..6dcaae0f 100644 --- a/src/config/auth.php +++ b/src/config/auth.php @@ -1,123 +1,123 @@ [ 'guard' => 'api', 'passwords' => 'users', ], /* |-------------------------------------------------------------------------- | Authentication Guards |-------------------------------------------------------------------------- | | Next, you may define every authentication guard for your application. | Of course, a great default configuration has been defined for you | here which uses session storage and the Eloquent user provider. | | All authentication drivers have a user provider. This defines how the | users are actually retrieved out of your database or other storage | mechanisms used by this application to persist your user's data. | | Supported: "session", "token" | */ 'guards' => [ 'web' => [ 'driver' => 'session', 'provider' => 'users', ], 'api' => [ 'driver' => 'passport', 'provider' => 'users', ], ], /* |-------------------------------------------------------------------------- | User Providers |-------------------------------------------------------------------------- | | All authentication drivers have a user provider. This defines how the | users are actually retrieved out of your database or other storage | mechanisms used by this application to persist your user's data. | | If you have multiple user tables or models you may configure multiple | sources which represent each model / table. These sources may then | be assigned to any extra authentication guards you have defined. | | Supported: "database", "eloquent" | */ 'providers' => [ 'users' => [ - 'driver' => 'ldap', + 'driver' => 'eloquent', 'model' => App\User::class, ], // 'users' => [ // 'driver' => 'database', // 'table' => 'users', // ], ], /* |-------------------------------------------------------------------------- | Resetting Passwords |-------------------------------------------------------------------------- | | You may specify multiple password reset configurations if you have more | than one user table or model in the application and you want to have | separate password reset settings based on the specific user types. | | The expire time is the number of minutes that the reset token should be | considered valid. This security feature keeps tokens short-lived so | they have less time to be guessed. You may change this as needed. | */ 'passwords' => [ 'users' => [ 'provider' => 'users', 'table' => 'password_resets', 'expire' => 60, ], ], /* |-------------------------------------------------------------------------- | OAuth Proxy Authentication |-------------------------------------------------------------------------- | | If you are planning to use your application to self-authenticate as a | proxy, you can define the client and grant type to use here. This is | sometimes the case when a trusted Single Page Application doesn't | use a backend to send the authentication request, but instead | relies on the API to handle proxying the request to itself. | */ 'proxy' => [ 'client_id' => env('PASSPORT_PROXY_OAUTH_CLIENT_ID'), 'client_secret' => env('PASSPORT_PROXY_OAUTH_CLIENT_SECRET'), ], 'token_expiry_minutes' => env('OAUTH_TOKEN_EXPIRY', 60), 'refresh_token_expiry_minutes' => env('OAUTH_REFRESH_TOKEN_EXPIRY', 30 * 24 * 60), ]; diff --git a/src/config/broadcasting.php b/src/config/broadcasting.php index 3bba1103..67fcbbd6 100644 --- a/src/config/broadcasting.php +++ b/src/config/broadcasting.php @@ -1,59 +1,67 @@ env('BROADCAST_DRIVER', 'null'), /* |-------------------------------------------------------------------------- | Broadcast Connections |-------------------------------------------------------------------------- | | Here you may define all of the broadcast connections that will be used | to broadcast events to other systems or over websockets. Samples of | each available type of connection are provided inside this array. | */ 'connections' => [ 'pusher' => [ 'driver' => 'pusher', 'key' => env('PUSHER_APP_KEY'), 'secret' => env('PUSHER_APP_SECRET'), 'app_id' => env('PUSHER_APP_ID'), 'options' => [ 'cluster' => env('PUSHER_APP_CLUSTER'), 'useTLS' => true, ], + 'client_options' => [ + // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html + ], + ], + + 'ably' => [ + 'driver' => 'ably', + 'key' => env('ABLY_KEY'), ], 'redis' => [ 'driver' => 'redis', 'connection' => 'default', ], 'log' => [ 'driver' => 'log', ], 'null' => [ 'driver' => 'null', ], ], ]; diff --git a/src/config/cache.php b/src/config/cache.php index 93adfa06..b463a98d 100644 --- a/src/config/cache.php +++ b/src/config/cache.php @@ -1,103 +1,110 @@ env('CACHE_DRIVER', 'redis'), /* |-------------------------------------------------------------------------- | Cache Stores |-------------------------------------------------------------------------- | | Here you may define all of the cache "stores" for your application as | well as their drivers. You may even define multiple stores for the | same cache driver to group types of items stored in your caches. | */ 'stores' => [ 'apc' => [ 'driver' => 'apc', ], 'array' => [ 'driver' => 'array', + 'serialize' => false, ], 'database' => [ 'driver' => 'database', 'table' => 'cache', 'connection' => null, + 'lock_connection' => null, ], 'file' => [ 'driver' => 'file', 'path' => storage_path('framework/cache/data'), ], 'memcached' => [ 'driver' => 'memcached', 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), 'sasl' => [ env('MEMCACHED_USERNAME'), env('MEMCACHED_PASSWORD'), ], 'options' => [ // Memcached::OPT_CONNECT_TIMEOUT => 2000, ], 'servers' => [ [ 'host' => env('MEMCACHED_HOST', '127.0.0.1'), 'port' => env('MEMCACHED_PORT', 11211), 'weight' => 100, ], ], ], 'redis' => [ 'driver' => 'redis', 'connection' => 'cache', + 'lock_connection' => 'default', ], 'dynamodb' => [ 'driver' => 'dynamodb', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), 'endpoint' => env('DYNAMODB_ENDPOINT'), ], + 'octane' => [ + 'driver' => 'octane', + ], + ], /* |-------------------------------------------------------------------------- | Cache Key Prefix |-------------------------------------------------------------------------- | | When utilizing a RAM based store such as APC or Memcached, there might | be other applications utilizing the same cache. So, we'll specify a | value to get prefixed to all our keys so we can avoid collisions. | */ - 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache'), + 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_cache_'), ]; diff --git a/src/config/database.php b/src/config/database.php index 57137ff4..e840262a 100644 --- a/src/config/database.php +++ b/src/config/database.php @@ -1,153 +1,153 @@ env('DB_CONNECTION', 'mysql'), /* |-------------------------------------------------------------------------- | Database Connections |-------------------------------------------------------------------------- | | Here are each of the database connections setup for your application. | Of course, examples of configuring each database platform that is | supported by Laravel is shown below to make development simple. | | | All database work in Laravel is done through the PHP PDO facilities | so make sure you have the driver for your particular database of | choice installed on your machine before you begin development. | */ 'connections' => [ 'sqlite' => [ 'driver' => 'sqlite', 'url' => env('DATABASE_URL'), 'database' => env('DB_DATABASE', database_path('database.sqlite')), 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), ], 'mysql' => [ 'driver' => 'mysql', 'url' => env('DATABASE_URL'), 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '3306'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'unix_socket' => env('DB_SOCKET', ''), 'charset' => 'utf8', 'collation' => 'utf8_unicode_ci', 'prefix' => '', 'prefix_indexes' => true, 'strict' => true, 'timezone' => '+00:00', 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], 'pgsql' => [ 'driver' => 'pgsql', 'url' => env('DATABASE_URL'), 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '5432'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'charset' => 'utf8', 'prefix' => '', 'prefix_indexes' => true, - 'schema' => 'public', + 'search_path' => 'public', 'sslmode' => 'prefer', ], 'sqlsrv' => [ 'driver' => 'sqlsrv', 'url' => env('DATABASE_URL'), 'host' => env('DB_HOST', 'localhost'), 'port' => env('DB_PORT', '1433'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'charset' => 'utf8', 'prefix' => '', 'prefix_indexes' => true, ], 'roundcube' => [ 'url' => env('DB_ROUNDCUBE_URL', env('MFA_DSN')), 'charset' => 'utf8', 'collation' => 'utf8_unicode_ci', ], ], /* |-------------------------------------------------------------------------- | Migration Repository Table |-------------------------------------------------------------------------- | | This table keeps track of all the migrations that have already run for | your application. Using this information, we can determine which of | the migrations on disk haven't actually been run in the database. | */ 'migrations' => 'migrations', /* |-------------------------------------------------------------------------- | Redis Databases |-------------------------------------------------------------------------- | | Redis is an open source, fast, and advanced key-value store that also | provides a richer body of commands than a typical key-value system | such as APC or Memcached. Laravel makes it easy to dig right in. | */ 'redis' => [ 'client' => env('REDIS_CLIENT', 'predis'), 'options' => [ 'cluster' => env('REDIS_CLUSTER', 'predis'), 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_database_'), ], 'default' => [ 'url' => env('REDIS_URL'), 'host' => env('REDIS_HOST', '127.0.0.1'), - 'password' => env('REDIS_PASSWORD', null), + 'password' => env('REDIS_PASSWORD'), 'port' => env('REDIS_PORT', 6379), 'database' => env('REDIS_DB', 0), ], 'cache' => [ 'url' => env('REDIS_URL'), 'host' => env('REDIS_HOST', '127.0.0.1'), - 'password' => env('REDIS_PASSWORD', null), + 'password' => env('REDIS_PASSWORD'), 'port' => env('REDIS_PORT', 6379), 'database' => env('REDIS_CACHE_DB', 1), ], ], ]; diff --git a/src/config/filesystems.php b/src/config/filesystems.php index cdab2570..9bf04e7e 100644 --- a/src/config/filesystems.php +++ b/src/config/filesystems.php @@ -1,74 +1,78 @@ env('FILESYSTEM_DRIVER', 'local'), - - /* - |-------------------------------------------------------------------------- - | Default Cloud Filesystem Disk - |-------------------------------------------------------------------------- - | - | Many applications store files both locally and in the cloud. For this - | reason, you may specify a default "cloud" driver here. This driver - | will be bound as the Cloud disk implementation in the container. - | - */ - - 'cloud' => env('FILESYSTEM_CLOUD', 's3'), + 'default' => env('FILESYSTEM_DISK', 'local'), /* |-------------------------------------------------------------------------- | Filesystem Disks |-------------------------------------------------------------------------- | | Here you may configure as many filesystem "disks" as you wish, and you | may even configure multiple disks of the same driver. Defaults have | been setup for each driver as an example of the required options. | - | Supported Drivers: "local", "ftp", "sftp", "s3", "rackspace" + | Supported Drivers: "local", "ftp", "sftp", "s3" | */ 'disks' => [ 'local' => [ 'driver' => 'local', 'root' => storage_path('app'), ], 'pgp' => [ 'driver' => 'local', 'root' => storage_path('app/keys'), ], 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), - 'url' => env('APP_URL').'/storage', + 'url' => env('APP_URL') . '/storage', 'visibility' => 'public', ], 's3' => [ 'driver' => 's3', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION'), 'bucket' => env('AWS_BUCKET'), 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), ], ], + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + ]; diff --git a/src/config/hashing.php b/src/config/hashing.php index 7fe643d5..ae44a3e8 100644 --- a/src/config/hashing.php +++ b/src/config/hashing.php @@ -1,52 +1,52 @@ 'bcrypt', /* |-------------------------------------------------------------------------- | Bcrypt Options |-------------------------------------------------------------------------- | | Here you may specify the configuration options that should be used when | passwords are hashed using the Bcrypt algorithm. This will allow you | to control the amount of time it takes to hash the given password. | */ 'bcrypt' => [ 'rounds' => env('BCRYPT_ROUNDS', 12), ], /* |-------------------------------------------------------------------------- | Argon Options |-------------------------------------------------------------------------- | | Here you may specify the configuration options that should be used when | passwords are hashed using the Argon algorithm. These will allow you | to control the amount of time it takes to hash the given password. | */ 'argon' => [ - 'memory' => 1024, - 'threads' => 2, - 'time' => 2, + 'memory' => 65536, + 'threads' => 1, + 'time' => 4, ], ]; diff --git a/src/config/horizon.php b/src/config/horizon.php index 54400c3e..98a1237c 100644 --- a/src/config/horizon.php +++ b/src/config/horizon.php @@ -1,166 +1,166 @@ 'admin.' . env('APP_DOMAIN'), /* |-------------------------------------------------------------------------- | Horizon Path |-------------------------------------------------------------------------- | | This is the URI path where Horizon will be accessible from. Feel free | to change this path to anything you like. Note that the URI will not | affect the paths of its internal API that aren't exposed to users. | */ 'path' => 'horizon', /* |-------------------------------------------------------------------------- | Horizon Redis Connection |-------------------------------------------------------------------------- | | This is the name of the Redis connection where Horizon will store the | meta information required for it to function. It includes the list | of supervisors, failed jobs, job metrics, and other information. | */ 'use' => 'default', /* |-------------------------------------------------------------------------- | Horizon Redis Prefix |-------------------------------------------------------------------------- | | This prefix will be used when storing all Horizon data in Redis. You | may modify the prefix when you are running multiple installations | of Horizon on the same server so that they don't have problems. | */ 'prefix' => env('HORIZON_PREFIX', 'horizon:'), /* |-------------------------------------------------------------------------- | Horizon Route Middleware |-------------------------------------------------------------------------- | | These middleware will get attached onto each Horizon route, giving you | the chance to add your own middleware to this list or change any of | the existing middleware. Or, you can simply stick with this list. | */ 'middleware' => ['web'], /* |-------------------------------------------------------------------------- | Queue Wait Time Thresholds |-------------------------------------------------------------------------- | | This option allows you to configure when the LongWaitDetected event | will be fired. Every connection / queue combination may have its | own, unique threshold (in seconds) before this event is fired. | */ 'waits' => [ 'redis:default' => 60, ], /* |-------------------------------------------------------------------------- | Job Trimming Times |-------------------------------------------------------------------------- | | Here you can configure for how long (in minutes) you desire Horizon to | persist the recent and failed jobs. Typically, recent jobs are kept | for one hour while all failed jobs are stored for an entire week. | */ 'trim' => [ 'recent' => 10080, 'completed' => 10080, 'recent_failed' => 10080, 'failed' => 10080, 'monitored' => 10080, ], /* |-------------------------------------------------------------------------- | Fast Termination |-------------------------------------------------------------------------- | | When this option is enabled, Horizon's "terminate" command will not | wait on all of the workers to terminate unless the --wait option | is provided. Fast termination can shorten deployment delay by | allowing a new instance of Horizon to start while the last | instance will continue to terminate each of its workers. | */ 'fast_termination' => false, /* |-------------------------------------------------------------------------- | Memory Limit (MB) |-------------------------------------------------------------------------- | | This value describes the maximum amount of memory the Horizon worker | may consume before it is terminated and restarted. You should set | this value according to the resources available to your server. | */ 'memory_limit' => 64, /* |-------------------------------------------------------------------------- | Queue Worker Configuration |-------------------------------------------------------------------------- | | Here you may define the queue worker settings used by your application | in all environments. These supervisors and settings handle all your | queued jobs and will be provisioned by Horizon during deployment. | */ 'environments' => [ 'production' => [ 'supervisor-1' => [ 'connection' => 'redis', 'queue' => ['default'], 'balance' => 'auto', 'maxProcesses' => 1, - 'minProcesses' => 0, + 'minProcesses' => 1, 'tries' => 1, ], ], 'local' => [ 'supervisor-1' => [ 'connection' => 'redis', 'queue' => ['default'], 'balance' => 'auto', 'maxProcesses' => 1, - 'minProcesses' => 0, + 'minProcesses' => 1, 'tries' => 1, ], ], ], ]; diff --git a/src/config/logging.php b/src/config/logging.php index 0dcdb218..23483a6e 100644 --- a/src/config/logging.php +++ b/src/config/logging.php @@ -1,96 +1,119 @@ env('LOG_CHANNEL', 'stack'), + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + /* |-------------------------------------------------------------------------- | Log Channels |-------------------------------------------------------------------------- | | Here you may configure the log channels for your application. Out of | the box, Laravel uses the Monolog PHP logging library. This gives | you a variety of powerful log handlers / formatters to utilize. | | Available Drivers: "single", "daily", "slack", "syslog", | "errorlog", "monolog", | "custom", "stack" | */ 'channels' => [ 'stack' => [ 'driver' => 'stack', 'channels' => ['daily'], 'ignore_exceptions' => false, ], 'single' => [ 'driver' => 'single', 'path' => storage_path('logs/laravel.log'), - 'level' => 'debug', + 'level' => env('LOG_LEVEL', 'debug'), ], 'daily' => [ 'driver' => 'daily', 'path' => storage_path('logs/laravel.log'), - 'level' => 'debug', + 'level' => env('LOG_LEVEL', 'debug'), 'days' => 14, ], 'slack' => [ 'driver' => 'slack', 'url' => env('LOG_SLACK_WEBHOOK_URL'), 'username' => 'Laravel Log', 'emoji' => ':boom:', - 'level' => 'critical', + 'level' => env('LOG_LEVEL', 'critical'), ], 'papertrail' => [ 'driver' => 'monolog', - 'level' => 'debug', - 'handler' => SyslogUdpHandler::class, + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), 'handler_with' => [ 'host' => env('PAPERTRAIL_URL'), 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://' . env('PAPERTRAIL_URL') . ':' . env('PAPERTRAIL_PORT'), ], ], 'stderr' => [ 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), 'handler' => StreamHandler::class, 'formatter' => env('LOG_STDERR_FORMATTER'), 'with' => [ 'stream' => 'php://stderr', ], ], 'syslog' => [ 'driver' => 'syslog', - 'level' => 'debug', + 'level' => env('LOG_LEVEL', 'debug'), ], 'errorlog' => [ 'driver' => 'errorlog', - 'level' => 'debug', + 'level' => env('LOG_LEVEL', 'debug'), + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, ], - ], - 'slow_log' => (float) env('LOG_SLOW_REQUESTS', 5), + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + ], ]; diff --git a/src/config/mail.php b/src/config/mail.php index c79c039c..cd0913a3 100644 --- a/src/config/mail.php +++ b/src/config/mail.php @@ -1,152 +1,133 @@ env('MAIL_DRIVER', 'smtp'), + 'default' => env('MAIL_MAILER', 'smtp'), /* |-------------------------------------------------------------------------- - | SMTP Host Address + | Mailer Configurations |-------------------------------------------------------------------------- | - | Here you may provide the host address of the SMTP server used by your - | applications. A default option is provided that is compatible with - | the Mailgun mail service which will provide reliable deliveries. + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. | - */ - - 'host' => env('MAIL_HOST', 'smtp.mailgun.org'), - - /* - |-------------------------------------------------------------------------- - | SMTP Host Port - |-------------------------------------------------------------------------- + | Laravel supports a variety of mail "transport" drivers to be used while + | sending an e-mail. You will specify which one you are using for your + | mailers below. You are free to add additional mailers as required. | - | This is the SMTP port used by your application to deliver e-mails to - | users of the application. Like the host we have set this value to - | stay compatible with the Mailgun e-mail application by default. + | Supported: "smtp", "sendmail", "mailgun", "ses", + | "postmark", "log", "array", "failover" | */ - 'port' => env('MAIL_PORT', 587), + 'mailers' => [ + 'smtp' => [ + 'transport' => 'smtp', + 'host' => env('MAIL_HOST', 'smtp.mailgun.org'), + 'port' => env('MAIL_PORT', 587), + 'encryption' => env('MAIL_ENCRYPTION', 'tls'), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + ], + + 'ses' => [ + 'transport' => 'ses', + ], + + 'mailgun' => [ + 'transport' => 'mailgun', + ], + + 'postmark' => [ + 'transport' => 'postmark', + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -t -i'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + ], + ], /* |-------------------------------------------------------------------------- | Global "From" Address |-------------------------------------------------------------------------- | | You may wish for all e-mails sent by your application to be sent from | the same address. Here, you may specify a name and address that is | used globally for all e-mails that are sent by your application. | */ 'from' => [ 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), 'name' => env('MAIL_FROM_NAME', 'Example'), ], /* |-------------------------------------------------------------------------- | Global "Reply-To" Address |-------------------------------------------------------------------------- | | You may wish for all e-mails sent by your application to be sent from | the same address. Here, you may specify a name and address that is | used globally for all e-mails that are sent by your application. | */ 'reply_to' => [ 'address' => env('MAIL_REPLYTO_ADDRESS', ''), 'name' => env('MAIL_REPLYTO_NAME', ''), ], - /* - |-------------------------------------------------------------------------- - | E-Mail Encryption Protocol - |-------------------------------------------------------------------------- - | - | Here you may specify the encryption protocol that should be used when - | the application send e-mail messages. A sensible default using the - | transport layer security protocol should provide great security. - | - */ - - 'encryption' => env('MAIL_ENCRYPTION', 'tls'), - - /* - |-------------------------------------------------------------------------- - | SMTP Server Username - |-------------------------------------------------------------------------- - | - | If your SMTP server requires a username for authentication, you should - | set it here. This will get used to authenticate with your server on - | connection. You may also set the "password" value below this one. - | - */ - - 'username' => env('MAIL_USERNAME'), - - 'password' => env('MAIL_PASSWORD'), - - /* - |-------------------------------------------------------------------------- - | Sendmail System Path - |-------------------------------------------------------------------------- - | - | When using the "sendmail" driver to send e-mails, we will need to know - | the path to where Sendmail lives on this server. A default path has - | been provided here, which will work well on most of your systems. - | - */ - - 'sendmail' => '/usr/sbin/sendmail -bs', - /* |-------------------------------------------------------------------------- | Markdown Mail Settings |-------------------------------------------------------------------------- | | If you are using Markdown based email rendering, you may configure your | theme and component paths here, allowing you to customize the design | of the emails. Or, you may simply stick with the Laravel defaults! | */ 'markdown' => [ 'theme' => 'default', 'paths' => [ - resource_path('views/emails'), + resource_path('views/vendor/mail'), ], ], - /* - |-------------------------------------------------------------------------- - | Log Channel - |-------------------------------------------------------------------------- - | - | If you are using the "log" driver, you may specify the logging channel - | if you prefer to keep mail messages separate from other log entries - | for simpler reading. Otherwise, the default channel will be used. - | - */ - - 'log_channel' => env('MAIL_LOG_CHANNEL'), - ]; diff --git a/src/config/octane.php b/src/config/octane.php new file mode 100644 index 00000000..b89f15b5 --- /dev/null +++ b/src/config/octane.php @@ -0,0 +1,246 @@ + env('OCTANE_SERVER', 'swoole'), + + /* + |-------------------------------------------------------------------------- + | Force HTTPS + |-------------------------------------------------------------------------- + | + | When this configuration value is set to "true", Octane will inform the + | framework that all absolute links must be generated using the HTTPS + | protocol. Otherwise your links may be generated using plain HTTP. + | + */ + + 'https' => env('OCTANE_HTTPS', true), + + /* + |-------------------------------------------------------------------------- + | Octane Listeners + |-------------------------------------------------------------------------- + | + | All of the event listeners for Octane's events are defined below. These + | listeners are responsible for resetting your application's state for + | the next request. You may even add your own listeners to the list. + | + */ + + 'listeners' => [ + WorkerStarting::class => [ + EnsureUploadedFilesAreValid::class, + EnsureUploadedFilesCanBeMoved::class, + ], + + RequestReceived::class => [ + ...Octane::prepareApplicationForNextOperation(), + ...Octane::prepareApplicationForNextRequest(), + // + ], + + RequestHandled::class => [ + // + ], + + RequestTerminated::class => [ + // FlushUploadedFiles::class, + ], + + TaskReceived::class => [ + ...Octane::prepareApplicationForNextOperation(), + // + ], + + TaskTerminated::class => [ + // + ], + + TickReceived::class => [ + ...Octane::prepareApplicationForNextOperation(), + // + ], + + TickTerminated::class => [ + // + ], + + OperationTerminated::class => [ + FlushTemporaryContainerInstances::class, + // DisconnectFromDatabases::class, + CollectGarbage::class, + ], + + WorkerErrorOccurred::class => [ + ReportException::class, + StopWorkerIfNecessary::class, + ], + + WorkerStopping::class => [ + // + ], + ], + + /* + |-------------------------------------------------------------------------- + | Warm / Flush Bindings + |-------------------------------------------------------------------------- + | + | The bindings listed below will either be pre-warmed when a worker boots + | or they will be flushed before every new request. Flushing a binding + | will force the container to resolve that binding again when asked. + | + */ + + 'warm' => [ + ...Octane::defaultServicesToWarm(), + ], + + 'flush' => [ + ], + + /* + |-------------------------------------------------------------------------- + | Octane Cache Table + |-------------------------------------------------------------------------- + | + | While using Swoole, you may leverage the Octane cache, which is powered + | by a Swoole table. You may set the maximum number of rows as well as + | the number of bytes per row using the configuration options below. + | + */ + + 'cache' => [ + 'rows' => 1000, + 'bytes' => 10000, + ], + + /* + |-------------------------------------------------------------------------- + | Octane Swoole Tables + |-------------------------------------------------------------------------- + | + | While using Swoole, you may define additional tables as required by the + | application. These tables can be used to store data that needs to be + | quickly accessed by other workers on the particular Swoole server. + | + */ + + 'tables' => [ +/* + 'example:1000' => [ + 'name' => 'string:1000', + 'votes' => 'int', + ], +*/ + ], + + /* + |-------------------------------------------------------------------------- + | File Watching + |-------------------------------------------------------------------------- + | + | The following list of files and directories will be watched when using + | the --watch option offered by Octane. If any of the directories and + | files are changed, Octane will automatically reload your workers. + | + */ + + 'watch' => [ + 'app', + 'bootstrap', + 'config', + 'database', + 'public/**/*.php', + 'resources/**/*.php', + 'routes', + 'composer.lock', + '.env', + ], + + /* + |-------------------------------------------------------------------------- + | Garbage Collection Threshold + |-------------------------------------------------------------------------- + | + | When executing long-lived PHP scripts such as Octane, memory can build + | up before being cleared by PHP. You can force Octane to run garbage + | collection if your application consumes this amount of megabytes. + | + */ + + 'garbage' => 64, + + /* + |-------------------------------------------------------------------------- + | Maximum Execution Time + |-------------------------------------------------------------------------- + | + | The following setting configures the maximum execution time for requests + | being handled by Octane. You may set this value to 0 to indicate that + | there isn't a specific time limit on Octane request execution time. + | + */ + + 'max_execution_time' => 30, + + /* + |-------------------------------------------------------------------------- + | Swoole configuration + |-------------------------------------------------------------------------- + | + | See Laravel\Octane\Command\StartSwooleCommand + */ + + 'swoole' => [ + 'options' => [ + 'log_file' => storage_path('logs/swoole_http.log'), + 'package_max_length' => 10 * 1024 * 1024, + 'enable_coroutine' => false, + //FIXME the daemonize option does not work + // 'daemonize' => env('OCTANE_DAEMONIZE', true), + //FIXME accessing app()->environment in here renders artisan disfunctional. I suppose it's too early. + //'log_level' => app()->environment('local') ? SWOOLE_LOG_INFO : SWOOLE_LOG_ERROR, + // 'reactor_num' => , // number of available cpus by default + 'send_yield' => true, + 'socket_buffer_size' => 10 * 1024 * 1024, + // 'task_worker_num' => // number of available cpus by default + // 'worker_num' => // number of available cpus by default + ], + ], +]; diff --git a/src/config/queue.php b/src/config/queue.php index 07c7d2a9..49784b99 100644 --- a/src/config/queue.php +++ b/src/config/queue.php @@ -1,87 +1,92 @@ env('QUEUE_CONNECTION', 'sync'), /* |-------------------------------------------------------------------------- | Queue Connections |-------------------------------------------------------------------------- | | Here you may configure the connection information for each server that | is used by your application. A default configuration has been added | for each back-end shipped with Laravel. You are free to add more. | | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null" | */ 'connections' => [ 'sync' => [ 'driver' => 'sync', ], 'database' => [ 'driver' => 'database', 'table' => 'jobs', 'queue' => 'default', 'retry_after' => 90, + 'after_commit' => false, ], 'beanstalkd' => [ 'driver' => 'beanstalkd', 'host' => 'localhost', 'queue' => 'default', 'retry_after' => 90, 'block_for' => 0, + 'after_commit' => false, ], 'sqs' => [ 'driver' => 'sqs', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), - 'queue' => env('SQS_QUEUE', 'your-queue-name'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => false, ], 'redis' => [ 'driver' => 'redis', 'connection' => 'default', 'queue' => env('REDIS_QUEUE', 'default'), 'retry_after' => 90, 'block_for' => null, + 'after_commit' => false, ], ], /* |-------------------------------------------------------------------------- | Failed Queue Jobs |-------------------------------------------------------------------------- | | These options configure the behavior of failed queue job logging so you | can control which database and table are used to store the jobs that | have failed. You may change them to any database / table you wish. | */ 'failed' => [ 'database' => env('DB_CONNECTION', 'mysql'), 'table' => 'failed_jobs', ], ]; diff --git a/src/config/session.php b/src/config/session.php index 406d50ec..d2b8b4a0 100644 --- a/src/config/session.php +++ b/src/config/session.php @@ -1,199 +1,198 @@ env('SESSION_DRIVER', 'file'), /* |-------------------------------------------------------------------------- | Session Lifetime |-------------------------------------------------------------------------- | | Here you may specify the number of minutes that you wish the session | to be allowed to remain idle before it expires. If you want them | to immediately expire on the browser closing, set that option. | */ 'lifetime' => env('SESSION_LIFETIME', 120), 'expire_on_close' => false, /* |-------------------------------------------------------------------------- | Session Encryption |-------------------------------------------------------------------------- | | This option allows you to easily specify that all of your session data | should be encrypted before it is stored. All encryption will be run | automatically by Laravel and you can use the Session like normal. | */ 'encrypt' => false, /* |-------------------------------------------------------------------------- | Session File Location |-------------------------------------------------------------------------- | | When using the native session driver, we need a location where session | files may be stored. A default has been set for you but a different | location may be specified. This is only needed for file sessions. | */ 'files' => storage_path('framework/sessions'), /* |-------------------------------------------------------------------------- | Session Database Connection |-------------------------------------------------------------------------- | | When using the "database" or "redis" session drivers, you may specify a | connection that should be used to manage these sessions. This should | correspond to a connection in your database configuration options. | */ - 'connection' => env('SESSION_CONNECTION', null), + 'connection' => env('SESSION_CONNECTION'), /* |-------------------------------------------------------------------------- | Session Database Table |-------------------------------------------------------------------------- | | When using the "database" session driver, you may specify the table we | should use to manage the sessions. Of course, a sensible default is | provided for you; however, you are free to change this as needed. | */ 'table' => 'sessions', /* |-------------------------------------------------------------------------- | Session Cache Store |-------------------------------------------------------------------------- | - | When using the "apc", "memcached", or "dynamodb" session drivers you may + | While using one of the framework's cache driven session backends you may | list a cache store that should be used for these sessions. This value | must match with one of the application's configured cache "stores". | + | Affects: "apc", "dynamodb", "memcached", "redis" + | */ - 'store' => env('SESSION_STORE', null), + 'store' => env('SESSION_STORE'), /* |-------------------------------------------------------------------------- | Session Sweeping Lottery |-------------------------------------------------------------------------- | | Some session drivers must manually sweep their storage location to get | rid of old sessions from storage. Here are the chances that it will | happen on a given request. By default, the odds are 2 out of 100. | */ 'lottery' => [2, 100], /* |-------------------------------------------------------------------------- | Session Cookie Name |-------------------------------------------------------------------------- | | Here you may change the name of the cookie used to identify a session | instance by ID. The name specified here will get used every time a | new session cookie is created by the framework for every driver. | */ - 'cookie' => env( - 'SESSION_COOKIE', - Str::slug(env('APP_NAME', 'laravel'), '_') . '_session' - ), + 'cookie' => env('SESSION_COOKIE', Str::slug(env('APP_NAME', 'laravel'), '_') . '_session'), /* |-------------------------------------------------------------------------- | Session Cookie Path |-------------------------------------------------------------------------- | | The session cookie path determines the path for which the cookie will | be regarded as available. Typically, this will be the root path of | your application but you are free to change this when necessary. | */ 'path' => '/', /* |-------------------------------------------------------------------------- | Session Cookie Domain |-------------------------------------------------------------------------- | | Here you may change the domain of the cookie used to identify a session | in your application. This will determine which domains the cookie is | available to in your application. A sensible default has been set. | */ - 'domain' => env('SESSION_DOMAIN', null), + 'domain' => env('SESSION_DOMAIN'), /* |-------------------------------------------------------------------------- | HTTPS Only Cookies |-------------------------------------------------------------------------- | | By setting this option to true, session cookies will only be sent back | to the server if the browser has a HTTPS connection. This will keep - | the cookie from being sent to you if it can not be done securely. + | the cookie from being sent to you when it can't be done securely. | */ - 'secure' => env('SESSION_SECURE_COOKIE', false), + 'secure' => env('SESSION_SECURE_COOKIE'), /* |-------------------------------------------------------------------------- | HTTP Access Only |-------------------------------------------------------------------------- | | Setting this value to true will prevent JavaScript from accessing the | value of the cookie and the cookie will only be accessible through | the HTTP protocol. You are free to modify this option if needed. | */ 'http_only' => true, /* |-------------------------------------------------------------------------- | Same-Site Cookies |-------------------------------------------------------------------------- | | This option determines how your cookies behave when cross-site requests | take place, and can be used to mitigate CSRF attacks. By default, we - | do not enable this as other CSRF protection services are in place. + | will set this value to "lax" since this is a secure default value. | - | Supported: "lax", "strict" + | Supported: "lax", "strict", "none", null | */ - 'same_site' => null, + 'same_site' => 'lax', ]; diff --git a/src/config/swoole_http.php b/src/config/swoole_http.php deleted file mode 100644 index 73befcf2..00000000 --- a/src/config/swoole_http.php +++ /dev/null @@ -1,141 +0,0 @@ - [ - 'host' => env('SWOOLE_HTTP_HOST', '127.0.0.1'), - 'port' => env('SWOOLE_HTTP_PORT', '1215'), - 'public_path' => base_path('public'), - // Determine if to use swoole to respond request for static files - 'handle_static_files' => env('SWOOLE_HANDLE_STATIC', true), - 'access_log' => env('SWOOLE_HTTP_ACCESS_LOG', false), - // You must add --enable-openssl while compiling Swoole - // Put `SWOOLE_SOCK_TCP | SWOOLE_SSL` if you want to enable SSL - 'socket_type' => SWOOLE_SOCK_TCP, - 'process_type' => SWOOLE_PROCESS, - 'options' => [ - 'pid_file' => env('SWOOLE_HTTP_PID_FILE', base_path('storage/logs/swoole_http.pid')), - 'log_file' => env('SWOOLE_HTTP_LOG_FILE', base_path('storage/logs/swoole_http.log')), - 'daemonize' => env('SWOOLE_HTTP_DAEMONIZE', false), - // Normally this value should be 1~4 times larger according to your cpu cores. - 'reactor_num' => env('SWOOLE_HTTP_REACTOR_NUM', swoole_cpu_num()), - 'worker_num' => env('SWOOLE_HTTP_WORKER_NUM', swoole_cpu_num()), - 'task_worker_num' => env('SWOOLE_HTTP_TASK_WORKER_NUM', swoole_cpu_num()), - // The data to receive can't be larger than buffer_output_size. - 'package_max_length' => 20 * 1024 * 1024, - // The data to send can't be larger than buffer_output_size. - 'buffer_output_size' => 10 * 1024 * 1024, - // Max buffer size for socket connections - 'socket_buffer_size' => 128 * 1024 * 1024, - // Worker will restart after processing this number of requests - 'max_request' => 3000, - // Enable coroutine send - 'send_yield' => true, - // You must add --enable-openssl while compiling Swoole - 'ssl_cert_file' => null, - 'ssl_key_file' => null, - ], - ], - - /* - |-------------------------------------------------------------------------- - | Enable to turn on websocket server. - |-------------------------------------------------------------------------- - */ - 'websocket' => [ - 'enabled' => env('SWOOLE_HTTP_WEBSOCKET', false), - ], - - /* - |-------------------------------------------------------------------------- - | Hot reload configuration - |-------------------------------------------------------------------------- - */ - 'hot_reload' => [ - 'enabled' => env('SWOOLE_HOT_RELOAD_ENABLE', false), - 'recursively' => env('SWOOLE_HOT_RELOAD_RECURSIVELY', true), - 'directory' => env('SWOOLE_HOT_RELOAD_DIRECTORY', base_path()), - 'log' => env('SWOOLE_HOT_RELOAD_LOG', true), - 'filter' => env('SWOOLE_HOT_RELOAD_FILTER', '.php'), - ], - - /* - |-------------------------------------------------------------------------- - | Console output will be transferred to response content if enabled. - |-------------------------------------------------------------------------- - */ - 'ob_output' => env('SWOOLE_OB_OUTPUT', true), - - /* - |-------------------------------------------------------------------------- - | Pre-resolved instances here will be resolved when sandbox created. - |-------------------------------------------------------------------------- - */ - 'pre_resolved' => [ - 'view', 'files', 'session', 'session.store', 'routes', - 'db', 'db.factory', 'cache', 'cache.store', 'config', 'cookie', - 'encrypter', 'hash', 'router', 'translator', 'url', 'log', - ], - - /* - |-------------------------------------------------------------------------- - | Instances here will be cleared on every request. - |-------------------------------------------------------------------------- - */ - 'instances' => [ - 'auth', 'translator' - ], - - /* - |-------------------------------------------------------------------------- - | Providers here will be registered on every request. - |-------------------------------------------------------------------------- - */ - 'providers' => [ - Illuminate\Pagination\PaginationServiceProvider::class, - App\Providers\AuthServiceProvider::class, - //Without this passport will sort of work, - //but PassportServiceProvider will not contain a valid app instance. - App\Providers\PassportServiceProvider::class, - ], - - /* - |-------------------------------------------------------------------------- - | Resetters for sandbox app. - |-------------------------------------------------------------------------- - */ - 'resetters' => [ - SwooleTW\Http\Server\Resetters\ResetConfig::class, - SwooleTW\Http\Server\Resetters\ResetSession::class, - SwooleTW\Http\Server\Resetters\ResetCookie::class, - SwooleTW\Http\Server\Resetters\ClearInstances::class, - SwooleTW\Http\Server\Resetters\BindRequest::class, - SwooleTW\Http\Server\Resetters\RebindKernelContainer::class, - SwooleTW\Http\Server\Resetters\RebindRouterContainer::class, - SwooleTW\Http\Server\Resetters\RebindViewContainer::class, - SwooleTW\Http\Server\Resetters\ResetProviders::class, - ], - - /* - |-------------------------------------------------------------------------- - | Define your swoole tables here. - | - | @see https://www.swoole.co.uk/docs/modules/swoole-table - |-------------------------------------------------------------------------- - */ - 'tables' => [ - // 'table_name' => [ - // 'size' => 1024, - // 'columns' => [ - // ['name' => 'column_name', 'type' => Table::TYPE_STRING, 'size' => 1024], - // ] - // ], - ], -]; diff --git a/src/config/swoole_websocket.php b/src/config/swoole_websocket.php deleted file mode 100644 index 4208912f..00000000 --- a/src/config/swoole_websocket.php +++ /dev/null @@ -1,107 +0,0 @@ - SwooleTW\Http\Websocket\SocketIO\WebsocketHandler::class, - - /* - |-------------------------------------------------------------------------- - | Default frame parser - | Replace it if you want to customize your websocket payload - |-------------------------------------------------------------------------- - */ - 'parser' => SwooleTW\Http\Websocket\SocketIO\SocketIOParser::class, - - /* - |-------------------------------------------------------------------------- - | Websocket route file path - |-------------------------------------------------------------------------- - */ - 'route_file' => base_path('routes/websocket.php'), - - /* - |-------------------------------------------------------------------------- - | Default middleware for on connect request - |-------------------------------------------------------------------------- - */ - 'middleware' => [ - SwooleTW\Http\Websocket\Middleware\DecryptCookies::class, - SwooleTW\Http\Websocket\Middleware\StartSession::class, - SwooleTW\Http\Websocket\Middleware\Authenticate::class, - ], - - /* - |-------------------------------------------------------------------------- - | Websocket handler for customized onHandShake callback - |-------------------------------------------------------------------------- - */ - 'handshake' => [ - 'enabled' => false, - 'handler' => SwooleTW\Http\Websocket\HandShakeHandler::class, - ], - - /* - |-------------------------------------------------------------------------- - | Default websocket driver - |-------------------------------------------------------------------------- - */ - 'default' => 'table', - - /* - |-------------------------------------------------------------------------- - | Websocket client's heartbeat interval (ms) - |-------------------------------------------------------------------------- - */ - 'ping_interval' => 25000, - - /* - |-------------------------------------------------------------------------- - | Websocket client's heartbeat interval timeout (ms) - |-------------------------------------------------------------------------- - */ - 'ping_timeout' => 60000, - - /* - |-------------------------------------------------------------------------- - | Room drivers mapping - |-------------------------------------------------------------------------- - */ - 'drivers' => [ - 'table' => SwooleTW\Http\Websocket\Rooms\TableRoom::class, - 'redis' => SwooleTW\Http\Websocket\Rooms\RedisRoom::class, - ], - - /* - |-------------------------------------------------------------------------- - | Room drivers settings - |-------------------------------------------------------------------------- - */ - 'settings' => [ - - 'table' => [ - 'room_rows' => 4096, - 'room_size' => 2048, - 'client_rows' => 8192, - 'client_size' => 2048, - ], - - 'redis' => [ - 'server' => [ - 'host' => env('REDIS_HOST', '127.0.0.1'), - 'password' => env('REDIS_PASSWORD', null), - 'port' => env('REDIS_PORT', 6379), - 'database' => 0, - 'persistent' => true, - ], - 'options' => [ - // - ], - 'prefix' => 'swoole:', - ], - ], -]; diff --git a/src/include/Kolab2FA/Driver/HOTP.php b/src/include/Kolab2FA/Driver/HOTP.php index 8093c11c..442fd66c 100644 --- a/src/include/Kolab2FA/Driver/HOTP.php +++ b/src/include/Kolab2FA/Driver/HOTP.php @@ -1,128 +1,137 @@ * * Copyright (C) 2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ namespace Kolab2FA\Driver; class HOTP extends Base { public $method = 'hotp'; protected $config = array( 'digits' => 6, 'window' => 4, 'digest' => 'sha1', ); protected $backend; /** * */ public function init($config) { parent::init($config); $this->user_settings += array( 'secret' => array( 'type' => 'text', 'private' => true, 'label' => 'secret', 'generator' => 'generate_secret', ), 'counter' => array( 'type' => 'integer', 'editable' => false, 'hidden' => true, 'generator' => 'random_counter', ), ); // copy config options - $this->backend = new \Kolab2FA\OTP\HOTP(); - $this->backend - ->setDigits($this->config['digits']) - ->setDigest($this->config['digest']) - ->setIssuer($this->config['issuer']) - ->setIssuerIncludedAsParameter(true); + $this->backend = \OTPHP\HOTP::create( + null, + 0, + $this->config['digest'], + $this->config['digits'] + ); + + $this->backend->setIssuer($this->config['issuer']); + $this->backend->setIssuerIncludedAsParameter(true); } /** * */ public function verify($code, $timestamp = null) { // get my secret from the user storage $secret = $this->get('secret'); - $counter = $this->get('counter'); + $counter = (int) $this->get('counter'); if (!strlen($secret)) { // LOG: "no secret set for user $this->username" // rcube::console("VERIFY HOTP: no secret set for user $this->username"); return false; } try { - $this->backend->setLabel($this->username)->setSecret($secret)->setCounter(intval($this->get('counter'))); + $this->backend->setLabel($this->username); + $this->backend->setSecret($secret); + $this->backend->setParameter('counter', $counter); + $pass = $this->backend->verify($code, $counter, $this->config['window']); // store incremented counter value $this->set('counter', $this->backend->getCounter()); $this->commit(); } catch (\Exception $e) { // LOG: exception // rcube::console("VERIFY HOTP: $this->id, " . strval($e)); $pass = false; } // rcube::console('VERIFY HOTP', $this->username, $secret, $counter, $code, $pass); return $pass; } /** * */ public function get_provisioning_uri() { if (!$this->secret) { // generate new secret and store it $this->set('secret', $this->get('secret', true)); $this->set('counter', $this->get('counter', true)); $this->set('created', $this->get('created', true)); $this->commit(); } // TODO: deny call if already active? - $this->backend->setLabel($this->username)->setSecret($this->secret)->setCounter(intval($this->get('counter'))); + $this->backend->setLabel($this->username); + $this->backend->setSecret($this->secret); + $this->backend->setParameter('counter', (int) $this->get('counter')); + return $this->backend->getProvisioningUri(); } /** * Generate a random counter value */ public function random_counter() { return mt_rand(1, 999); } } diff --git a/src/include/Kolab2FA/Driver/TOTP.php b/src/include/Kolab2FA/Driver/TOTP.php index dd4845b2..272d5553 100644 --- a/src/include/Kolab2FA/Driver/TOTP.php +++ b/src/include/Kolab2FA/Driver/TOTP.php @@ -1,137 +1,138 @@ * * Copyright (C) 2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ namespace Kolab2FA\Driver; class TOTP extends Base { public $method = 'totp'; protected $config = array( 'digits' => 6, 'interval' => 30, 'digest' => 'sha1', ); protected $backend; /** * */ public function init($config) { parent::init($config); $this->user_settings += array( 'secret' => array( 'type' => 'text', 'private' => true, 'label' => 'secret', 'generator' => 'generate_secret', ), ); // copy config options - $this->backend = new \Kolab2FA\OTP\TOTP(); - $this->backend - ->setDigits($this->config['digits']) - ->setInterval($this->config['interval']) - ->setDigest($this->config['digest']) - ->setIssuer($this->config['issuer']) - ->setIssuerIncludedAsParameter(true); + $this->backend = \OTPHP\TOTP::create( + null, + $this->config['interval'], + $this->config['digest'], + $this->config['digits'] + ); + + $this->backend->setIssuer($this->config['issuer']); + $this->backend->setIssuerIncludedAsParameter(true); } /** * */ public function verify($code, $timestamp = null) { // get my secret from the user storage $secret = $this->get('secret'); if (!strlen($secret)) { // LOG: "no secret set for user $this->username" // rcube::console("VERIFY TOTP: no secret set for user $this->username"); return false; } - $this->backend->setLabel($this->username)->setSecret($secret); + $this->backend->setLabel($this->username); + $this->backend->setParameter('secret', $secret); - // PHP gets a string, but we're comparing integers. - $code = (int)$code; -//$code = (string) $code; - // Pass a window to indicate the maximum timeslip between client (mobile - // device) and server. - $pass = $this->backend->verify($code, $timestamp, 150); + // Pass a window to indicate the maximum timeslip between client (device) and server. + $pass = $this->backend->verify((string) $code, $timestamp, 150); // try all codes from $timestamp till now if (!$pass && $timestamp) { $now = time(); while (!$pass && $timestamp < $now) { $pass = $code === $this->backend->at($timestamp); $timestamp += $this->config['interval']; } } // rcube::console('VERIFY TOTP', $this->username, $secret, $code, $timestamp, $pass); return $pass; } /** * Get current code (for testing) */ public function get_code() { // get my secret from the user storage $secret = $this->get('secret'); if (!strlen($secret)) { return; } - $this->backend->setLabel($this->username)->setSecret($secret); + $this->backend->setLabel($this->username); + $this->backend->setParameter('secret', $secret); return $this->backend->at(time()); } /** * */ public function get_provisioning_uri() { // rcube::console('PROV', $this->secret); if (!$this->secret) { // generate new secret and store it $this->set('secret', $this->get('secret', true)); $this->set('created', $this->get('created', true)); // rcube::console('PROV2', $this->secret); $this->commit(); } // TODO: deny call if already active? - $this->backend->setLabel($this->username)->setSecret($this->secret); + $this->backend->setLabel($this->username); + $this->backend->setParameter('secret', $secret); + return $this->backend->getProvisioningUri(); } - } diff --git a/src/include/Kolab2FA/OTP/HOTP.php b/src/include/Kolab2FA/OTP/HOTP.php deleted file mode 100644 index 530b0d37..00000000 --- a/src/include/Kolab2FA/OTP/HOTP.php +++ /dev/null @@ -1,58 +0,0 @@ - - * - * Copyright (C) 2015, Kolab Systems AG - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - - -namespace Kolab2FA\OTP; - -use OTPHP\HOTP as Base; - -class HOTP extends Base -{ - use OTP; - protected $counter = 0; - - public function setCounter($counter) - { - if (!is_integer($counter) || $counter < 0) { - throw new \Exception('Counter must be at least 0.'); - } - $this->counter = $counter; - - return $this; - } - - public function getCounter() - { - return $this->counter; - } - - public function updateCounter($counter) - { - $this->counter = $counter; - - return $this; - } -} diff --git a/src/include/Kolab2FA/OTP/OTP.php b/src/include/Kolab2FA/OTP/OTP.php deleted file mode 100644 index 87c27ecc..00000000 --- a/src/include/Kolab2FA/OTP/OTP.php +++ /dev/null @@ -1,133 +0,0 @@ - - * - * Copyright (C) 2015, Kolab Systems AG - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -namespace Kolab2FA\OTP; - -trait OTP -{ - protected $secret = null; - protected $issuer = null; - protected $issuer_included_as_parameter = false; - protected $label = null; - protected $digest = 'sha1'; - protected $digits = 6; - - public function setSecret($secret) - { - $this->secret = $secret; - - return $this; - } - - public function getSecret() - { - return $this->secret; - } - - public function setLabel($label) - { - if ($this->hasSemicolon($label)) { - throw new \Exception('Label must not contain a semi-colon.'); - } - $this->label = $label; - - return $this; - } - - public function getLabel() - { - return $this->label; - } - - public function setIssuer($issuer) - { - if ($this->hasSemicolon($issuer)) { - throw new \Exception('Issuer must not contain a semi-colon.'); - } - $this->issuer = $issuer; - - return $this; - } - - public function getIssuer() - { - return $this->issuer; - } - - public function isIssuerIncludedAsParameter() - { - return $this->issuer_included_as_parameter; - } - - public function setIssuerIncludedAsParameter($issuer_included_as_parameter) - { - $this->issuer_included_as_parameter = $issuer_included_as_parameter; - - return $this; - } - - public function setDigits($digits) - { - if (!is_numeric($digits) || $digits < 1) { - throw new \Exception('Digits must be at least 1.'); - } - $this->digits = $digits; - - return $this; - } - - public function getDigits() - { - return $this->digits; - } - - public function setDigest($digest) - { - if (!in_array($digest, array('md5', 'sha1', 'sha256', 'sha512'))) { - throw new \Exception("'$digest' digest is not supported."); - } - $this->digest = $digest; - - return $this; - } - - public function getDigest() - { - return $this->digest; - } - - private function hasSemicolon($value) - { - $semicolons = array(':', '%3A', '%3a'); - foreach ($semicolons as $semicolon) { - if (false !== strpos($value, $semicolon)) { - return true; - } - } - - return false; - } -} diff --git a/src/include/Kolab2FA/OTP/TOTP.php b/src/include/Kolab2FA/OTP/TOTP.php deleted file mode 100644 index d972897c..00000000 --- a/src/include/Kolab2FA/OTP/TOTP.php +++ /dev/null @@ -1,50 +0,0 @@ - - * - * Copyright (C) 2015, Kolab Systems AG - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -namespace Kolab2FA\OTP; - -use OTPHP\TOTP as Base; - -class TOTP extends Base -{ - use OTP; - protected $interval = 30; - - public function setInterval($interval) - { - if (!is_integer($interval) || $interval < 1) { - throw new \Exception('Interval must be at least 1.'); - } - $this->interval = $interval; - - return $this; - } - - public function getInterval() - { - return $this->interval; - } -} diff --git a/src/phpstan.neon b/src/phpstan.neon index e6045c2b..abfdebd7 100644 --- a/src/phpstan.neon +++ b/src/phpstan.neon @@ -1,20 +1,20 @@ includes: - ./vendor/nunomaduro/larastan/extension.neon parameters: ignoreErrors: - '#Access to an undefined property [a-zA-Z\\]+::\$pivot#' - '#Access to undefined constant static\(App\\[a-zA-Z]+\)::STATUS_[A-Z_]+#' - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withEnvTenantContext\(\)#' - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withObjectTenantContext\(\)#' - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withSubjectTenantContext\(\)#' - '#Call to an undefined method Tests\\Browser::#' - - '#Call to an undefined method Illuminate\\Support\\Fluent::references\(\)#' level: 4 parallel: processTimeout: 300.0 paths: - app/ - config/ - database/ - resources/ + - routes/ - tests/ diff --git a/src/phpunit.xml b/src/phpunit.xml index cbb78f98..71327da0 100644 --- a/src/phpunit.xml +++ b/src/phpunit.xml @@ -1,46 +1,46 @@ tests/Unit tests/Functional tests/Feature tests/Browser ./app - + diff --git a/src/public/index.php b/src/public/index.php index 4584cbcd..3b9880d9 100644 --- a/src/public/index.php +++ b/src/public/index.php @@ -1,60 +1,60 @@ - */ +use Illuminate\Contracts\Http\Kernel; +use Illuminate\Http\Request; define('LARAVEL_START', microtime(true)); +if (file_exists($maintenance = __DIR__ . '/../storage/framework/maintenance.php')) { + require $maintenance; +} + /* |-------------------------------------------------------------------------- | Register The Auto Loader |-------------------------------------------------------------------------- | | Composer provides a convenient, automatically generated class loader for | our application. We just need to utilize it! We'll simply require it | into the script here so that we don't have to worry about manual | loading any of our classes later on. It feels great to relax. | */ -require __DIR__.'/../vendor/autoload.php'; +require __DIR__ . '/../vendor/autoload.php'; /* |-------------------------------------------------------------------------- | Turn On The Lights |-------------------------------------------------------------------------- | | We need to illuminate PHP development, so let us turn on the lights. | This bootstraps the framework and gets it ready for use, then it | will load up this application so that we can run it and send | the responses back to the browser and delight our users. | */ -$app = require_once __DIR__.'/../bootstrap/app.php'; +$app = require_once __DIR__ . '/../bootstrap/app.php'; /* |-------------------------------------------------------------------------- | Run The Application |-------------------------------------------------------------------------- | | Once we have the application, we can handle the incoming request | through the kernel, and send the associated response back to | the client's browser allowing them to enjoy the creative | and wonderful application we have prepared for them. | */ -$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class); +$kernel = $app->make(Kernel::class); $response = $kernel->handle( - $request = Illuminate\Http\Request::capture() + $request = Request::capture() ); $response->send(); $kernel->terminate($request, $response); diff --git a/src/resources/lang/en/auth.php b/src/resources/lang/en/auth.php index 7aeaf78c..eb23c93f 100644 --- a/src/resources/lang/en/auth.php +++ b/src/resources/lang/en/auth.php @@ -1,20 +1,21 @@ 'Invalid username or password.', + 'password' => 'The provided password is incorrect.', 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 'logoutsuccess' => 'Successfully logged out.', ]; diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php index 5fe7ce2c..99d82857 100644 --- a/src/resources/lang/en/validation.php +++ b/src/resources/lang/en/validation.php @@ -1,183 +1,195 @@ 'The :attribute must be accepted.', + 'accepted_if' => 'The :attribute must be accepted when :other is :value.', 'active_url' => 'The :attribute is not a valid URL.', 'after' => 'The :attribute must be a date after :date.', 'after_or_equal' => 'The :attribute must be a date after or equal to :date.', - 'alpha' => 'The :attribute may only contain letters.', - 'alpha_dash' => 'The :attribute may only contain letters, numbers, dashes and underscores.', - 'alpha_num' => 'The :attribute may only contain letters and numbers.', + 'alpha' => 'The :attribute must only contain letters.', + 'alpha_dash' => 'The :attribute must only contain letters, numbers, dashes and underscores.', + 'alpha_num' => 'The :attribute must only contain letters and numbers.', 'array' => 'The :attribute must be an array.', 'before' => 'The :attribute must be a date before :date.', 'before_or_equal' => 'The :attribute must be a date before or equal to :date.', 'between' => [ 'numeric' => 'The :attribute must be between :min and :max.', 'file' => 'The :attribute must be between :min and :max kilobytes.', 'string' => 'The :attribute must be between :min and :max characters.', 'array' => 'The :attribute must have between :min and :max items.', ], 'boolean' => 'The :attribute field must be true or false.', 'confirmed' => 'The :attribute confirmation does not match.', + 'current_password' => 'The password is incorrect.', 'date' => 'The :attribute is not a valid date.', 'date_equals' => 'The :attribute must be a date equal to :date.', 'date_format' => 'The :attribute does not match the format :format.', + 'declined' => 'The :attribute must be declined.', + 'declined_if' => 'The :attribute must be declined when :other is :value.', 'different' => 'The :attribute and :other must be different.', 'digits' => 'The :attribute must be :digits digits.', 'digits_between' => 'The :attribute must be between :min and :max digits.', 'dimensions' => 'The :attribute has invalid image dimensions.', 'distinct' => 'The :attribute field has a duplicate value.', 'email' => 'The :attribute must be a valid email address.', 'ends_with' => 'The :attribute must end with one of the following: :values', + 'enum' => 'The selected :attribute is invalid.', 'exists' => 'The selected :attribute is invalid.', 'file' => 'The :attribute must be a file.', 'filled' => 'The :attribute field must have a value.', 'gt' => [ 'numeric' => 'The :attribute must be greater than :value.', 'file' => 'The :attribute must be greater than :value kilobytes.', 'string' => 'The :attribute must be greater than :value characters.', 'array' => 'The :attribute must have more than :value items.', ], 'gte' => [ 'numeric' => 'The :attribute must be greater than or equal :value.', 'file' => 'The :attribute must be greater than or equal :value kilobytes.', 'string' => 'The :attribute must be greater than or equal :value characters.', 'array' => 'The :attribute must have :value items or more.', ], 'image' => 'The :attribute must be an image.', 'in' => 'The selected :attribute is invalid.', 'in_array' => 'The :attribute field does not exist in :other.', 'integer' => 'The :attribute must be an integer.', 'ip' => 'The :attribute must be a valid IP address.', 'ipv4' => 'The :attribute must be a valid IPv4 address.', 'ipv6' => 'The :attribute must be a valid IPv6 address.', 'json' => 'The :attribute must be a valid JSON string.', 'lt' => [ 'numeric' => 'The :attribute must be less than :value.', 'file' => 'The :attribute must be less than :value kilobytes.', 'string' => 'The :attribute must be less than :value characters.', 'array' => 'The :attribute must have less than :value items.', ], 'lte' => [ 'numeric' => 'The :attribute must be less than or equal :value.', 'file' => 'The :attribute must be less than or equal :value kilobytes.', 'string' => 'The :attribute must be less than or equal :value characters.', 'array' => 'The :attribute must not have more than :value items.', ], 'max' => [ 'numeric' => 'The :attribute may not be greater than :max.', 'file' => 'The :attribute may not be greater than :max kilobytes.', 'string' => 'The :attribute may not be greater than :max characters.', 'array' => 'The :attribute may not have more than :max items.', ], + 'mac_address' => 'The :attribute must be a valid MAC address.', 'mimes' => 'The :attribute must be a file of type: :values.', 'mimetypes' => 'The :attribute must be a file of type: :values.', 'min' => [ 'numeric' => 'The :attribute must be at least :min.', 'file' => 'The :attribute must be at least :min kilobytes.', 'string' => 'The :attribute must be at least :min characters.', 'array' => 'The :attribute must have at least :min items.', ], + 'multiple_of' => 'The :attribute must be a multiple of :value.', 'not_in' => 'The selected :attribute is invalid.', 'not_regex' => 'The :attribute format is invalid.', 'numeric' => 'The :attribute must be a number.', 'present' => 'The :attribute field must be present.', + 'prohibited' => 'The :attribute field is prohibited.', + 'prohibited_if' => 'The :attribute field is prohibited when :other is :value.', + 'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.', + 'prohibits' => 'The :attribute field prohibits :other from being present.', 'regex' => 'The :attribute format is invalid.', 'required' => 'The :attribute field is required.', + 'required_array_keys' => 'The :attribute field must contain entries for: :values.', 'required_if' => 'The :attribute field is required when :other is :value.', 'required_unless' => 'The :attribute field is required unless :other is in :values.', 'required_with' => 'The :attribute field is required when :values is present.', 'required_with_all' => 'The :attribute field is required when :values are present.', 'required_without' => 'The :attribute field is required when :values is not present.', 'required_without_all' => 'The :attribute field is required when none of :values are present.', 'same' => 'The :attribute and :other must match.', 'size' => [ 'numeric' => 'The :attribute must be :size.', 'file' => 'The :attribute must be :size kilobytes.', 'string' => 'The :attribute must be :size characters.', 'array' => 'The :attribute must contain :size items.', ], 'starts_with' => 'The :attribute must start with one of the following: :values', 'string' => 'The :attribute must be a string.', - 'timezone' => 'The :attribute must be a valid zone.', + 'timezone' => 'The :attribute must be a valid timezone.', 'unique' => 'The :attribute has already been taken.', 'uploaded' => 'The :attribute failed to upload.', - 'url' => 'The :attribute format is invalid.', + 'url' => 'The :attribute must be a valid URL.', 'uuid' => 'The :attribute must be a valid UUID.', '2fareq' => 'Second factor code is required.', '2fainvalid' => 'Second factor code is invalid.', 'emailinvalid' => 'The specified email address is invalid.', 'domaininvalid' => 'The specified domain is invalid.', 'domainnotavailable' => 'The specified domain is not available.', 'logininvalid' => 'The specified login is invalid.', 'loginexists' => 'The specified login is not available.', 'domainexists' => 'The specified domain is not available.', 'noemailorphone' => 'The specified text is neither a valid email address nor a phone number.', 'packageinvalid' => 'Invalid package selected.', 'packagerequired' => 'Package is required.', 'usernotexists' => 'Unable to find user.', 'voucherinvalid' => 'The voucher code is invalid or expired.', 'noextemail' => 'This user has no external email address.', 'entryinvalid' => 'The specified :attribute is invalid.', 'entryexists' => 'The specified :attribute is not available.', 'minamount' => 'Minimum amount for a single payment is :amount.', 'minamountdebt' => 'The specified amount does not cover the balance on the account.', 'notalocaluser' => 'The specified email address does not exist.', 'memberislist' => 'A recipient cannot be the same as the list address.', 'listmembersrequired' => 'At least one recipient is required.', 'spf-entry-invalid' => 'The entry format is invalid. Expected a domain name starting with a dot.', 'sp-entry-invalid' => 'The entry format is invalid. Expected an email, domain, or part of it.', 'acl-entry-invalid' => 'The entry format is invalid. Expected an email address.', 'ipolicy-invalid' => 'The specified invitation policy is invalid.', 'invalid-config-parameter' => 'The requested configuration parameter is not supported.', 'nameexists' => 'The specified name is not available.', 'nameinvalid' => 'The specified name is invalid.', 'password-policy-error' => 'Specified password does not comply with the policy.', 'invalid-password-policy' => 'Specified password policy is invalid.', 'password-policy-min-len-error' => 'Minimum password length cannot be less than :min.', 'password-policy-max-len-error' => 'Maximum password length cannot be more than :max.', 'password-policy-last-error' => 'The minimum value for last N passwords is :last.', /* |-------------------------------------------------------------------------- | Custom Validation Language Lines |-------------------------------------------------------------------------- | | Here you may specify custom validation messages for attributes using the | convention "attribute.rule" to name the lines. This makes it quick to | specify a specific custom language line for a given attribute rule. | */ 'custom' => [ 'attribute-name' => [ 'rule-name' => 'custom-message', ], ], /* |-------------------------------------------------------------------------- | Custom Validation Attributes |-------------------------------------------------------------------------- | | The following language lines are used to swap our attribute placeholder | with something more reader friendly such as "E-Mail Address" instead | of "email". This simply helps us make our message more expressive. | */ 'attributes' => [], ]; diff --git a/src/routes/api.php b/src/routes/api.php index f7ae9956..c5c59b80 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,270 +1,258 @@ 'api', - 'prefix' => $prefix . 'api/auth' + 'prefix' => 'auth' ], - function ($router) { - Route::post('login', 'API\AuthController@login'); + function () { + Route::post('login', [API\AuthController::class, 'login']); Route::group( ['middleware' => 'auth:api'], - function ($router) { - Route::get('info', 'API\AuthController@info'); - Route::post('info', 'API\AuthController@info'); - Route::post('logout', 'API\AuthController@logout'); - Route::post('refresh', 'API\AuthController@refresh'); + function () { + Route::get('info', [API\AuthController::class, 'info']); + Route::post('info', [API\AuthController::class, 'info']); + Route::post('logout', [API\AuthController::class, 'logout']); + Route::post('refresh', [API\AuthController::class, 'refresh']); } ); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'api', - 'prefix' => $prefix . 'api/auth' - ], - function ($router) { - Route::post('password-policy/check', 'API\PasswordPolicyController@check'); - - Route::post('password-reset/init', 'API\PasswordResetController@init'); - Route::post('password-reset/verify', 'API\PasswordResetController@verify'); - Route::post('password-reset', 'API\PasswordResetController@reset'); - - Route::post('signup/init', 'API\SignupController@init'); - Route::get('signup/invitations/{id}', 'API\SignupController@invitation'); - Route::get('signup/plans', 'API\SignupController@plans'); - Route::post('signup/verify', 'API\SignupController@verify'); - Route::post('signup', 'API\SignupController@signup'); - } -); - -Route::group( - [ - 'domain' => \config('app.website_domain'), - 'middleware' => 'auth:api', - 'prefix' => $prefix . 'api/v4' + 'prefix' => 'auth' ], function () { - Route::post('companion/register', 'API\V4\CompanionAppsController@register'); - - Route::post('auth-attempts/{id}/confirm', 'API\V4\AuthAttemptsController@confirm'); - Route::post('auth-attempts/{id}/deny', 'API\V4\AuthAttemptsController@deny'); - Route::get('auth-attempts/{id}/details', 'API\V4\AuthAttemptsController@details'); - Route::get('auth-attempts', 'API\V4\AuthAttemptsController@index'); - - Route::apiResource('domains', 'API\V4\DomainsController'); - Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); - Route::get('domains/{id}/skus', 'API\V4\SkusController@domainSkus'); - Route::get('domains/{id}/status', 'API\V4\DomainsController@status'); - Route::post('domains/{id}/config', 'API\V4\DomainsController@setConfig'); - - Route::apiResource('groups', 'API\V4\GroupsController'); - Route::get('groups/{id}/status', 'API\V4\GroupsController@status'); - Route::post('groups/{id}/config', 'API\V4\GroupsController@setConfig'); - - Route::apiResource('packages', 'API\V4\PackagesController'); - - Route::apiResource('resources', 'API\V4\ResourcesController'); - Route::get('resources/{id}/status', 'API\V4\ResourcesController@status'); - Route::post('resources/{id}/config', 'API\V4\ResourcesController@setConfig'); - - Route::apiResource('shared-folders', 'API\V4\SharedFoldersController'); - Route::get('shared-folders/{id}/status', 'API\V4\SharedFoldersController@status'); - Route::post('shared-folders/{id}/config', 'API\V4\SharedFoldersController@setConfig'); - - Route::apiResource('skus', 'API\V4\SkusController'); - - Route::apiResource('users', 'API\V4\UsersController'); - Route::post('users/{id}/config', 'API\V4\UsersController@setConfig'); - Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus'); - Route::get('users/{id}/status', 'API\V4\UsersController@status'); - - Route::apiResource('wallets', 'API\V4\WalletsController'); - Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions'); - Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts'); - Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload'); - - Route::get('password-policy', 'API\PasswordPolicyController@index'); - Route::post('password-reset/code', 'API\PasswordResetController@codeCreate'); - Route::delete('password-reset/code/{id}', 'API\PasswordResetController@codeDelete'); - - Route::post('payments', 'API\V4\PaymentsController@store'); - //Route::delete('payments', 'API\V4\PaymentsController@cancel'); - Route::get('payments/mandate', 'API\V4\PaymentsController@mandate'); - Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate'); - Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate'); - Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete'); - Route::get('payments/methods', 'API\V4\PaymentsController@paymentMethods'); - Route::get('payments/pending', 'API\V4\PaymentsController@payments'); - Route::get('payments/has-pending', 'API\V4\PaymentsController@hasPayments'); - - Route::get('openvidu/rooms', 'API\V4\OpenViduController@index'); - Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom'); - Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig'); + Route::post('password-policy/check', [API\PasswordPolicyController::class, 'check']); - // FIXME: I'm not sure about this one, should we use DELETE request maybe? - Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); - Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); - Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); - Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); + Route::post('password-reset/init', [API\PasswordResetController::class, 'init']); + Route::post('password-reset/verify', [API\PasswordResetController::class, 'verify']); + Route::post('password-reset', [API\PasswordResetController::class, 'reset']); + + Route::post('signup/init', [API\SignupController::class, 'init']); + Route::get('signup/invitations/{id}', [API\SignupController::class, 'invitation']); + Route::get('signup/plans', [API\SignupController::class, 'plans']); + Route::post('signup/verify', [API\SignupController::class, 'verify']); + Route::post('signup', [API\SignupController::class, 'signup']); } ); -// Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group Route::group( [ 'domain' => \config('app.website_domain'), - 'prefix' => $prefix . 'api/v4' + 'middleware' => 'auth:api', + 'prefix' => 'v4' ], function () { - Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom'); - Route::post('openvidu/rooms/{id}/connections', 'API\V4\OpenViduController@createConnection'); + Route::post('companion/register', [API\V4\CompanionAppsController::class, 'register']); + + Route::post('auth-attempts/{id}/confirm', [API\V4\AuthAttemptsController::class, 'confirm']); + Route::post('auth-attempts/{id}/deny', [API\V4\AuthAttemptsController::class, 'deny']); + Route::get('auth-attempts/{id}/details', [API\V4\AuthAttemptsController::class, 'details']); + Route::get('auth-attempts', [API\V4\AuthAttemptsController::class, 'index']); + + Route::apiResource('domains', API\V4\DomainsController::class); + Route::get('domains/{id}/confirm', [API\V4\DomainsController::class, 'confirm']); + Route::get('domains/{id}/skus', [API\V4\SkusController::class, 'domainSkus']); + Route::get('domains/{id}/status', [API\V4\DomainsController::class, 'status']); + Route::post('domains/{id}/config', [API\V4\DomainsController::class, 'setConfig']); + + Route::apiResource('groups', API\V4\GroupsController::class); + Route::get('groups/{id}/status', [API\V4\GroupsController::class, 'status']); + Route::post('groups/{id}/config', [API\V4\GroupsController::class, 'setConfig']); + + Route::apiResource('packages', API\V4\PackagesController::class); + + Route::apiResource('resources', API\V4\ResourcesController::class); + Route::get('resources/{id}/status', [API\V4\ResourcesController::class, 'status']); + Route::post('resources/{id}/config', [API\V4\ResourcesController::class, 'setConfig']); + + Route::apiResource('shared-folders', API\V4\SharedFoldersController::class); + Route::get('shared-folders/{id}/status', [API\V4\SharedFoldersController::class, 'status']); + Route::post('shared-folders/{id}/config', [API\V4\SharedFoldersController::class, 'setConfig']); + + Route::apiResource('skus', API\V4\SkusController::class); + + Route::apiResource('users', API\V4\UsersController::class); + Route::post('users/{id}/config', [API\V4\UsersController::class, 'setConfig']); + Route::get('users/{id}/skus', [API\V4\SkusController::class, 'userSkus']); + Route::get('users/{id}/status', [API\V4\UsersController::class, 'status']); + + Route::apiResource('wallets', API\V4\WalletsController::class); + Route::get('wallets/{id}/transactions', [API\V4\WalletsController::class, 'transactions']); + Route::get('wallets/{id}/receipts', [API\V4\WalletsController::class, 'receipts']); + Route::get('wallets/{id}/receipts/{receipt}', [API\V4\WalletsController::class, 'receiptDownload']); + + Route::get('password-policy', [API\PasswordPolicyController::class, 'index']); + Route::post('password-reset/code', [API\PasswordResetController::class, 'codeCreate']); + Route::delete('password-reset/code/{id}', [API\PasswordResetController::class, 'codeDelete']); + + Route::post('payments', [API\V4\PaymentsController::class, 'store']); + //Route::delete('payments', [API\V4\PaymentsController::class, 'cancel']); + Route::get('payments/mandate', [API\V4\PaymentsController::class, 'mandate']); + Route::post('payments/mandate', [API\V4\PaymentsController::class, 'mandateCreate']); + Route::put('payments/mandate', [API\V4\PaymentsController::class, 'mandateUpdate']); + Route::delete('payments/mandate', [API\V4\PaymentsController::class, 'mandateDelete']); + Route::get('payments/methods', [API\V4\PaymentsController::class, 'paymentMethods']); + Route::get('payments/pending', [API\V4\PaymentsController::class, 'payments']); + Route::get('payments/has-pending', [API\V4\PaymentsController::class, 'hasPayments']); + + Route::get('openvidu/rooms', [API\V4\OpenViduController::class, 'index']); + Route::post('openvidu/rooms/{id}/close', [API\V4\OpenViduController::class, 'closeRoom']); + Route::post('openvidu/rooms/{id}/config', [API\V4\OpenViduController::class, 'setRoomConfig']); + + Route::post('openvidu/rooms/{id}', [API\V4\OpenViduController::class, 'joinRoom']) + ->withoutMiddleware(['auth:api']); + Route::post('openvidu/rooms/{id}/connections', [API\V4\OpenViduController::class, 'createConnection']) + ->withoutMiddleware(['auth:api']); // FIXME: I'm not sure about this one, should we use DELETE request maybe? - Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); - Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); - Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); - Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); - } -); - -Route::group( - [ - 'domain' => \config('app.website_domain'), - 'middleware' => 'api', - 'prefix' => $prefix . 'api/v4' - ], - function ($router) { - Route::post('support/request', 'API\V4\SupportController@request'); + Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', [API\V4\OpenViduController::class, 'dismissConnection']) + ->withoutMiddleware(['auth:api']); + Route::put('openvidu/rooms/{id}/connections/{conn}', [API\V4\OpenViduController::class, 'updateConnection']) + ->withoutMiddleware(['auth:api']); + Route::post('openvidu/rooms/{id}/request/{reqid}/accept', [API\V4\OpenViduController::class, 'acceptJoinRequest']) + ->withoutMiddleware(['auth:api']); + Route::post('openvidu/rooms/{id}/request/{reqid}/deny', [API\V4\OpenViduController::class, 'denyJoinRequest']) + ->withoutMiddleware(['auth:api']); + + Route::post('support/request', [API\V4\SupportController::class, 'request']) + ->withoutMiddleware(['auth:api']) + ->middleware(['api']); } ); Route::group( [ 'domain' => \config('app.website_domain'), - 'prefix' => $prefix . 'api/webhooks' + 'prefix' => 'webhooks' ], function () { - Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook'); - Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook'); + Route::post('payment/{provider}', [API\V4\PaymentsController::class, 'webhook']); + Route::post('meet/openvidu', [API\V4\OpenViduController::class, 'webhook']); } ); if (\config('app.with_services')) { Route::group( [ 'domain' => 'services.' . \config('app.website_domain'), - 'prefix' => $prefix . 'api/webhooks' + 'prefix' => 'webhooks' ], function () { - Route::get('nginx', 'API\V4\NGINXController@authenticate'); - Route::get('nginx-httpauth', 'API\V4\NGINXController@httpauth'); - Route::post('policy/greylist', 'API\V4\PolicyController@greylist'); - Route::post('policy/ratelimit', 'API\V4\PolicyController@ratelimit'); - Route::post('policy/spf', 'API\V4\PolicyController@senderPolicyFramework'); + Route::get('nginx', [API\V4\NGINXController::class, 'authenticate']); + Route::get('nginx-httpauth', [API\V4\NGINXController::class, 'httpauth']); + Route::post('policy/greylist', [API\V4\PolicyController::class, 'greylist']); + Route::post('policy/ratelimit', [API\V4\PolicyController::class, 'ratelimit']); + Route::post('policy/spf', [API\V4\PolicyController::class, 'senderPolicyFramework']); } ); } if (\config('app.with_admin')) { Route::group( [ 'domain' => 'admin.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'admin'], - 'prefix' => $prefix . 'api/v4', + 'prefix' => 'v4', ], function () { - Route::apiResource('domains', 'API\V4\Admin\DomainsController'); - Route::get('domains/{id}/skus', 'API\V4\Admin\SkusController@domainSkus'); - Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend'); - Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend'); - - Route::apiResource('groups', 'API\V4\Admin\GroupsController'); - Route::post('groups/{id}/suspend', 'API\V4\Admin\GroupsController@suspend'); - Route::post('groups/{id}/unsuspend', 'API\V4\Admin\GroupsController@unsuspend'); - - Route::apiResource('resources', 'API\V4\Admin\ResourcesController'); - Route::apiResource('shared-folders', 'API\V4\Admin\SharedFoldersController'); - Route::apiResource('skus', 'API\V4\Admin\SkusController'); - Route::apiResource('users', 'API\V4\Admin\UsersController'); - Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts'); - Route::post('users/{id}/reset2FA', 'API\V4\Admin\UsersController@reset2FA'); - Route::get('users/{id}/skus', 'API\V4\Admin\SkusController@userSkus'); - Route::post('users/{id}/skus/{sku}', 'API\V4\Admin\UsersController@setSku'); - Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend'); - Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend'); - Route::apiResource('wallets', 'API\V4\Admin\WalletsController'); - Route::post('wallets/{id}/one-off', 'API\V4\Admin\WalletsController@oneOff'); - Route::get('wallets/{id}/transactions', 'API\V4\Admin\WalletsController@transactions'); - - Route::get('stats/chart/{chart}', 'API\V4\Admin\StatsController@chart'); + Route::apiResource('domains', API\V4\Admin\DomainsController::class); + Route::get('domains/{id}/skus', [API\V4\Admin\SkusController::class, 'domainSkus']); + Route::post('domains/{id}/suspend', [API\V4\Admin\DomainsController::class, 'suspend']); + Route::post('domains/{id}/unsuspend', [API\V4\Admin\DomainsController::class, 'unsuspend']); + + Route::apiResource('groups', API\V4\Admin\GroupsController::class); + Route::post('groups/{id}/suspend', [API\V4\Admin\GroupsController::class, 'suspend']); + Route::post('groups/{id}/unsuspend', [API\V4\Admin\GroupsController::class, 'unsuspend']); + + Route::apiResource('resources', API\V4\Admin\ResourcesController::class); + Route::apiResource('shared-folders', API\V4\Admin\SharedFoldersController::class); + Route::apiResource('skus', API\V4\Admin\SkusController::class); + + Route::apiResource('users', API\V4\Admin\UsersController::class); + Route::get('users/{id}/discounts', [API\V4\Reseller\DiscountsController::class, 'userDiscounts']); + Route::post('users/{id}/reset2FA', [API\V4\Admin\UsersController::class, 'reset2FA']); + Route::get('users/{id}/skus', [API\V4\Admin\SkusController::class, 'userSkus']); + Route::post('users/{id}/skus/{sku}', [API\V4\Admin\UsersController::class, 'setSku']); + Route::post('users/{id}/suspend', [API\V4\Admin\UsersController::class, 'suspend']); + Route::post('users/{id}/unsuspend', [API\V4\Admin\UsersController::class, 'unsuspend']); + + Route::apiResource('wallets', API\V4\Admin\WalletsController::class); + Route::post('wallets/{id}/one-off', [API\V4\Admin\WalletsController::class, 'oneOff']); + Route::get('wallets/{id}/transactions', [API\V4\Admin\WalletsController::class, 'transactions']); + + Route::get('stats/chart/{chart}', [API\V4\Admin\StatsController::class, 'chart']); } ); } if (\config('app.with_reseller')) { Route::group( [ 'domain' => 'reseller.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'reseller'], - 'prefix' => $prefix . 'api/v4', + 'prefix' => 'v4', ], function () { - Route::apiResource('domains', 'API\V4\Reseller\DomainsController'); - Route::get('domains/{id}/skus', 'API\V4\Reseller\SkusController@domainSkus'); - Route::post('domains/{id}/suspend', 'API\V4\Reseller\DomainsController@suspend'); - Route::post('domains/{id}/unsuspend', 'API\V4\Reseller\DomainsController@unsuspend'); - - Route::apiResource('groups', 'API\V4\Reseller\GroupsController'); - Route::post('groups/{id}/suspend', 'API\V4\Reseller\GroupsController@suspend'); - Route::post('groups/{id}/unsuspend', 'API\V4\Reseller\GroupsController@unsuspend'); - - Route::apiResource('invitations', 'API\V4\Reseller\InvitationsController'); - Route::post('invitations/{id}/resend', 'API\V4\Reseller\InvitationsController@resend'); - - Route::post('payments', 'API\V4\Reseller\PaymentsController@store'); - Route::get('payments/mandate', 'API\V4\Reseller\PaymentsController@mandate'); - Route::post('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateCreate'); - Route::put('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateUpdate'); - Route::delete('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateDelete'); - Route::get('payments/methods', 'API\V4\Reseller\PaymentsController@paymentMethods'); - Route::get('payments/pending', 'API\V4\Reseller\PaymentsController@payments'); - Route::get('payments/has-pending', 'API\V4\Reseller\PaymentsController@hasPayments'); - - Route::apiResource('resources', 'API\V4\Reseller\ResourcesController'); - Route::apiResource('shared-folders', 'API\V4\Reseller\SharedFoldersController'); - Route::apiResource('skus', 'API\V4\Reseller\SkusController'); - Route::apiResource('users', 'API\V4\Reseller\UsersController'); - Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts'); - Route::post('users/{id}/reset2FA', 'API\V4\Reseller\UsersController@reset2FA'); - Route::get('users/{id}/skus', 'API\V4\Reseller\SkusController@userSkus'); - Route::post('users/{id}/skus/{sku}', 'API\V4\Admin\UsersController@setSku'); - Route::post('users/{id}/suspend', 'API\V4\Reseller\UsersController@suspend'); - Route::post('users/{id}/unsuspend', 'API\V4\Reseller\UsersController@unsuspend'); - Route::apiResource('wallets', 'API\V4\Reseller\WalletsController'); - Route::post('wallets/{id}/one-off', 'API\V4\Reseller\WalletsController@oneOff'); - Route::get('wallets/{id}/receipts', 'API\V4\Reseller\WalletsController@receipts'); - Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\Reseller\WalletsController@receiptDownload'); - Route::get('wallets/{id}/transactions', 'API\V4\Reseller\WalletsController@transactions'); - - Route::get('stats/chart/{chart}', 'API\V4\Reseller\StatsController@chart'); + Route::apiResource('domains', API\V4\Reseller\DomainsController::class); + Route::get('domains/{id}/skus', [API\V4\Reseller\SkusController::class, 'domainSkus']); + Route::post('domains/{id}/suspend', [API\V4\Reseller\DomainsController::class, 'suspend']); + Route::post('domains/{id}/unsuspend', [API\V4\Reseller\DomainsController::class, 'unsuspend']); + + Route::apiResource('groups', API\V4\Reseller\GroupsController::class); + Route::post('groups/{id}/suspend', [API\V4\Reseller\GroupsController::class, 'suspend']); + Route::post('groups/{id}/unsuspend', [API\V4\Reseller\GroupsController::class, 'unsuspend']); + + Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class); + Route::post('invitations/{id}/resend', [API\V4\Reseller\InvitationsController::class, 'resend']); + + Route::post('payments', [API\V4\Reseller\PaymentsController::class, 'store']); + Route::get('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandate']); + Route::post('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateCreate']); + Route::put('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateUpdate']); + Route::delete('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateDelete']); + Route::get('payments/methods', [API\V4\Reseller\PaymentsController::class, 'paymentMethods']); + Route::get('payments/pending', [API\V4\Reseller\PaymentsController::class, 'payments']); + Route::get('payments/has-pending', [API\V4\Reseller\PaymentsController::class, 'hasPayments']); + + Route::apiResource('resources', API\V4\Reseller\ResourcesController::class); + Route::apiResource('shared-folders', API\V4\Reseller\SharedFoldersController::class); + Route::apiResource('skus', API\V4\Reseller\SkusController::class); + + Route::apiResource('users', API\V4\Reseller\UsersController::class); + Route::get('users/{id}/discounts', [API\V4\Reseller\DiscountsController::class, 'userDiscounts']); + Route::post('users/{id}/reset2FA', [API\V4\Reseller\UsersController::class, 'reset2FA']); + Route::get('users/{id}/skus', [API\V4\Reseller\SkusController::class, 'userSkus']); + Route::post('users/{id}/skus/{sku}', [API\V4\Admin\UsersController::class, 'setSku']); + Route::post('users/{id}/suspend', [API\V4\Reseller\UsersController::class, 'suspend']); + Route::post('users/{id}/unsuspend', [API\V4\Reseller\UsersController::class, 'unsuspend']); + + Route::apiResource('wallets', API\V4\Reseller\WalletsController::class); + Route::post('wallets/{id}/one-off', [API\V4\Reseller\WalletsController::class, 'oneOff']); + Route::get('wallets/{id}/receipts', [API\V4\Reseller\WalletsController::class, 'receipts']); + Route::get('wallets/{id}/receipts/{receipt}', [API\V4\Reseller\WalletsController::class, 'receiptDownload']); + Route::get('wallets/{id}/transactions', [API\V4\Reseller\WalletsController::class, 'transactions']); + + Route::get('stats/chart/{chart}', [API\V4\Reseller\StatsController::class, 'chart']); } ); } diff --git a/src/routes/channels.php b/src/routes/channels.php index f16a20b9..963b0d21 100644 --- a/src/routes/channels.php +++ b/src/routes/channels.php @@ -1,16 +1,18 @@ id === (int) $id; }); diff --git a/src/routes/console.php b/src/routes/console.php index 75dd0cde..021fdfe3 100644 --- a/src/routes/console.php +++ b/src/routes/console.php @@ -1,18 +1,19 @@ comment(Inspiring::quote()); + $this->comment(Inspiring::quote()); // @phpstan-ignore-line })->describe('Display an inspiring quote'); diff --git a/src/routes/web.php b/src/routes/web.php index a8ce199a..43cfea5f 100644 --- a/src/routes/web.php +++ b/src/routes/web.php @@ -1,28 +1,31 @@ \config('app.website_domain'), ], function () { - Route::get('content/page/{page}', 'ContentController@pageContent') + Route::get('content/page/{page}', Controllers\ContentController::class . '@pageContent') ->where('page', '(.*)'); - Route::get('content/faq/{page}', 'ContentController@faqContent') + Route::get('content/faq/{page}', Controllers\ContentController::class . '@faqContent') ->where('page', '(.*)'); Route::fallback( function () { // Return 404 for requests to the API end-points that do not exist if (strpos(request()->path(), 'api/') === 0) { - return \App\Http\Controllers\Controller::errorResponse(404); + return Controllers\Controller::errorResponse(404); } $env = \App\Utils::uiEnv(); return view($env['view'])->with('env', $env); } ); } ); diff --git a/src/routes/websocket.php b/src/routes/websocket.php deleted file mode 100644 index 3fd6d85b..00000000 --- a/src/routes/websocket.php +++ /dev/null @@ -1,37 +0,0 @@ -deleteTestDomain('testdomain.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestDomain('testdomain.com'); parent::tearDown(); } /** * Test domain info page (unauthenticated) */ public function testDomainInfoUnauth(): void { // Test that the page requires authentication $this->browse(function ($browser) { $browser->visit('/domain/123')->on(new Home()); }); } + /** + * Test domains list page (unauthenticated) + */ + public function testDomainListUnauth(): void + { + // Test that the page requires authentication + $this->browse(function ($browser) { + $browser->visit('/domains')->on(new Home()); + }); + } + /** * Test domain info page (non-existing domain id) */ public function testDomainInfo404(): void { $this->browse(function ($browser) { // FIXME: I couldn't make loginAs() method working // Note: Here we're also testing that unauthenticated request // is passed to logon form and then "redirected" to the requested page $browser->visit('/domain/123') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123') ->assertErrorPage(404); }); } /** * Test domain info page (existing domain) * * @depends testDomainInfo404 */ public function testDomainInfo(): void { $this->browse(function ($browser) { // Unconfirmed domain $domain = Domain::where('namespace', 'kolab.org')->first(); if ($domain->isConfirmed()) { $domain->status ^= Domain::STATUS_CONFIRMED; $domain->save(); } $domain->setSetting('spf_whitelist', \json_encode(['.test.com'])); $browser->visit('/domain/' . $domain->id) ->on(new DomainInfo()) ->assertSeeIn('.card-title', 'Domain') ->whenAvailable('@general', function ($browser) use ($domain) { $browser->assertSeeIn('form div:nth-child(1) label', 'Status') ->assertSeeIn('form div:nth-child(1) #status.text-danger', 'Not Ready') ->assertSeeIn('form div:nth-child(2) label', 'Name') ->assertValue('form div:nth-child(2) input:disabled', $domain->namespace) ->assertSeeIn('form div:nth-child(3) label', 'Subscriptions'); }) ->whenAvailable('@general form div:nth-child(3) table', function ($browser) { $browser->assertElementsCount('tbody tr', 1) ->assertVisible('tbody tr td.selection input:checked:disabled') ->assertSeeIn('tbody tr td.name', 'External Domain') ->assertSeeIn('tbody tr td.price', '0,00 CHF/month') ->assertTip( 'tbody tr td.buttons button', 'Host a domain that is externally registered' ); }) ->whenAvailable('@verify', function ($browser) use ($domain) { $browser->assertSeeIn('pre', $domain->namespace) ->assertSeeIn('pre', $domain->hash()) ->click('button') ->assertToast(Toast::TYPE_SUCCESS, 'Domain verified successfully.'); // TODO: Test scenario when a domain confirmation failed }) ->whenAvailable('@config', function ($browser) use ($domain) { $browser->assertSeeIn('pre', $domain->namespace); }) ->assertMissing('@general button[type=submit]') ->assertMissing('@verify'); // Check that confirmed domain page contains only the config box $browser->visit('/domain/' . $domain->id) ->on(new DomainInfo()) ->assertMissing('@verify') ->assertPresent('@config'); }); } /** * Test domain settings */ public function testDomainSettings(): void { $this->browse(function ($browser) { $domain = Domain::where('namespace', 'kolab.org')->first(); $domain->setSetting('spf_whitelist', \json_encode(['.test.com'])); $browser->visit('/domain/' . $domain->id) ->on(new DomainInfo()) ->assertElementsCount('@nav a', 2) ->assertSeeIn('@nav #tab-general', 'General') ->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->with('#settings form', function (Browser $browser) { // Test whitelist widget $widget = new ListInput('#spf_whitelist'); $browser->assertSeeIn('div.row:nth-child(1) label', 'SPF Whitelist') ->assertVisible('div.row:nth-child(1) .list-input') ->with($widget, function (Browser $browser) { $browser->assertListInputValue(['.test.com']) ->assertValue('@input', '') ->addListEntry('invalid domain'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->with($widget, function (Browser $browser) { $err = 'The entry format is invalid. Expected a domain name starting with a dot.'; $browser->assertFormError(2, $err, false) ->removeListEntry(2) ->removeListEntry(1) ->addListEntry('.new.domain.tld'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Domain settings updated successfully.'); }); }); } - /** - * Test domains list page (unauthenticated) - */ - public function testDomainListUnauth(): void - { - // Test that the page requires authentication - $this->browse(function ($browser) { - $browser->visit('/logout') - ->visit('/domains') - ->on(new Home()); - }); - } - /** * Test domains list page * * @depends testDomainListUnauth */ public function testDomainList(): void { $this->browse(function ($browser) { // Login the user $browser->visit('/login') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) // On dashboard click the "Domains" link ->on(new Dashboard()) ->assertSeeIn('@links a.link-domains', 'Domains') ->click('@links a.link-domains') // On Domains List page click the domain entry ->on(new DomainList()) ->waitFor('@table tbody tr') ->assertVisible('@table tbody tr:first-child td:first-child svg.fa-globe.text-success') ->assertText('@table tbody tr:first-child td:first-child svg title', 'Active') ->assertSeeIn('@table tbody tr:first-child td:first-child', 'kolab.org') ->assertMissing('@table tfoot') ->click('@table tbody tr:first-child td:first-child a') // On Domain Info page verify that's the clicked domain ->on(new DomainInfo()) ->whenAvailable('@config', function ($browser) { $browser->assertSeeIn('pre', 'kolab.org'); }); }); // TODO: Test domains list acting as Ned (John's "delegatee") } /** * Test domains list page (user with no domains) */ public function testDomainListEmpty(): void { $this->browse(function ($browser) { // Login the user $browser->visit('/login') ->on(new Home()) ->submitLogon('jack@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertVisible('@links a.link-profile') ->assertMissing('@links a.link-domains') ->assertMissing('@links a.link-users') ->assertMissing('@links a.link-wallet'); /* // On dashboard click the "Domains" link ->assertSeeIn('@links a.link-domains', 'Domains') ->click('@links a.link-domains') // On Domains List page click the domain entry ->on(new DomainList()) ->assertMissing('@table tbody') ->assertSeeIn('tfoot td', 'There are no domains in this account.'); */ }); } /** * Test domain creation page */ public function testDomainCreate(): void { $this->browse(function ($browser) { $browser->visit('/login') ->on(new Home()) - ->submitLogon('john@kolab.org', 'simple123') + ->submitLogon('john@kolab.org', 'simple123', true) ->visit('/domains') ->on(new DomainList()) ->assertSeeIn('.card-title button.btn-success', 'Create domain') ->click('.card-title button.btn-success') ->on(new DomainInfo()) ->assertSeeIn('.card-title', 'New domain') ->assertElementsCount('@nav li', 1) ->assertSeeIn('@nav li:first-child', 'General') ->whenAvailable('@general', function ($browser) { $browser->assertSeeIn('form div:nth-child(1) label', 'Name') ->assertValue('form div:nth-child(1) input:not(:disabled)', '') ->assertFocused('form div:nth-child(1) input') ->assertSeeIn('form div:nth-child(2) label', 'Package') ->assertMissing('form div:nth-child(3)'); }) ->whenAvailable('@general form div:nth-child(2) table', function ($browser) { $browser->assertElementsCount('tbody tr', 1) ->assertVisible('tbody tr td.selection input:checked[readonly]') ->assertSeeIn('tbody tr td.name', 'Domain Hosting') ->assertSeeIn('tbody tr td.price', '0,00 CHF/month') ->assertTip( 'tbody tr td.buttons button', 'Use your own, existing domain.' ); }) ->assertSeeIn('@general button.btn-primary[type=submit]', 'Submit') ->assertMissing('@config') ->assertMissing('@verify') ->assertMissing('@settings') ->assertMissing('@status') // Test error handling ->click('button[type=submit]') ->waitFor('#namespace + .invalid-feedback') ->assertSeeIn('#namespace + .invalid-feedback', 'The namespace field is required.') ->assertFocused('#namespace') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->type('@general form div:nth-child(1) input', 'testdomain..com') ->click('button[type=submit]') ->waitFor('#namespace + .invalid-feedback') ->assertSeeIn('#namespace + .invalid-feedback', 'The specified domain is invalid.') ->assertFocused('#namespace') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // Test success ->type('@general form div:nth-child(1) input', 'testdomain.com') ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Domain created successfully.') ->on(new DomainList()) ->assertSeeIn('@table tr:nth-child(2) a', 'testdomain.com'); }); } /** * Test domain deletion */ public function testDomainDelete(): void { // Create the domain to delete $john = $this->getTestUser('john@kolab.org'); $domain = $this->getTestDomain('testdomain.com', ['type' => Domain::TYPE_EXTERNAL]); $packageDomain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domain->assignPackage($packageDomain, $john); $this->browse(function ($browser) { $browser->visit('/login') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123') ->visit('/domains') ->on(new DomainList()) ->assertElementsCount('@table tbody tr', 2) ->assertSeeIn('@table tr:nth-child(2) a', 'testdomain.com') ->click('@table tbody tr:nth-child(2) a') ->on(new DomainInfo()) ->waitFor('button.button-delete') ->assertSeeIn('button.button-delete', 'Delete domain') ->click('button.button-delete') ->with(new Dialog('#delete-warning'), function ($browser) { $browser->assertSeeIn('@title', 'Delete testdomain.com') ->assertFocused('@button-cancel') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Delete') ->click('@button-cancel'); }) ->waitUntilMissing('#delete-warning') ->click('button.button-delete') ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->click('@button-action'); }) ->waitUntilMissing('#delete-warning') ->assertToast(Toast::TYPE_SUCCESS, 'Domain deleted successfully.') ->on(new DomainList()) ->assertElementsCount('@table tbody tr', 1); // Test error handling on deleting a non-empty domain $err = 'Unable to delete a domain with assigned users or other objects.'; $browser->click('@table tbody tr:nth-child(1) a') ->on(new DomainInfo()) ->waitFor('button.button-delete') ->click('button.button-delete') ->with(new Dialog('#delete-warning'), function ($browser) { $browser->click('@button-action'); }) ->assertToast(Toast::TYPE_ERROR, $err); }); } } diff --git a/src/tests/Browser/ErrorTest.php b/src/tests/Browser/ErrorTest.php index 5a49ab9a..920f9a8b 100644 --- a/src/tests/Browser/ErrorTest.php +++ b/src/tests/Browser/ErrorTest.php @@ -1,38 +1,37 @@ browse(function (Browser $browser) { $browser->visit('/unknown') ->waitFor('#app > #error-page') ->assertVisible('#app > #header-menu') ->assertVisible('#app > #footer-menu'); $this->assertSame('404', $browser->text('#error-page .code')); $this->assertSame('Not found', $browser->text('#error-page .message')); }); $this->browse(function (Browser $browser) { $browser->visit('/login/unknown') ->waitFor('#app > #error-page') ->assertVisible('#app > #header-menu') ->assertVisible('#app > #footer-menu'); $this->assertSame('404', $browser->text('#error-page .code')); $this->assertSame('Not found', $browser->text('#error-page .message')); }); } } diff --git a/src/tests/Browser/SharedFolderTest.php b/src/tests/Browser/SharedFolderTest.php index 103ff02e..350fa817 100644 --- a/src/tests/Browser/SharedFolderTest.php +++ b/src/tests/Browser/SharedFolderTest.php @@ -1,397 +1,397 @@ delete(); $this->clearBetaEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { SharedFolder::whereNotIn('email', ['folder-event@kolab.org', 'folder-contact@kolab.org'])->delete(); $this->clearBetaEntitlements(); parent::tearDown(); } /** * Test shared folder info page (unauthenticated) */ public function testInfoUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/shared-folder/abc')->on(new Home()); }); } /** * Test shared folder list page (unauthenticated) */ public function testListUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/shared-folders')->on(new Home()); }); } /** * Test shared folders list page */ public function testList(): void { // Log on the user $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertMissing('@links .link-shared-folders'); }); // Test that shared folders lists page is not accessible without the 'beta-shared-folders' entitlement $this->browse(function (Browser $browser) { $browser->visit('/shared-folders') ->assertErrorPage(403); }); // Add beta+beta-shared-folders entitlements $john = $this->getTestUser('john@kolab.org'); $this->addBetaEntitlement($john, 'beta-shared-folders'); // Make sure the first folder is active $folder = $this->getTestSharedFolder('folder-event@kolab.org'); $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY; $folder->save(); // Test shared folders lists page $this->browse(function (Browser $browser) { $browser->visit(new Dashboard()) ->assertSeeIn('@links .link-shared-folders', 'Shared folders') ->click('@links .link-shared-folders') ->on(new SharedFolderList()) ->whenAvailable('@table', function (Browser $browser) { $browser->waitFor('tbody tr') ->assertElementsCount('thead th', 2) ->assertSeeIn('thead tr th:nth-child(1)', 'Name') ->assertSeeIn('thead tr th:nth-child(2)', 'Type') ->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(1) a', 'Calendar') ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(2)', 'Calendar') ->assertText('tbody tr:nth-child(1) td:nth-child(1) svg.text-success title', 'Active') ->assertSeeIn('tbody tr:nth-child(2) td:nth-child(1) a', 'Contacts') ->assertSeeIn('tbody tr:nth-child(2) td:nth-child(2)', 'Address Book') ->assertMissing('tfoot'); }); }); } /** * Test shared folder creation/editing/deleting * * @depends testList */ public function testCreateUpdateDelete(): void { // Test that the page is not available accessible without the 'beta-shared-folders' entitlement $this->browse(function (Browser $browser) { $browser->visit('/shared-folder/new') ->assertErrorPage(403); }); // Add beta+beta-shared-folders entitlements $john = $this->getTestUser('john@kolab.org'); $this->addBetaEntitlement($john, 'beta-shared-folders'); $this->browse(function (Browser $browser) { // Create a folder $browser->visit(new SharedFolderList()) ->assertSeeIn('button.shared-folder-new', 'Create folder') ->click('button.shared-folder-new') ->on(new SharedFolderInfo()) ->assertSeeIn('#folder-info .card-title', 'New shared folder') ->assertSeeIn('@nav #tab-general', 'General') ->assertMissing('@nav #tab-settings') ->with('@general', function (Browser $browser) { // Assert form content $browser->assertMissing('#status') ->assertFocused('#name') ->assertSeeIn('div.row:nth-child(1) label', 'Name') ->assertValue('div.row:nth-child(1) input[type=text]', '') ->assertSeeIn('div.row:nth-child(2) label', 'Type') ->assertSelectHasOptions( 'div.row:nth-child(2) select', ['mail', 'event', 'task', 'contact', 'note', 'file'] ) ->assertValue('div.row:nth-child(2) select', 'mail') ->assertSeeIn('div.row:nth-child(3) label', 'Domain') ->assertSelectHasOptions('div.row:nth-child(3) select', ['kolab.org']) ->assertValue('div.row:nth-child(3) select', 'kolab.org') ->assertSeeIn('div.row:nth-child(4) label', 'Email Addresses') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue([]) ->assertValue('@input', ''); }) ->assertSeeIn('button[type=submit]', 'Submit'); }) // Test error conditions ->type('#name', str_repeat('A', 192)) ->select('#type', 'event') ->assertMissing('#aliases') ->click('@general button[type=submit]') ->waitFor('#name + .invalid-feedback') ->assertSeeIn('#name + .invalid-feedback', 'The name may not be greater than 191 characters.') ->assertFocused('#name') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // Test error handling on aliases input ->type('#name', 'Test Folder') ->select('#type', 'mail') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('folder-alias@unknown'); }) ->click('@general button[type=submit]') ->assertMissing('#name + .invalid-feedback') ->waitFor('#aliases + .invalid-feedback') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertFormError(1, "The specified domain is invalid.", true); }) ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // Test successful folder creation ->select('#type', 'event') ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder created successfully.') ->on(new SharedFolderList()) ->assertElementsCount('@table tbody tr', 3); $this->assertSame(1, SharedFolder::where('name', 'Test Folder')->count()); $this->assertSame(0, SharedFolder::where('name', 'Test Folder')->first()->aliases()->count()); // Test folder update $browser->click('@table tr:nth-child(3) td:first-child a') ->on(new SharedFolderInfo()) ->assertSeeIn('#folder-info .card-title', 'Shared folder') ->with('@general', function (Browser $browser) { // Assert form content $browser->assertFocused('#name') ->assertSeeIn('div.row:nth-child(1) label', 'Status') ->assertSeeIn('div.row:nth-child(1) span.text-danger', 'Not Ready') ->assertSeeIn('div.row:nth-child(2) label', 'Name') ->assertValue('div.row:nth-child(2) input[type=text]', 'Test Folder') ->assertSeeIn('div.row:nth-child(3) label', 'Type') ->assertSelected('div.row:nth-child(3) select:disabled', 'event') ->assertMissing('#aliases') ->assertSeeIn('button[type=submit]', 'Submit'); }) // Test error handling ->type('#name', str_repeat('A', 192)) ->click('@general button[type=submit]') ->waitFor('#name + .invalid-feedback') ->assertSeeIn('#name + .invalid-feedback', 'The name may not be greater than 191 characters.') ->assertVisible('#name.is-invalid') ->assertFocused('#name') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // Test successful update ->type('#name', 'Test Folder Update') ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder updated successfully.') ->on(new SharedFolderList()) ->assertElementsCount('@table tbody tr', 3) ->assertSeeIn('@table tr:nth-child(3) td:first-child a', 'Test Folder Update'); $this->assertSame(1, SharedFolder::where('name', 'Test Folder Update')->count()); // Test folder deletion $browser->click('@table tr:nth-child(3) td:first-child a') ->on(new SharedFolderInfo()) ->assertSeeIn('button.button-delete', 'Delete folder') ->click('button.button-delete') ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder deleted successfully.') ->on(new SharedFolderList()) ->assertElementsCount('@table tbody tr', 2); $this->assertNull(SharedFolder::where('name', 'Test Folder Update')->first()); }); // Test creation/updating a mail folder with mail aliases $this->browse(function (Browser $browser) { $browser->on(new SharedFolderList()) - ->click('button.create-folder') + ->click('button.shared-folder-new') ->on(new SharedFolderInfo()) ->type('#name', 'Test Folder2') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('folder-alias1@kolab.org') ->addListEntry('folder-alias2@kolab.org'); }) ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder created successfully.') ->on(new SharedFolderList()) ->assertElementsCount('@table tbody tr', 3); $folder = SharedFolder::where('name', 'Test Folder2')->first(); $this->assertSame( ['folder-alias1@kolab.org', 'folder-alias2@kolab.org'], $folder->aliases()->pluck('alias')->all() ); // Test folder update $browser->click('@table tr:nth-child(3) td:first-child a') ->on(new SharedFolderInfo()) ->with('@general', function (Browser $browser) { // Assert form content $browser->assertFocused('#name') ->assertValue('div.row:nth-child(2) input[type=text]', 'Test Folder2') ->assertSelected('div.row:nth-child(3) select:disabled', 'mail') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue(['folder-alias1@kolab.org', 'folder-alias2@kolab.org']) ->assertValue('@input', ''); }); }) // change folder name, and remove one alias ->type('#name', 'Test Folder Update2') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->removeListEntry(2); }) ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder updated successfully.'); $folder->refresh(); $this->assertSame('Test Folder Update2', $folder->name); $this->assertSame(['folder-alias1@kolab.org'], $folder->aliases()->pluck('alias')->all()); }); } /** * Test shared folder status * * @depends testList */ public function testStatus(): void { $john = $this->getTestUser('john@kolab.org'); $this->addBetaEntitlement($john, 'beta-shared-folders'); $folder = $this->getTestSharedFolder('folder-event@kolab.org'); $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE | SharedFolder::STATUS_LDAP_READY; $folder->created_at = \now(); $folder->save(); $this->assertFalse($folder->isImapReady()); $this->browse(function ($browser) use ($folder) { // Test auto-refresh $browser->visit('/shared-folder/' . $folder->id) ->on(new SharedFolderInfo()) ->with(new Status(), function ($browser) { $browser->assertSeeIn('@body', 'We are preparing the shared folder') ->assertProgress(85, 'Creating a shared folder...', 'pending') ->assertMissing('@refresh-button') ->assertMissing('@refresh-text') ->assertMissing('#status-link') ->assertMissing('#status-verify'); }); $folder->status |= SharedFolder::STATUS_IMAP_READY; $folder->save(); // Test Verify button $browser->waitUntilMissing('@status', 10); }); // TODO: Test all shared folder statuses on the list } /** * Test shared folder settings */ public function testSettings(): void { $john = $this->getTestUser('john@kolab.org'); $this->addBetaEntitlement($john, 'beta-shared-folders'); $folder = $this->getTestSharedFolder('folder-event@kolab.org'); $folder->setSetting('acl', null); $this->browse(function ($browser) use ($folder) { $aclInput = new AclInput('@settings #acl'); // Test auto-refresh $browser->visit('/shared-folder/' . $folder->id) ->on(new SharedFolderInfo()) ->assertSeeIn('@nav #tab-general', 'General') ->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->with('@settings form', function (Browser $browser) { // Assert form content $browser->assertSeeIn('div.row:nth-child(1) label', 'Access rights') ->assertSeeIn('div.row:nth-child(1) #acl-hint', 'permissions') ->assertSeeIn('button[type=submit]', 'Submit'); }) // Test the AclInput widget ->with($aclInput, function (Browser $browser) { $browser->assertAclValue([]) ->addAclEntry('anyone, read-only') ->addAclEntry('test, read-write') ->addAclEntry('john@kolab.org, full') ->assertAclValue([ 'anyone, read-only', 'test, read-write', 'john@kolab.org, full', ]); }) // Test error handling ->click('@settings button[type=submit]') ->with($aclInput, function (Browser $browser) { $browser->assertFormError(2, 'The specified email address is invalid.'); }) ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // Test successful update ->with($aclInput, function (Browser $browser) { $browser->removeAclEntry(2) ->assertAclValue([ 'anyone, read-only', 'john@kolab.org, full', ]) ->updateAclEntry(2, 'jack@kolab.org, read-write') ->assertAclValue([ 'anyone, read-only', 'jack@kolab.org, read-write', ]); }) ->click('@settings button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder settings updated successfully.') ->assertMissing('.invalid-feedback') // Refresh the page and check if everything was saved ->refresh() ->on(new SharedFolderInfo()) ->click('@nav #tab-settings') ->with($aclInput, function (Browser $browser) { $browser->assertAclValue([ 'anyone, read-only', 'jack@kolab.org, read-write', ]); }); }); } } diff --git a/src/tests/Feature/Auth/SecondFactorTest.php b/src/tests/Feature/Auth/SecondFactorTest.php index 124ce306..a5e60385 100644 --- a/src/tests/Feature/Auth/SecondFactorTest.php +++ b/src/tests/Feature/Auth/SecondFactorTest.php @@ -1,63 +1,68 @@ deleteTestUser('entitlement-test@kolabnow.com'); } + /** + * {@inheritDoc} + */ public function tearDown(): void { $this->deleteTestUser('entitlement-test@kolabnow.com'); parent::tearDown(); } /** * Test that 2FA config is removed from Roundcube database * on entitlement delete */ public function testEntitlementDelete(): void { // Create the user, and assign 2FA to him, and add Roundcube setup $sku_2fa = Sku::withEnvTenantContext()->where('title', '2fa')->first(); $user = $this->getTestUser('entitlement-test@kolabnow.com'); $user->assignSku($sku_2fa); SecondFactor::seed('entitlement-test@kolabnow.com'); $entitlement = Entitlement::where('sku_id', $sku_2fa->id) ->where('entitleable_id', $user->id) ->first(); $this->assertTrue(!empty($entitlement)); $sf = new SecondFactor($user); $factors = $sf->factors(); $this->assertCount(1, $factors); $this->assertSame('totp:8132a46b1f741f88de25f47e', $factors[0]); // $this->assertSame('dummy:dummy', $factors[1]); // Delete the entitlement, expect all configured 2FA methods in Roundcube removed $entitlement->delete(); $this->assertTrue($entitlement->trashed()); $sf = new SecondFactor($user); $factors = $sf->factors(); $this->assertCount(0, $factors); } } diff --git a/src/tests/Feature/AuthAttemptTest.php b/src/tests/Feature/AuthAttemptTest.php index 0b096afb..fe1af546 100644 --- a/src/tests/Feature/AuthAttemptTest.php +++ b/src/tests/Feature/AuthAttemptTest.php @@ -1,40 +1,45 @@ deleteTestUser('jane@kolabnow.com'); } + /** + * {@inheritDoc} + */ public function tearDown(): void { $this->deleteTestUser('jane@kolabnow.com'); parent::tearDown(); } public function testRecord(): void { $user = $this->getTestUser('jane@kolabnow.com'); $authAttempt = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.1"); $this->assertEquals($authAttempt->user_id, $user->id); $this->assertEquals($authAttempt->ip, "10.0.0.1"); $authAttempt->refresh(); $this->assertEquals($authAttempt->status, "NEW"); $authAttempt2 = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.1"); $this->assertEquals($authAttempt->id, $authAttempt2->id); $authAttempt3 = \App\AuthAttempt::recordAuthAttempt($user, "10.0.0.2"); $this->assertNotEquals($authAttempt->id, $authAttempt3->id); } } diff --git a/src/tests/Feature/Console/Data/Import/LdifTest.php b/src/tests/Feature/Console/Data/Import/LdifTest.php index 65e6073f..b858d632 100644 --- a/src/tests/Feature/Console/Data/Import/LdifTest.php +++ b/src/tests/Feature/Console/Data/Import/LdifTest.php @@ -1,439 +1,444 @@ deleteTestUser('owner@kolab3.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('owner@kolab3.com'); parent::tearDown(); } /** * Test the command */ public function testHandle(): void { $code = \Artisan::call("data:import:ldif tests/data/kolab3.ldif owner@kolab3.com"); $output = trim(\Artisan::output()); $this->assertSame(1, $code); $this->assertStringNotContainsString("Importing", $output); $this->assertStringNotContainsString("WARNING", $output); $this->assertStringContainsString( "ERROR cn=error,ou=groups,ou=kolab3.com,dc=hosted,dc=com: Missing 'mail' attribute", $output ); $this->assertStringContainsString( "ERROR cn=error,ou=resources,ou=kolab3.com,dc=hosted,dc=com: Missing 'mail' attribute", $output ); $code = \Artisan::call("data:import:ldif tests/data/kolab3.ldif owner@kolab3.com --force"); $output = trim(\Artisan::output()); $this->assertSame(0, $code); $this->assertStringContainsString("Importing domains... DONE", $output); $this->assertStringContainsString("Importing users... DONE", $output); $this->assertStringContainsString("Importing resources... DONE", $output); $this->assertStringContainsString("Importing shared folders... DONE", $output); $this->assertStringContainsString("Importing groups... DONE", $output); $this->assertStringNotContainsString("ERROR", $output); $this->assertStringContainsString( "WARNING cn=unknowndomain,ou=groups,ou=kolab3.org,dc=hosted,dc=com: Domain not found", $output ); $owner = \App\User::where('email', 'owner@kolab3.com')->first(); $this->assertNull($owner->password); $this->assertSame( '{SSHA512}g74+SECTLsM1x0aYkSrTG9sOFzEp8wjCflhshr2DjE7mi1G3iNb4ClH3ljorPRlTgZ105PsQGEpNtNr+XRjigg==', $owner->password_ldap ); // User settings $this->assertSame('Aleksander', $owner->getSetting('first_name')); $this->assertSame('Machniak', $owner->getSetting('last_name')); $this->assertSame('123456789', $owner->getSetting('phone')); $this->assertSame('external@gmail.com', $owner->getSetting('external_email')); $this->assertSame('Organization AG', $owner->getSetting('organization')); // User aliases $aliases = $owner->aliases()->orderBy('alias')->pluck('alias')->all(); $this->assertSame(['alias@kolab3-alias.com', 'alias@kolab3.com'], $aliases); // Wallet, entitlements $wallet = $owner->wallets->first(); $this->assertEntitlements($owner, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', ]); // Users $this->assertSame(2, $owner->users(false)->count()); + /** @var \App\User $user */ $user = $owner->users(false)->where('email', 'user@kolab3.com')->first(); // User settings $this->assertSame('Jane', $user->getSetting('first_name')); $this->assertSame('Doe', $user->getSetting('last_name')); $this->assertSame('1234567890', $user->getSetting('phone')); $this->assertSame('ext@gmail.com', $user->getSetting('external_email')); $this->assertSame('Org AG', $user->getSetting('organization')); // User aliases $aliases = $user->aliases()->orderBy('alias')->pluck('alias')->all(); $this->assertSame(['alias2@kolab3.com'], $aliases); $this->assertEntitlements($user, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', ]); // Domains + /** @var \App\Domain[] $domains */ $domains = $owner->domains(false, false)->orderBy('namespace')->get(); $this->assertCount(2, $domains); $this->assertSame('kolab3-alias.com', $domains[0]->namespace); $this->assertSame('kolab3.com', $domains[1]->namespace); $this->assertSame(\App\Domain::TYPE_EXTERNAL, $domains[0]->type); $this->assertSame(\App\Domain::TYPE_EXTERNAL, $domains[1]->type); $this->assertEntitlements($domains[0], ['domain-hosting']); $this->assertEntitlements($domains[1], ['domain-hosting']); // Shared folders + /** @var \App\SharedFolder[] $folders */ $folders = $owner->sharedFolders(false)->orderBy('email')->get(); $this->assertCount(2, $folders); $this->assertMatchesRegularExpression('/^event-[0-9]+@kolab3\.com$/', $folders[0]->email); $this->assertMatchesRegularExpression('/^mail-[0-9]+@kolab3\.com$/', $folders[1]->email); $this->assertSame('Folder2', $folders[0]->name); $this->assertSame('Folder1', $folders[1]->name); $this->assertSame('event', $folders[0]->type); $this->assertSame('mail', $folders[1]->type); $this->assertSame('["anyone, read-only"]', $folders[0]->getSetting('acl')); $this->assertSame('shared/Folder2@kolab3.com', $folders[0]->getSetting('folder')); $this->assertSame('["anyone, read-write","owner@kolab3.com, full"]', $folders[1]->getSetting('acl')); $this->assertSame('shared/Folder1@kolab3.com', $folders[1]->getSetting('folder')); $this->assertSame([], $folders[0]->aliases()->orderBy('alias')->pluck('alias')->all()); $this->assertSame( ['folder-alias1@kolab3.com', 'folder-alias2@kolab3.com'], $folders[1]->aliases()->orderBy('alias')->pluck('alias')->all() ); // Groups + /** @var \App\Group[] $groups */ $groups = $owner->groups(false)->orderBy('email')->get(); $this->assertCount(1, $groups); $this->assertSame('Group', $groups[0]->name); $this->assertSame('group@kolab3.com', $groups[0]->email); $this->assertSame(['owner@kolab3.com', 'user@kolab3.com'], $groups[0]->members); $this->assertSame('["sender@gmail.com","-"]', $groups[0]->getSetting('sender_policy')); // Resources + /** @var \App\Resource[] $resources */ $resources = $owner->resources(false)->orderBy('email')->get(); $this->assertCount(1, $resources); $this->assertSame('Resource', $resources[0]->name); $this->assertMatchesRegularExpression('/^resource-[0-9]+@kolab3\.com$/', $resources[0]->email); $this->assertSame('shared/Resource@kolab3.com', $resources[0]->getSetting('folder')); $this->assertSame('manual:user@kolab3.com', $resources[0]->getSetting('invitation_policy')); } /** * Test parseACL() method */ public function testParseACL(): void { $command = new \App\Console\Commands\Data\Import\LdifCommand(); $result = $this->invokeMethod($command, 'parseACL', [[]]); $this->assertSame([], $result); $acl = [ 'anyone, read-write', 'read-only@kolab3.com, read-only', 'read-only@kolab3.com, read', 'full@kolab3.com,full', 'lrswipkxtecdn@kolab3.com, lrswipkxtecdn', // full 'lrs@kolab3.com, lrs', // read-only 'lrswitedn@kolab3.com, lrswitedn', // read-write // unsupported: 'anonymous, read-only', 'group:test, lrs', 'test@kolab3.com, lrspkxtdn', ]; $expected = [ 'anyone, read-write', 'read-only@kolab3.com, read-only', 'read-only@kolab3.com, read-only', 'full@kolab3.com, full', 'lrswipkxtecdn@kolab3.com, full', 'lrs@kolab3.com, read-only', 'lrswitedn@kolab3.com, read-write', ]; $result = $this->invokeMethod($command, 'parseACL', [$acl]); $this->assertSame($expected, $result); } /** * Test parseInvitationPolicy() method */ public function testParseInvitationPolicy(): void { $command = new \App\Console\Commands\Data\Import\LdifCommand(); $result = $this->invokeMethod($command, 'parseInvitationPolicy', [[]]); $this->assertSame(null, $result); $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['UNKNOWN']]); $this->assertSame(null, $result); $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['ACT_ACCEPT']]); $this->assertSame(null, $result); $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['ACT_MANUAL']]); $this->assertSame('manual', $result); $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['ACT_REJECT']]); $this->assertSame('reject', $result); $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['ACT_ACCEPT_AND_NOTIFY', 'ACT_REJECT']]); $this->assertSame(null, $result); } /** * Test parseSenderPolicy() method */ public function testParseSenderPolicy(): void { $command = new \App\Console\Commands\Data\Import\LdifCommand(); $result = $this->invokeMethod($command, 'parseSenderPolicy', [[]]); $this->assertSame([], $result); $result = $this->invokeMethod($command, 'parseSenderPolicy', [['test']]); $this->assertSame(['test', '-'], $result); $result = $this->invokeMethod($command, 'parseSenderPolicy', [['test', '-test2', 'test3', '']]); $this->assertSame(['test', 'test3', '-'], $result); } /** * Test parseLDAPDomain() method */ public function testParseLDAPDomain(): void { $command = new \App\Console\Commands\Data\Import\LdifCommand(); $entry = []; $result = $this->invokeMethod($command, 'parseLDAPDomain', [$entry]); $this->assertSame([], $result[0]); $this->assertSame("Missing 'associatedDomain' attribute", $result[1]); $entry = ['associateddomain' => 'test.com']; $result = $this->invokeMethod($command, 'parseLDAPDomain', [$entry]); $this->assertSame(['namespace' => 'test.com'], $result[0]); $this->assertSame(null, $result[1]); $entry = ['associateddomain' => 'test.com', 'inetdomainstatus' => 'deleted']; $result = $this->invokeMethod($command, 'parseLDAPDomain', [$entry]); $this->assertSame([], $result[0]); $this->assertSame("Domain deleted", $result[1]); } /** * Test parseLDAPGroup() method */ public function testParseLDAPGroup(): void { $command = new \App\Console\Commands\Data\Import\LdifCommand(); $entry = []; $result = $this->invokeMethod($command, 'parseLDAPGroup', [$entry]); $this->assertSame([], $result[0]); $this->assertSame("Missing 'cn' attribute", $result[1]); $entry = ['cn' => 'Test']; $result = $this->invokeMethod($command, 'parseLDAPGroup', [$entry]); $this->assertSame([], $result[0]); $this->assertSame("Missing 'mail' attribute", $result[1]); $entry = ['cn' => 'Test', 'mail' => 'test@domain.tld']; $result = $this->invokeMethod($command, 'parseLDAPGroup', [$entry]); $this->assertSame([], $result[0]); $this->assertSame("Missing 'uniqueMember' attribute", $result[1]); $entry = [ 'cn' => 'Test', 'mail' => 'Test@domain.tld', 'uniquemember' => 'uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com', 'kolaballowsmtpsender' => ['sender1@gmail.com', 'sender2@gmail.com'], ]; $expected = [ 'name' => 'Test', 'email' => 'test@domain.tld', 'members' => ['uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com'], 'sender_policy' => ['sender1@gmail.com', 'sender2@gmail.com', '-'], ]; $result = $this->invokeMethod($command, 'parseLDAPGroup', [$entry]); $this->assertSame($expected, $result[0]); $this->assertSame(null, $result[1]); } /** * Test parseLDAPResource() method */ public function testParseLDAPResource(): void { $command = new \App\Console\Commands\Data\Import\LdifCommand(); $entry = []; $result = $this->invokeMethod($command, 'parseLDAPResource', [$entry]); $this->assertSame([], $result[0]); $this->assertSame("Missing 'cn' attribute", $result[1]); $entry = ['cn' => 'Test']; $result = $this->invokeMethod($command, 'parseLDAPResource', [$entry]); $this->assertSame([], $result[0]); $this->assertSame("Missing 'mail' attribute", $result[1]); $entry = [ 'cn' => 'Test', 'mail' => 'Test@domain.tld', 'owner' => 'uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com', 'kolabtargetfolder' => 'Folder', 'kolabinvitationpolicy' => 'ACT_REJECT' ]; $expected = [ 'name' => 'Test', 'email' => 'test@domain.tld', 'folder' => 'Folder', 'owner' => 'uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com', 'invitation_policy' => 'reject', ]; $result = $this->invokeMethod($command, 'parseLDAPResource', [$entry]); $this->assertSame($expected, $result[0]); $this->assertSame(null, $result[1]); } /** * Test parseLDAPSharedFolder() method */ public function testParseLDAPSharedFolder(): void { $command = new \App\Console\Commands\Data\Import\LdifCommand(); $entry = []; $result = $this->invokeMethod($command, 'parseLDAPSharedFolder', [$entry]); $this->assertSame([], $result[0]); $this->assertSame("Missing 'cn' attribute", $result[1]); $entry = ['cn' => 'Test']; $result = $this->invokeMethod($command, 'parseLDAPSharedFolder', [$entry]); $this->assertSame([], $result[0]); $this->assertSame("Missing 'mail' attribute", $result[1]); $entry = [ 'cn' => 'Test', 'mail' => 'Test@domain.tld', 'kolabtargetfolder' => 'Folder', 'kolabfoldertype' => 'event', 'acl' => 'anyone, read-write', 'alias' => ['test1@domain.tld', 'test2@domain.tld'], ]; $expected = [ 'name' => 'Test', 'email' => 'test@domain.tld', 'type' => 'event', 'folder' => 'Folder', 'acl' => ['anyone, read-write'], 'aliases' => ['test1@domain.tld', 'test2@domain.tld'], ]; $result = $this->invokeMethod($command, 'parseLDAPSharedFolder', [$entry]); $this->assertSame($expected, $result[0]); $this->assertSame(null, $result[1]); } /** * Test parseLDAPUser() method */ public function testParseLDAPUser(): void { // Note: If we do not initialize the command input we'll get an error $args = [ 'file' => 'test.ldif', 'owner' => 'test@domain.tld', ]; $command = new \App\Console\Commands\Data\Import\LdifCommand(); $command->setInput(new \Symfony\Component\Console\Input\ArrayInput($args, $command->getDefinition())); $entry = ['cn' => 'Test']; $result = $this->invokeMethod($command, 'parseLDAPUser', [$entry]); $this->assertSame([], $result[0]); $this->assertSame("Missing 'mail' attribute", $result[1]); $entry = [ 'dn' => 'user dn', 'givenname' => 'Given', 'mail' => 'Test@domain.tld', 'sn' => 'Surname', 'telephonenumber' => '123', 'o' => 'Org', 'mailalternateaddress' => 'test@ext.com', 'alias' => ['test1@domain.tld', 'test2@domain.tld'], 'userpassword' => 'pass', 'mailquota' => '12345678', ]; $expected = [ 'email' => 'test@domain.tld', 'settings' => [ 'first_name' => 'Given', 'last_name' => 'Surname', 'phone' => '123', 'external_email' => 'test@ext.com', 'organization' => 'Org', ], 'aliases' => ['test1@domain.tld', 'test2@domain.tld'], 'password' => 'pass', 'quota' => '12345678', ]; $result = $this->invokeMethod($command, 'parseLDAPUser', [$entry]); $this->assertSame($expected, $result[0]); $this->assertSame(null, $result[1]); $this->assertSame($entry['dn'], $this->getObjectProperty($command, 'ownerDN')); } } diff --git a/src/tests/Feature/Console/Scalpel/TenantSetting/CreateCommandTest.php b/src/tests/Feature/Console/Scalpel/TenantSetting/CreateCommandTest.php index 975dbc2a..3d90d53f 100644 --- a/src/tests/Feature/Console/Scalpel/TenantSetting/CreateCommandTest.php +++ b/src/tests/Feature/Console/Scalpel/TenantSetting/CreateCommandTest.php @@ -1,44 +1,44 @@ first(); $this->artisan("scalpel:tenant-setting:create --key=test --value=init --tenant_id={$tenant->id}") ->assertExitCode(0); $setting = $tenant->settings()->where('key', 'test')->first(); - $this->assertSame('init', $setting->fresh()->value); + $this->assertSame('init', $setting->value); $this->assertSame('init', $tenant->fresh()->getSetting('test')); } } diff --git a/src/tests/Feature/Console/Scalpel/WalletSetting/CreateCommandTest.php b/src/tests/Feature/Console/Scalpel/WalletSetting/CreateCommandTest.php index 2fdd3854..3c946bd3 100644 --- a/src/tests/Feature/Console/Scalpel/WalletSetting/CreateCommandTest.php +++ b/src/tests/Feature/Console/Scalpel/WalletSetting/CreateCommandTest.php @@ -1,23 +1,23 @@ getTestUser('john@kolab.org'); $wallet = $user->wallets->first(); $wallet->setSetting('test', null); $this->artisan("scalpel:wallet-setting:create --key=test --value=init --wallet_id={$wallet->id}") ->assertExitCode(0); $setting = $wallet->settings()->where('key', 'test')->first(); - $this->assertSame('init', $setting->fresh()->value); + $this->assertSame('init', $setting->value); $this->assertSame('init', $wallet->fresh()->getSetting('test')); } } diff --git a/src/tests/Feature/Controller/SupportTest.php b/src/tests/Feature/Controller/SupportTest.php index c0924257..0ba8be9c 100644 --- a/src/tests/Feature/Controller/SupportTest.php +++ b/src/tests/Feature/Controller/SupportTest.php @@ -1,82 +1,109 @@ $support_email]); } // Empty request $response = $this->post("api/v4/support/request", []); $response->assertStatus(422); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('error', $json['status']); $this->assertCount(3, $json['errors']); $this->assertSame(['The email field is required.'], $json['errors']['email']); $this->assertSame(['The summary field is required.'], $json['errors']['summary']); $this->assertSame(['The body field is required.'], $json['errors']['body']); // Invalid email $post = [ 'email' => '@test.com', 'summary' => 'Test summary', 'body' => 'Test body', ]; $response = $this->post("api/v4/support/request", $post); $response->assertStatus(422); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame(['The email must be a valid email address.'], $json['errors']['email']); - $this->assertCount(0, $this->app->make('swift.transport')->driver()->messages()); + $this->assertCount(0, $this->getSentMessages()); // Valid input $post = [ 'email' => 'test@test.com', 'summary' => 'Test summary', 'body' => 'Test body', 'user' => '1234567', 'name' => 'Username', ]; $response = $this->post("api/v4/support/request", $post); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame('Support request submitted successfully.', $json['message']); - $emails = $this->app->make('swift.transport')->driver()->messages(); + $emails = $this->getSentMessages(); - $expected_body = "ID: 1234567\nName: Username\nWorking email address: test@test.com\n" + $this->assertCount(1, $emails); + + $to = $emails[0]->getTo(); + $from = $emails[0]->getFrom(); + $replyTo = $emails[0]->getReplyTo(); + $expectedBody = "ID: 1234567\nName: Username\nWorking email address: test@test.com\n" . "Subject: Test summary\n\nTest body"; - $this->assertCount(1, $emails); + $this->assertCount(1, $to); + $this->assertCount(1, $from); + $this->assertCount(1, $replyTo); $this->assertSame('Test summary', $emails[0]->getSubject()); - $this->assertSame(['test@test.com' => 'Username'], $emails[0]->getFrom()); - $this->assertSame(['test@test.com' => 'Username'], $emails[0]->getReplyTo()); - $this->assertNull($emails[0]->getCc()); - $this->assertSame([$support_email => null], $emails[0]->getTo()); - $this->assertSame($expected_body, trim($emails[0]->getBody())); + $this->assertSame('test@test.com', $from[0]->getAddress()); + $this->assertSame('Username', $from[0]->getName()); + $this->assertSame('test@test.com', $replyTo[0]->getAddress()); + $this->assertSame('Username', $replyTo[0]->getName()); + $this->assertSame([], $emails[0]->getCc()); + $this->assertSame($support_email, $to[0]->getAddress()); + $this->assertSame('', $to[0]->getName()); + $this->assertSame($expectedBody, trim($emails[0]->getTextBody())); + $this->assertSame('', trim($emails[0]->getHtmlBody())); + } + + /** + * Get all messages that have been sent + * + * @return \Symfony\Component\Mime\Email[] + */ + protected function getSentMessages(): array + { + $transport = $this->app->make('mail.manager')->mailer()->getSymfonyTransport(); + + return $this->getObjectProperty($transport, 'messages') + ->map(function (\Symfony\Component\Mailer\SentMessage $item) { + return $item->getOriginalMessage(); + }) + ->all(); } } diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php index c10c7e46..8bec1245 100644 --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -1,1508 +1,1509 @@ deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestUser('deleted@kolab.org'); $this->deleteTestUser('deleted@kolabnow.com'); $this->deleteTestDomain('userscontroller.com'); $this->deleteTestGroup('group-test@kolabnow.com'); $this->deleteTestGroup('group-test@kolab.org'); $this->deleteTestSharedFolder('folder-test@kolabnow.com'); $this->deleteTestResource('resource-test@kolabnow.com'); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->discount()->dissociate(); $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete(); $wallet->save(); $user->status |= User::STATUS_IMAP_READY; $user->save(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestUser('deleted@kolab.org'); $this->deleteTestUser('deleted@kolabnow.com'); $this->deleteTestDomain('userscontroller.com'); $this->deleteTestGroup('group-test@kolabnow.com'); $this->deleteTestGroup('group-test@kolab.org'); $this->deleteTestSharedFolder('folder-test@kolabnow.com'); $this->deleteTestResource('resource-test@kolabnow.com'); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->discount()->dissociate(); $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete(); $wallet->save(); $user->settings()->whereIn('key', ['greylist_enabled'])->delete(); $user->status |= User::STATUS_IMAP_READY; $user->save(); parent::tearDown(); } /** * Test user deleting (DELETE /api/v4/users/) */ public function testDestroy(): void { // First create some users/accounts to delete $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $john = $this->getTestUser('john@kolab.org'); $user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user1->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user1); $user1->assignPackage($package_kolab, $user2); $user1->assignPackage($package_kolab, $user3); // Test unauth access $response = $this->delete("api/v4/users/{$user2->id}"); $response->assertStatus(401); // Test access to other user/account $response = $this->actingAs($john)->delete("api/v4/users/{$user2->id}"); $response->assertStatus(403); $response = $this->actingAs($john)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(403); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test that non-controller cannot remove himself $response = $this->actingAs($user3)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(403); // Test removing a non-controller user $response = $this->actingAs($user1)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('User deleted successfully.', $json['message']); // Test removing self (an account with users) $response = $this->actingAs($user1)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('User deleted successfully.', $json['message']); } /** * Test user deleting (DELETE /api/v4/users/) */ public function testDestroyByController(): void { // Create an account with additional controller - $user2 $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user1->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user1); $user1->assignPackage($package_kolab, $user2); $user1->assignPackage($package_kolab, $user3); $user1->wallets()->first()->addController($user2); // TODO/FIXME: // For now controller can delete himself, as well as // the whole account he has control to, including the owner // Probably he should not be able to do none of those // However, this is not 0-regression scenario as we // do not fully support additional controllers. //$response = $this->actingAs($user2)->delete("api/v4/users/{$user2->id}"); //$response->assertStatus(403); $response = $this->actingAs($user2)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(200); $response = $this->actingAs($user2)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(200); // Note: More detailed assertions in testDestroy() above $this->assertTrue($user1->fresh()->trashed()); $this->assertTrue($user2->fresh()->trashed()); $this->assertTrue($user3->fresh()->trashed()); } /** * Test user listing (GET /api/v4/users) */ public function testIndex(): void { // Test unauth access $response = $this->get("api/v4/users"); $response->assertStatus(401); $jack = $this->getTestUser('jack@kolab.org'); $joe = $this->getTestUser('joe@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($jack)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasMore']); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); $response = $this->actingAs($john)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasMore']); $this->assertSame(4, $json['count']); $this->assertCount(4, $json['list']); $this->assertSame($jack->email, $json['list'][0]['email']); $this->assertSame($joe->email, $json['list'][1]['email']); $this->assertSame($john->email, $json['list'][2]['email']); $this->assertSame($ned->email, $json['list'][3]['email']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json['list'][0]); $this->assertArrayHasKey('isDegraded', $json['list'][0]); $this->assertArrayHasKey('isAccountDegraded', $json['list'][0]); $this->assertArrayHasKey('isSuspended', $json['list'][0]); $this->assertArrayHasKey('isActive', $json['list'][0]); $this->assertArrayHasKey('isLdapReady', $json['list'][0]); $this->assertArrayHasKey('isImapReady', $json['list'][0]); $response = $this->actingAs($ned)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasMore']); $this->assertSame(4, $json['count']); $this->assertCount(4, $json['list']); $this->assertSame($jack->email, $json['list'][0]['email']); $this->assertSame($joe->email, $json['list'][1]['email']); $this->assertSame($john->email, $json['list'][2]['email']); $this->assertSame($ned->email, $json['list'][3]['email']); // Search by user email $response = $this->actingAs($john)->get("/api/v4/users?search=jack@k"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasMore']); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($jack->email, $json['list'][0]['email']); // Search by alias $response = $this->actingAs($john)->get("/api/v4/users?search=monster"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasMore']); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($joe->email, $json['list'][0]['email']); // Search by name $response = $this->actingAs($john)->get("/api/v4/users?search=land"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasMore']); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($ned->email, $json['list'][0]['email']); // TODO: Test paging } /** * Test fetching user data/profile (GET /api/v4/users/) */ public function testShow(): void { $userA = $this->getTestUser('UserEntitlement2A@UserEntitlement.com'); // Test getting profile of self $response = $this->actingAs($userA)->get("/api/v4/users/{$userA->id}"); $json = $response->json(); $response->assertStatus(200); $this->assertEquals($userA->id, $json['id']); $this->assertEquals($userA->email, $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue($json['config']['greylist_enabled']); $this->assertSame([], $json['skus']); $this->assertSame([], $json['aliases']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json); $this->assertArrayHasKey('isDegraded', $json); $this->assertArrayHasKey('isAccountDegraded', $json); $this->assertArrayHasKey('isSuspended', $json); $this->assertArrayHasKey('isActive', $json); $this->assertArrayHasKey('isLdapReady', $json); $this->assertArrayHasKey('isImapReady', $json); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); // Test unauthorized access to a profile of other user $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}"); $response->assertStatus(403); // Test authorized access to a profile of other user // Ned: Additional account controller $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(['john.doe@kolab.org'], $json['aliases']); $response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}"); $response->assertStatus(200); // John: Account owner $response = $this->actingAs($john)->get("/api/v4/users/{$jack->id}"); $response->assertStatus(200); $response = $this->actingAs($john)->get("/api/v4/users/{$ned->id}"); $response->assertStatus(200); $json = $response->json(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $groupware_sku = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $mailbox_sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $secondfactor_sku = Sku::withEnvTenantContext()->where('title', '2fa')->first(); $this->assertCount(5, $json['skus']); $this->assertSame(5, $json['skus'][$storage_sku->id]['count']); $this->assertSame([0,0,0,0,0], $json['skus'][$storage_sku->id]['costs']); $this->assertSame(1, $json['skus'][$groupware_sku->id]['count']); $this->assertSame([490], $json['skus'][$groupware_sku->id]['costs']); $this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']); $this->assertSame([500], $json['skus'][$mailbox_sku->id]['costs']); $this->assertSame(1, $json['skus'][$secondfactor_sku->id]['count']); $this->assertSame([0], $json['skus'][$secondfactor_sku->id]['costs']); $this->assertSame([], $json['aliases']); } /** * Test fetching user status (GET /api/v4/users//status) * and forcing setup process update (?refresh=1) * * @group imap * @group dns */ public function testStatus(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); // Test unauthorized access $response = $this->actingAs($jack)->get("/api/v4/users/{$john->id}/status"); $response->assertStatus(403); if ($john->isImapReady()) { $john->status ^= User::STATUS_IMAP_READY; $john->save(); } // Get user status $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse($json['isImapReady']); $this->assertFalse($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('user-imap-ready', $json['process'][2]['label']); $this->assertSame(false, $json['process'][2]['state']); $this->assertTrue(empty($json['status'])); $this->assertTrue(empty($json['message'])); // Make sure the domain is confirmed (other test might unset that status) $domain = $this->getTestDomain('kolab.org'); $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); // Now "reboot" the process and verify the user in imap synchronously $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertTrue($json['isImapReady']); $this->assertTrue($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('user-imap-ready', $json['process'][2]['label']); $this->assertSame(true, $json['process'][2]['state']); $this->assertSame('success', $json['status']); $this->assertSame('Setup process finished successfully.', $json['message']); Queue::size(1); // Test case for when the verify job is dispatched to the worker $john->refresh(); $john->status ^= User::STATUS_IMAP_READY; $john->save(); \config(['imap.admin_password' => null]); $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse($json['isImapReady']); $this->assertFalse($json['isReady']); $this->assertSame('success', $json['status']); $this->assertSame('waiting', $json['processState']); $this->assertSame('Setup process has been pushed. Please wait.', $json['message']); Queue::assertPushed(\App\Jobs\User\VerifyJob::class, 1); } /** * Test UsersController::statusInfo() */ public function testStatusInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user->created_at = Carbon::now(); $user->status = User::STATUS_NEW; $user->save(); $result = UsersController::statusInfo($user); $this->assertFalse($result['isReady']); $this->assertSame([], $result['skus']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(false, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(false, $result['process'][2]['state']); $this->assertSame('running', $result['processState']); $user->created_at = Carbon::now()->subSeconds(181); $user->save(); $result = UsersController::statusInfo($user); $this->assertSame('failed', $result['processState']); $user->status |= User::STATUS_LDAP_READY | User::STATUS_IMAP_READY; $user->save(); $result = UsersController::statusInfo($user); $this->assertTrue($result['isReady']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('done', $result['processState']); $domain->status |= Domain::STATUS_VERIFIED; $domain->type = Domain::TYPE_EXTERNAL; $domain->save(); $result = UsersController::statusInfo($user); $this->assertFalse($result['isReady']); $this->assertSame([], $result['skus']); $this->assertCount(7, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('domain-new', $result['process'][3]['label']); $this->assertSame(true, $result['process'][3]['state']); $this->assertSame('domain-ldap-ready', $result['process'][4]['label']); $this->assertSame(false, $result['process'][4]['state']); $this->assertSame('domain-verified', $result['process'][5]['label']); $this->assertSame(true, $result['process'][5]['state']); $this->assertSame('domain-confirmed', $result['process'][6]['label']); $this->assertSame(false, $result['process'][6]['state']); // Test 'skus' property $user->assignSku(Sku::withEnvTenantContext()->where('title', 'beta')->first()); $result = UsersController::statusInfo($user); $this->assertSame(['beta'], $result['skus']); $user->assignSku(Sku::withEnvTenantContext()->where('title', 'meet')->first()); $result = UsersController::statusInfo($user); $this->assertSame(['beta', 'meet'], $result['skus']); $user->assignSku(Sku::withEnvTenantContext()->where('title', 'meet')->first()); $result = UsersController::statusInfo($user); $this->assertSame(['beta', 'meet'], $result['skus']); } /** * Test user config update (POST /api/v4/users//config) */ public function testSetConfig(): void { $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $john->setSetting('greylist_enabled', null); $john->setSetting('password_policy', null); // Test unknown user id $post = ['greylist_enabled' => 1]; $response = $this->actingAs($john)->post("/api/v4/users/123/config", $post); $json = $response->json(); $response->assertStatus(404); // Test access by user not being a wallet controller $post = ['greylist_enabled' => 1]; $response = $this->actingAs($jack)->post("/api/v4/users/{$john->id}/config", $post); $json = $response->json(); $response->assertStatus(403); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['grey' => 1, 'password_policy' => 'min:1,max:255']; $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertCount(2, $json['errors']); $this->assertSame("The requested configuration parameter is not supported.", $json['errors']['grey']); $this->assertSame("Minimum password length cannot be less than 6.", $json['errors']['password_policy']); $this->assertNull($john->fresh()->getSetting('greylist_enabled')); // Test some valid data $post = ['greylist_enabled' => 1, 'password_policy' => 'min:10,max:255,upper,lower,digit,special']; $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame('User settings updated successfully.', $json['message']); $this->assertSame('true', $john->fresh()->getSetting('greylist_enabled')); $this->assertSame('min:10,max:255,upper,lower,digit,special', $john->fresh()->getSetting('password_policy')); // Test some valid data, acting as another account controller $ned = $this->getTestUser('ned@kolab.org'); $post = ['greylist_enabled' => 0, 'password_policy' => 'min:10,max:255,upper,last:1']; $response = $this->actingAs($ned)->post("/api/v4/users/{$john->id}/config", $post); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame('User settings updated successfully.', $json['message']); $this->assertSame('false', $john->fresh()->getSetting('greylist_enabled')); $this->assertSame('min:10,max:255,upper,last:1', $john->fresh()->getSetting('password_policy')); } /** * Test user creation (POST /api/v4/users) */ public function testStore(): void { Queue::fake(); $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $john->setSetting('password_policy', 'min:8,max:100,digit'); $deleted_priv = $this->getTestUser('deleted@kolab.org'); $deleted_priv->delete(); // Test empty request $response = $this->actingAs($john)->post("/api/v4/users", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The email field is required.", $json['errors']['email']); $this->assertSame("The password field is required.", $json['errors']['password'][0]); $this->assertCount(2, $json); // Test access by user not being a wallet controller $post = ['first_name' => 'Test']; $response = $this->actingAs($jack)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(403); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['password' => '12345678', 'email' => 'invalid']; $response = $this->actingAs($john)->post("/api/v4/users", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]); $this->assertSame('The specified email is invalid.', $json['errors']['email']); // Test existing user email $post = [ 'password' => 'simple123', 'password_confirmation' => 'simple123', 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'jack.daniels@kolab.org', ]; $response = $this->actingAs($john)->post("/api/v4/users", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The specified email is not available.', $json['errors']['email']); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $post = [ 'password' => 'simple123', 'password_confirmation' => 'simple123', 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'john2.doe2@kolab.org', 'organization' => 'TestOrg', 'aliases' => ['useralias1@kolab.org', 'deleted@kolab.org'], ]; // Missing package $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Package is required.", $json['errors']['package']); $this->assertCount(2, $json); // Invalid package $post['package'] = $package_domain->id; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Invalid package selected.", $json['errors']['package']); $this->assertCount(2, $json); // Test password policy checking $post['package'] = $package_kolab->id; $post['password'] = 'password'; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]); $this->assertSame("Specified password does not comply with the policy.", $json['errors']['password'][1]); $this->assertCount(2, $json); // Test password confirmation $post['password_confirmation'] = 'password'; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Specified password does not comply with the policy.", $json['errors']['password'][0]); $this->assertCount(2, $json); // Test full and valid data $post['password'] = 'password123'; $post['password_confirmation'] = 'password123'; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User created successfully.", $json['message']); $this->assertCount(2, $json); $user = User::where('email', 'john2.doe2@kolab.org')->first(); $this->assertInstanceOf(User::class, $user); $this->assertSame('John2', $user->getSetting('first_name')); $this->assertSame('Doe2', $user->getSetting('last_name')); $this->assertSame('TestOrg', $user->getSetting('organization')); + /** @var \App\UserAlias[] $aliases */ $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('deleted@kolab.org', $aliases[0]->alias); $this->assertSame('useralias1@kolab.org', $aliases[1]->alias); // Assert the new user entitlements $this->assertEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']); // Assert the wallet to which the new user should be assigned to $wallet = $user->wallet(); $this->assertSame($john->wallets->first()->id, $wallet->id); // Attempt to create a user previously deleted $user->delete(); $post['package'] = $package_kolab->id; $post['aliases'] = []; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User created successfully.", $json['message']); $this->assertCount(2, $json); $user = User::where('email', 'john2.doe2@kolab.org')->first(); $this->assertInstanceOf(User::class, $user); $this->assertSame('John2', $user->getSetting('first_name')); $this->assertSame('Doe2', $user->getSetting('last_name')); $this->assertSame('TestOrg', $user->getSetting('organization')); $this->assertCount(0, $user->aliases()->get()); $this->assertEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']); // Test password reset link "mode" $code = new \App\VerificationCode(['mode' => 'password-reset', 'active' => false]); $john->verificationcodes()->save($code); $post = [ 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'deleted@kolab.org', 'organization' => '', 'aliases' => [], 'passwordLinkCode' => $code->short_code . '-' . $code->code, 'package' => $package_kolab->id, ]; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User created successfully.", $json['message']); $this->assertCount(2, $json); $user = $this->getTestUser('deleted@kolab.org'); $code->refresh(); $this->assertSame($user->id, $code->user_id); $this->assertTrue($code->active); $this->assertTrue(is_string($user->password) && strlen($user->password) >= 60); // Test acting as account controller not owner, which is not yet supported $john->wallets->first()->addController($user); $response = $this->actingAs($user)->post("/api/v4/users", []); $response->assertStatus(403); } /** * Test user update (PUT /api/v4/users/) */ public function testUpdate(): void { $userA = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $userA->setSetting('password_policy', 'min:8,digit'); $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $domain = $this->getTestDomain( 'userscontroller.com', ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL] ); // Test unauthorized update of other user profile $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}", []); $response->assertStatus(403); // Test authorized update of account owner by account controller $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}", []); $response->assertStatus(200); // Test updating of self (empty request) $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertCount(3, $json); // Test some invalid data $post = ['password' => '1234567', 'currency' => 'invalid']; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]); $this->assertSame("Specified password does not comply with the policy.", $json['errors']['password'][1]); $this->assertSame("The currency must be 3 characters.", $json['errors']['currency'][0]); // Test full profile update including password $post = [ 'password' => 'simple123', 'password_confirmation' => 'simple123', 'first_name' => 'John2', 'last_name' => 'Doe2', 'organization' => 'TestOrg', 'phone' => '+123 123 123', 'external_email' => 'external@gmail.com', 'billing_address' => 'billing', 'country' => 'CH', 'currency' => 'CHF', 'aliases' => ['useralias1@' . \config('app.domain'), 'useralias2@' . \config('app.domain')] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertCount(3, $json); $this->assertTrue($userA->password != $userA->fresh()->password); unset($post['password'], $post['password_confirmation'], $post['aliases']); foreach ($post as $key => $value) { $this->assertSame($value, $userA->getSetting($key)); } $aliases = $userA->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@' . \config('app.domain'), $aliases[0]->alias); $this->assertSame('useralias2@' . \config('app.domain'), $aliases[1]->alias); // Test unsetting values $post = [ 'first_name' => '', 'last_name' => '', 'organization' => '', 'phone' => '', 'external_email' => '', 'billing_address' => '', 'country' => '', 'currency' => '', 'aliases' => ['useralias2@' . \config('app.domain')] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertCount(3, $json); unset($post['aliases']); foreach ($post as $key => $value) { $this->assertNull($userA->getSetting($key)); } $aliases = $userA->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias2@' . \config('app.domain'), $aliases[0]->alias); // Test error on some invalid aliases missing password confirmation $post = [ 'password' => 'simple123', 'aliases' => [ 'useralias2@' . \config('app.domain'), 'useralias1@kolab.org', '@kolab.org', ] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertCount(2, $json['errors']['aliases']); $this->assertSame("The specified domain is not available.", $json['errors']['aliases'][1]); $this->assertSame("The specified alias is invalid.", $json['errors']['aliases'][2]); $this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]); // Test authorized update of other user $response = $this->actingAs($ned)->put("/api/v4/users/{$jack->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertTrue(empty($json['statusInfo'])); // TODO: Test error on aliases with invalid/non-existing/other-user's domain // Create entitlements and additional user for following tests $owner = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $package_domain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $package_kolab = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_lite = Package::withEnvTenantContext()->where('title', 'lite')->first(); $sku_mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $sku_storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $sku_groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $domain = $this->getTestDomain( 'userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $domain->assignPackage($package_domain, $owner); $owner->assignPackage($package_kolab); $owner->assignPackage($package_lite, $user); // Non-controller cannot update his own entitlements $post = ['skus' => []]; $response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(422); // Test updating entitlements $post = [ 'skus' => [ $sku_mailbox->id => 1, $sku_storage->id => 6, $sku_groupware->id => 1, ], ]; $response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(200); $json = $response->json(); $storage_cost = $user->entitlements() ->where('sku_id', $sku_storage->id) ->orderBy('cost') ->pluck('cost')->all(); $this->assertEntitlements( $user, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage'] ); $this->assertSame([0, 0, 0, 0, 0, 25], $storage_cost); $this->assertTrue(empty($json['statusInfo'])); // Test password reset link "mode" $code = new \App\VerificationCode(['mode' => 'password-reset', 'active' => false]); $owner->verificationcodes()->save($code); $post = ['passwordLinkCode' => $code->short_code . '-' . $code->code]; $response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post); $json = $response->json(); $response->assertStatus(200); $code->refresh(); $this->assertSame($user->id, $code->user_id); $this->assertTrue($code->active); $this->assertSame($user->password, $user->fresh()->password); } /** * Test UsersController::updateEntitlements() */ public function testUpdateEntitlements(): void { $jane = $this->getTestUser('jane@kolabnow.com'); $kolab = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $activesync = Sku::withEnvTenantContext()->where('title', 'activesync')->first(); $groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); // standard package, 1 mailbox, 1 groupware, 2 storage $jane->assignPackage($kolab); // add 2 storage, 1 activesync $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 7, $activesync->id => 1 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); $this->assertEntitlements( $jane, [ 'activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // add 2 storage, remove 1 activesync $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 9, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // add mailbox $post = [ 'skus' => [ $mailbox->id => 2, $groupware->id => 1, $storage->id => 9, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(500); $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // remove mailbox $post = [ 'skus' => [ $mailbox->id => 0, $groupware->id => 1, $storage->id => 9, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(500); $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // less than free storage $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 1, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); } /** * Test user data response used in show and info actions */ public function testUserResponse(): void { $provider = \config('services.payment_provider') ?: 'mollie'; $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->setSettings(['mollie_id' => null, 'stripe_id' => null]); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); $this->assertEquals($user->id, $result['id']); $this->assertEquals($user->email, $result['email']); $this->assertEquals($user->status, $result['status']); $this->assertTrue(is_array($result['statusInfo'])); $this->assertTrue(is_array($result['settings'])); $this->assertSame('US', $result['settings']['country']); $this->assertSame('USD', $result['settings']['currency']); $this->assertTrue(is_array($result['accounts'])); $this->assertTrue(is_array($result['wallets'])); $this->assertCount(0, $result['accounts']); $this->assertCount(1, $result['wallets']); $this->assertSame($wallet->id, $result['wallet']['id']); $this->assertArrayNotHasKey('discount', $result['wallet']); $this->assertTrue($result['statusInfo']['enableDomains']); $this->assertTrue($result['statusInfo']['enableWallets']); $this->assertTrue($result['statusInfo']['enableUsers']); $this->assertTrue($result['statusInfo']['enableSettings']); // Ned is John's wallet controller $ned = $this->getTestUser('ned@kolab.org'); $ned_wallet = $ned->wallets()->first(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$ned]); $this->assertEquals($ned->id, $result['id']); $this->assertEquals($ned->email, $result['email']); $this->assertTrue(is_array($result['accounts'])); $this->assertTrue(is_array($result['wallets'])); $this->assertCount(1, $result['accounts']); $this->assertCount(1, $result['wallets']); $this->assertSame($wallet->id, $result['wallet']['id']); $this->assertSame($wallet->id, $result['accounts'][0]['id']); $this->assertSame($ned_wallet->id, $result['wallets'][0]['id']); $this->assertSame($provider, $result['wallet']['provider']); $this->assertSame($provider, $result['wallets'][0]['provider']); $this->assertTrue($result['statusInfo']['enableDomains']); $this->assertTrue($result['statusInfo']['enableWallets']); $this->assertTrue($result['statusInfo']['enableUsers']); $this->assertTrue($result['statusInfo']['enableSettings']); // Test discount in a response $discount = Discount::where('code', 'TEST')->first(); $wallet->discount()->associate($discount); $wallet->save(); $mod_provider = $provider == 'mollie' ? 'stripe' : 'mollie'; $wallet->setSetting($mod_provider . '_id', 123); $user->refresh(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); $this->assertEquals($user->id, $result['id']); $this->assertSame($discount->id, $result['wallet']['discount_id']); $this->assertSame($discount->discount, $result['wallet']['discount']); $this->assertSame($discount->description, $result['wallet']['discount_description']); $this->assertSame($mod_provider, $result['wallet']['provider']); $this->assertSame($discount->id, $result['wallets'][0]['discount_id']); $this->assertSame($discount->discount, $result['wallets'][0]['discount']); $this->assertSame($discount->description, $result['wallets'][0]['discount_description']); $this->assertSame($mod_provider, $result['wallets'][0]['provider']); // Jack is not a John's wallet controller $jack = $this->getTestUser('jack@kolab.org'); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$jack]); $this->assertFalse($result['statusInfo']['enableDomains']); $this->assertFalse($result['statusInfo']['enableWallets']); $this->assertFalse($result['statusInfo']['enableUsers']); $this->assertFalse($result['statusInfo']['enableSettings']); } /** * User email address validation. * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? */ public function testValidateEmail(): void { Queue::fake(); $public_domains = Domain::getPublicDomains(); $domain = reset($public_domains); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $folder = $this->getTestSharedFolder('folder-event@kolab.org'); $folder->setAliases(['folder-alias1@kolab.org']); $folder_del = $this->getTestSharedFolder('folder-test@kolabnow.com'); $folder_del->setAliases(['folder-alias2@kolabnow.com']); $folder_del->delete(); $pub_group = $this->getTestGroup('group-test@kolabnow.com'); $pub_group->delete(); $priv_group = $this->getTestGroup('group-test@kolab.org'); $resource = $this->getTestResource('resource-test@kolabnow.com'); $resource->delete(); $cases = [ // valid (user domain) ["admin@kolab.org", $john, null], // valid (public domain) ["test.test@$domain", $john, null], // Invalid format ["$domain", $john, 'The specified email is invalid.'], [".@$domain", $john, 'The specified email is invalid.'], ["test123456@localhost", $john, 'The specified domain is invalid.'], ["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'], ["$domain", $john, 'The specified email is invalid.'], [".@$domain", $john, 'The specified email is invalid.'], // forbidden local part on public domains ["admin@$domain", $john, 'The specified email is not available.'], ["administrator@$domain", $john, 'The specified email is not available.'], // forbidden (other user's domain) ["testtest@kolab.org", $user, 'The specified domain is not available.'], // existing alias of other user ["jack.daniels@kolab.org", $john, 'The specified email is not available.'], // An existing shared folder or folder alias ["folder-event@kolab.org", $john, 'The specified email is not available.'], ["folder-alias1@kolab.org", $john, 'The specified email is not available.'], // A soft-deleted shared folder or folder alias ["folder-test@kolabnow.com", $john, 'The specified email is not available.'], ["folder-alias2@kolabnow.com", $john, 'The specified email is not available.'], // A group ["group-test@kolab.org", $john, 'The specified email is not available.'], // A soft-deleted group ["group-test@kolabnow.com", $john, 'The specified email is not available.'], // A resource ["resource-test1@kolab.org", $john, 'The specified email is not available.'], // A soft-deleted resource ["resource-test@kolabnow.com", $john, 'The specified email is not available.'], ]; foreach ($cases as $idx => $case) { list($email, $user, $expected) = $case; $deleted = null; $result = UsersController::validateEmail($email, $user, $deleted); $this->assertSame($expected, $result, "Case {$email}"); $this->assertNull($deleted, "Case {$email}"); } } /** * User email validation - tests for $deleted argument * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? */ public function testValidateEmailDeleted(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $deleted_priv = $this->getTestUser('deleted@kolab.org'); $deleted_priv->delete(); $deleted_pub = $this->getTestUser('deleted@kolabnow.com'); $deleted_pub->delete(); $result = UsersController::validateEmail('deleted@kolab.org', $john, $deleted); $this->assertSame(null, $result); $this->assertSame($deleted_priv->id, $deleted->id); $result = UsersController::validateEmail('deleted@kolabnow.com', $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertSame(null, $deleted); $result = UsersController::validateEmail('jack@kolab.org', $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertSame(null, $deleted); $pub_group = $this->getTestGroup('group-test@kolabnow.com'); $priv_group = $this->getTestGroup('group-test@kolab.org'); // A group in a public domain, existing $result = UsersController::validateEmail($pub_group->email, $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertNull($deleted); $pub_group->delete(); // A group in a public domain, deleted $result = UsersController::validateEmail($pub_group->email, $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertNull($deleted); // A group in a private domain, existing $result = UsersController::validateEmail($priv_group->email, $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertNull($deleted); $priv_group->delete(); // A group in a private domain, deleted $result = UsersController::validateEmail($priv_group->email, $john, $deleted); $this->assertSame(null, $result); $this->assertSame($priv_group->id, $deleted->id); // TODO: Test the same with a resource and shared folder } /** * User email alias validation. * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? */ public function testValidateAlias(): void { Queue::fake(); $public_domains = Domain::getPublicDomains(); $domain = reset($public_domains); $john = $this->getTestUser('john@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $deleted_priv = $this->getTestUser('deleted@kolab.org'); $deleted_priv->setAliases(['deleted-alias@kolab.org']); $deleted_priv->delete(); $deleted_pub = $this->getTestUser('deleted@kolabnow.com'); $deleted_pub->setAliases(['deleted-alias@kolabnow.com']); $deleted_pub->delete(); $folder = $this->getTestSharedFolder('folder-event@kolab.org'); $folder->setAliases(['folder-alias1@kolab.org']); $folder_del = $this->getTestSharedFolder('folder-test@kolabnow.com'); $folder_del->setAliases(['folder-alias2@kolabnow.com']); $folder_del->delete(); $group_priv = $this->getTestGroup('group-test@kolab.org'); $group = $this->getTestGroup('group-test@kolabnow.com'); $group->delete(); $resource = $this->getTestResource('resource-test@kolabnow.com'); $resource->delete(); $cases = [ // Invalid format ["$domain", $john, 'The specified alias is invalid.'], [".@$domain", $john, 'The specified alias is invalid.'], ["test123456@localhost", $john, 'The specified domain is invalid.'], ["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'], ["$domain", $john, 'The specified alias is invalid.'], [".@$domain", $john, 'The specified alias is invalid.'], // forbidden local part on public domains ["admin@$domain", $john, 'The specified alias is not available.'], ["administrator@$domain", $john, 'The specified alias is not available.'], // forbidden (other user's domain) ["testtest@kolab.org", $user, 'The specified domain is not available.'], // existing alias of other user, to be an alias, user in the same group account ["jack.daniels@kolab.org", $john, null], // existing user ["jack@kolab.org", $john, 'The specified alias is not available.'], // valid (user domain) ["admin@kolab.org", $john, null], // valid (public domain) ["test.test@$domain", $john, null], // An alias that was a user email before is allowed, but only for custom domains ["deleted@kolab.org", $john, null], ["deleted-alias@kolab.org", $john, null], ["deleted@kolabnow.com", $john, 'The specified alias is not available.'], ["deleted-alias@kolabnow.com", $john, 'The specified alias is not available.'], // An existing shared folder or folder alias ["folder-event@kolab.org", $john, 'The specified alias is not available.'], ["folder-alias1@kolab.org", $john, null], // A soft-deleted shared folder or folder alias ["folder-test@kolabnow.com", $john, 'The specified alias is not available.'], ["folder-alias2@kolabnow.com", $john, 'The specified alias is not available.'], // A group with the same email address exists ["group-test@kolab.org", $john, 'The specified alias is not available.'], // A soft-deleted group ["group-test@kolabnow.com", $john, 'The specified alias is not available.'], // A resource ["resource-test1@kolab.org", $john, 'The specified alias is not available.'], // A soft-deleted resource ["resource-test@kolabnow.com", $john, 'The specified alias is not available.'], ]; foreach ($cases as $idx => $case) { list($alias, $user, $expected) = $case; $result = UsersController::validateAlias($alias, $user); $this->assertSame($expected, $result, "Case {$alias}"); } } } diff --git a/src/tests/Feature/Jobs/PasswordResetEmailTest.php b/src/tests/Feature/Jobs/PasswordResetEmailTest.php index 7f623775..829d56cc 100644 --- a/src/tests/Feature/Jobs/PasswordResetEmailTest.php +++ b/src/tests/Feature/Jobs/PasswordResetEmailTest.php @@ -1,77 +1,75 @@ deleteTestUser('PasswordReset@UserAccount.com'); } /** * {@inheritDoc} * * @return void */ public function tearDown(): void { $this->deleteTestUser('PasswordReset@UserAccount.com'); parent::tearDown(); } /** * Test job handle * * @return void */ public function testPasswordResetEmailHandle() { $code = new VerificationCode([ 'mode' => 'password-reset', ]); $user = $this->getTestUser('PasswordReset@UserAccount.com'); $user->verificationcodes()->save($code); $user->setSettings(['external_email' => 'etx@email.com']); Mail::fake(); // Assert that no jobs were pushed... Mail::assertNothingSent(); $job = new PasswordResetEmail($code); $job->handle(); // Assert the email sending job was pushed once Mail::assertSent(PasswordReset::class, 1); // Assert the mail was sent to the code's email Mail::assertSent(PasswordReset::class, function ($mail) use ($code) { return $mail->hasTo($code->user->getSetting('external_email')); }); // Assert sender Mail::assertSent(PasswordReset::class, function ($mail) { return $mail->hasFrom(\config('mail.from.address'), \config('mail.from.name')) && $mail->hasReplyTo(\config('mail.reply_to.address'), \config('mail.reply_to.name')); }); } } diff --git a/src/tests/Feature/SkuTest.php b/src/tests/Feature/SkuTest.php index 87635aa3..e9fad72c 100644 --- a/src/tests/Feature/SkuTest.php +++ b/src/tests/Feature/SkuTest.php @@ -1,104 +1,109 @@ deleteTestUser('jane@kolabnow.com'); } + /** + * {@inheritDoc} + */ public function tearDown(): void { $this->deleteTestUser('jane@kolabnow.com'); parent::tearDown(); } public function testPackageEntitlements(): void { $user = $this->getTestUser('jane@kolabnow.com'); $wallet = $user->wallets()->first(); $package = Package::withEnvTenantContext()->where('title', 'lite')->first(); $user = $user->assignPackage($package); $this->backdateEntitlements($user->fresh()->entitlements, Carbon::now()->subMonthsWithoutOverflow(1)); $wallet->chargeEntitlements(); $this->assertTrue($wallet->balance < 0); } public function testSkuEntitlements(): void { $this->assertCount(5, Sku::withEnvTenantContext()->where('title', 'mailbox')->first()->entitlements); } public function testSkuPackages(): void { $this->assertCount(2, Sku::withEnvTenantContext()->where('title', 'mailbox')->first()->packages); } public function testSkuHandlerDomainHosting(): void { $sku = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $entitlement = $sku->entitlements->first(); $this->assertSame( Handlers\DomainHosting::entitleableClass(), $entitlement->entitleable_type ); } public function testSkuHandlerMailbox(): void { $sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $entitlement = $sku->entitlements->first(); $this->assertSame( Handlers\Mailbox::entitleableClass(), $entitlement->entitleable_type ); } public function testSkuHandlerStorage(): void { $sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $entitlement = $sku->entitlements->first(); $this->assertSame( Handlers\Storage::entitleableClass(), $entitlement->entitleable_type ); } public function testSkuTenant(): void { $sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $tenant = $sku->tenant()->first(); $this->assertInstanceof(\App\Tenant::class, $tenant); $tenant = $sku->tenant; $this->assertInstanceof(\App\Tenant::class, $tenant); } } diff --git a/src/tests/Feature/Stories/GreylistTest.php b/src/tests/Feature/Stories/GreylistTest.php index 4926d669..07b050a6 100644 --- a/src/tests/Feature/Stories/GreylistTest.php +++ b/src/tests/Feature/Stories/GreylistTest.php @@ -1,433 +1,432 @@ setUpTest(); $this->useServicesUrl(); $this->clientAddress = '212.103.80.148'; $this->net = \App\IP4Net::getNet($this->clientAddress); DB::delete("DELETE FROM greylist_connect WHERE sender_domain = 'sender.domain';"); DB::delete("DELETE FROM greylist_whitelist WHERE sender_domain = 'sender.domain';"); } public function tearDown(): void { DB::delete("DELETE FROM greylist_connect WHERE sender_domain = 'sender.domain';"); DB::delete("DELETE FROM greylist_whitelist WHERE sender_domain = 'sender.domain';"); parent::tearDown(); } public function testWithTimestamp() { $request = new Greylist\Request( [ 'sender' => 'someone@sender.domain', 'recipient' => $this->domainOwner->email, 'client_address' => $this->clientAddress, 'client_name' => 'some.mx', 'timestamp' => \Carbon\Carbon::now()->subDays(7)->toString() ] ); $timestamp = $this->getObjectProperty($request, 'timestamp'); $this->assertTrue( \Carbon\Carbon::parse($timestamp, 'UTC') < \Carbon\Carbon::now() ); } public function testNoNet() { $request = new Greylist\Request( [ 'sender' => 'someone@sender.domain', 'recipient' => $this->domainOwner->email, 'client_address' => '127.128.129.130', 'client_name' => 'some.mx' ] ); $this->assertTrue($request->shouldDefer()); } public function testIp6Net() { $request = new Greylist\Request( [ 'sender' => 'someone@sender.domain', 'recipient' => $this->domainOwner->email, 'client_address' => '2a00:1450:400a:803::2005', 'client_name' => 'some.mx' ] ); $this->assertTrue($request->shouldDefer()); } // public function testMultiRecipientThroughAlias() {} public function testWhitelistNew() { $whitelist = Greylist\Whitelist::where('sender_domain', 'sender.domain')->first(); $this->assertNull($whitelist); for ($i = 0; $i < 5; $i++) { $request = new Greylist\Request( [ 'sender' => "someone{$i}@sender.domain", 'recipient' => $this->domainOwner->email, 'client_address' => $this->clientAddress, 'client_name' => 'some.mx', 'timestamp' => \Carbon\Carbon::now()->subDays(1) ] ); $this->assertTrue($request->shouldDefer()); } $whitelist = Greylist\Whitelist::where('sender_domain', 'sender.domain')->first(); $this->assertNotNull($whitelist); $request = new Greylist\Request( [ 'sender' => "someone5@sender.domain", 'recipient' => $this->domainOwner->email, 'client_address' => $this->clientAddress, 'client_name' => 'some.mx', 'timestamp' => \Carbon\Carbon::now()->subDays(1) ] ); $this->assertFalse($request->shouldDefer()); } // public function testWhitelistedHit() {} public function testWhitelistStale() { $whitelist = Greylist\Whitelist::where('sender_domain', 'sender.domain')->first(); $this->assertNull($whitelist); for ($i = 0; $i < 5; $i++) { $request = new Greylist\Request( [ 'sender' => "someone{$i}@sender.domain", 'recipient' => $this->domainOwner->email, 'client_address' => $this->clientAddress, 'client_name' => 'some.mx', 'timestamp' => \Carbon\Carbon::now()->subDays(1) ] ); $this->assertTrue($request->shouldDefer()); } $whitelist = Greylist\Whitelist::where('sender_domain', 'sender.domain')->first(); $this->assertNotNull($whitelist); $request = new Greylist\Request( [ 'sender' => "someone5@sender.domain", 'recipient' => $this->domainOwner->email, 'client_address' => $this->clientAddress, 'client_name' => 'some.mx', 'timestamp' => \Carbon\Carbon::now()->subDays(1) ] ); $this->assertFalse($request->shouldDefer()); $whitelist->updated_at = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2); $whitelist->save(['timestamps' => false]); $this->assertTrue($request->shouldDefer()); } // public function testWhitelistUpdate() {} public function testRetry() { $connect = Greylist\Connect::create( [ 'sender_local' => 'someone', 'sender_domain' => 'sender.domain', 'recipient_hash' => hash('sha256', $this->domainOwner->email), 'recipient_id' => $this->domainOwner->id, 'recipient_type' => \App\User::class, 'connect_count' => 1, 'net_id' => $this->net->id, 'net_type' => \App\IP4Net::class ] ); $connect->created_at = \Carbon\Carbon::now()->subMinutes(6); $connect->save(); $request = new Greylist\Request( [ 'sender' => 'someone@sender.domain', 'recipient' => $this->domainOwner->email, 'client_address' => $this->clientAddress ] ); $this->assertFalse($request->shouldDefer()); } public function testInvalidRecipient() { $connect = Greylist\Connect::create( [ 'sender_local' => 'someone', 'sender_domain' => 'sender.domain', 'recipient_hash' => hash('sha256', $this->domainOwner->email), 'recipient_id' => 1234, 'recipient_type' => \App\Domain::class, 'connect_count' => 1, 'net_id' => $this->net->id, 'net_type' => \App\IP4Net::class ] ); $request = new Greylist\Request( [ 'sender' => 'someone@sender.domain', 'recipient' => 'not.someone@that.exists', 'client_address' => $this->clientAddress ] ); $this->assertTrue($request->shouldDefer()); } public function testUserDisabled() { $connect = Greylist\Connect::create( [ 'sender_local' => 'someone', 'sender_domain' => 'sender.domain', 'recipient_hash' => hash('sha256', $this->domainOwner->email), 'recipient_id' => $this->domainOwner->id, 'recipient_type' => \App\User::class, 'connect_count' => 1, 'net_id' => $this->net->id, 'net_type' => \App\IP4Net::class ] ); $this->domainOwner->setSetting('greylist_enabled', 'false'); $request = new Greylist\Request( [ 'sender' => 'someone@sender.domain', 'recipient' => $this->domainOwner->email, 'client_address' => $this->clientAddress ] ); $this->assertFalse($request->shouldDefer()); // Ensure we also find the setting by alias $aliases = $this->domainOwner->aliases()->orderBy('alias')->pluck('alias')->all(); $request = new Greylist\Request( [ 'sender' => 'someone@sender.domain', 'recipient' => $aliases[0], 'client_address' => $this->clientAddress ] ); $this->assertFalse($request->shouldDefer()); } public function testUserEnabled() { $connect = Greylist\Connect::create( [ 'sender_local' => 'someone', 'sender_domain' => 'sender.domain', 'recipient_hash' => hash('sha256', $this->domainOwner->email), 'recipient_id' => $this->domainOwner->id, 'recipient_type' => \App\User::class, 'connect_count' => 1, 'net_id' => $this->net->id, 'net_type' => \App\IP4Net::class ] ); $this->domainOwner->setSetting('greylist_enabled', 'true'); $request = new Greylist\Request( [ 'sender' => 'someone@sender.domain', 'recipient' => $this->domainOwner->email, 'client_address' => $this->clientAddress ] ); $this->assertTrue($request->shouldDefer()); $connect->created_at = \Carbon\Carbon::now()->subMinutes(6); $connect->save(); $this->assertFalse($request->shouldDefer()); } public function testMultipleUsersAllDisabled() { $request = new Greylist\Request( [ 'sender' => 'someone@sender.domain', 'recipient' => $this->domainOwner->email, 'client_address' => $this->clientAddress ] ); foreach ($this->domainUsers as $user) { Greylist\Connect::create( [ 'sender_local' => 'someone', 'sender_domain' => 'sender.domain', 'recipient_hash' => hash('sha256', $user->email), 'recipient_id' => $user->id, 'recipient_type' => \App\User::class, 'connect_count' => 1, 'net_id' => $this->net->id, 'net_type' => \App\IP4Net::class ] ); $user->setSetting('greylist_enabled', 'false'); if ($user->email == $this->domainOwner->email) { continue; } $request = new Greylist\Request( [ 'sender' => 'someone@sender.domain', 'recipient' => $user->email, 'client_address' => $this->clientAddress ] ); $this->assertFalse($request->shouldDefer()); } } public function testMultipleUsersAnyEnabled() { $request = new Greylist\Request( [ 'sender' => 'someone@sender.domain', 'recipient' => $this->domainOwner->email, 'client_address' => $this->clientAddress ] ); foreach ($this->domainUsers as $user) { Greylist\Connect::create( [ 'sender_local' => 'someone', 'sender_domain' => 'sender.domain', 'recipient_hash' => hash('sha256', $user->email), 'recipient_id' => $user->id, 'recipient_type' => \App\User::class, 'connect_count' => 1, 'net_id' => $this->net->id, 'net_type' => \App\IP4Net::class ] ); $user->setSetting('greylist_enabled', ($user->id == $this->jack->id) ? 'true' : 'false'); if ($user->email == $this->domainOwner->email) { continue; } $request = new Greylist\Request( [ 'sender' => 'someone@sender.domain', 'recipient' => $user->email, 'client_address' => $this->clientAddress ] ); if ($user->id == $this->jack->id) { $this->assertTrue($request->shouldDefer()); } else { $this->assertFalse($request->shouldDefer()); } } } public function testControllerNew() { $data = [ 'sender' => 'someone@sender.domain', 'recipient' => $this->domainOwner->email, 'client_address' => $this->clientAddress, 'client_name' => 'some.mx' ]; $response = $this->post('/api/webhooks/policy/greylist', $data); $response->assertStatus(403); } public function testControllerNotNew() { $connect = Greylist\Connect::create( [ 'sender_local' => 'someone', 'sender_domain' => 'sender.domain', 'recipient_hash' => hash('sha256', $this->domainOwner->email), 'recipient_id' => $this->domainOwner->id, 'recipient_type' => \App\User::class, 'connect_count' => 1, 'net_id' => $this->net->id, 'net_type' => \App\IP4Net::class ] ); $connect->created_at = \Carbon\Carbon::now()->subMinutes(6); $connect->save(); $data = [ 'sender' => 'someone@sender.domain', 'recipient' => $this->domainOwner->email, 'client_address' => $this->clientAddress, 'client_name' => 'some.mx' ]; $response = $this->post('/api/webhooks/policy/greylist', $data); $response->assertStatus(200); } } diff --git a/src/tests/Feature/TenantTest.php b/src/tests/Feature/TenantTest.php index 56f97556..a5ee62c6 100644 --- a/src/tests/Feature/TenantTest.php +++ b/src/tests/Feature/TenantTest.php @@ -1,65 +1,64 @@ assertSame(\config('app.name'), Tenant::getConfig(null, 'app.name')); $this->assertSame(\config('app.env'), Tenant::getConfig(null, 'app.env')); $this->assertSame(null, Tenant::getConfig(null, 'app.unknown')); $tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first(); $tenant->setSetting('app.test', 'test'); // Tenant specified $this->assertSame($tenant->title, Tenant::getConfig($tenant->id, 'app.name')); $this->assertSame('test', Tenant::getConfig($tenant->id, 'app.test')); $this->assertSame(\config('app.env'), Tenant::getConfig($tenant->id, 'app.env')); $this->assertSame(null, Tenant::getConfig($tenant->id, 'app.unknown')); } /** * Test Tenant::wallet() method */ public function testWallet(): void { $tenant = Tenant::find(\config('app.tenant_id')); $user = \App\User::where('email', 'reseller@' . \config('app.domain'))->first(); $wallet = $tenant->wallet(); $this->assertInstanceof(\App\Wallet::class, $wallet); $this->assertSame($user->wallets->first()->id, $wallet->id); } } diff --git a/src/tests/MailInterceptTrait.php b/src/tests/MailInterceptTrait.php deleted file mode 100644 index 8782e188..00000000 --- a/src/tests/MailInterceptTrait.php +++ /dev/null @@ -1,79 +0,0 @@ -interceptMail(); - - Mail::send($mail); - - $message = $this->interceptedMail()->last(); - - // SwiftMailer does not have methods to get the bodies, we'll parse the message - list($plain, $html) = $this->extractMailBody($message->toString()); - - return [ - 'plain' => $plain, - 'html' => $html, - 'message' => $message, - ]; - } - - /** - * Simple message parser to extract plain and html body - * - * @param string $message Email message as string - * - * @return array Plain text and HTML body - */ - protected function extractMailBody(string $message): array - { - // Note that we're not supporting every message format, we only - // support what Laravel/SwiftMailer produces - // TODO: It may stop working if we start using attachments - $plain = ''; - $html = ''; - - if (preg_match('/[\s\t]boundary="([^"]+)"/', $message, $matches)) { - // multipart message assume plain and html parts - $split = preg_split('/--' . preg_quote($matches[1]) . '/', $message); - - list($plain_head, $plain) = explode("\r\n\r\n", $split[1], 2); - list($html_head, $html) = explode("\r\n\r\n", $split[2], 2); - - if (strpos($plain_head, 'Content-Transfer-Encoding: quoted-printable') !== false) { - $plain = quoted_printable_decode($plain); - } - - if (strpos($html_head, 'Content-Transfer-Encoding: quoted-printable') !== false) { - $html = quoted_printable_decode($html); - } - } else { - list($header, $html) = explode("\r\n\r\n", $message, 2); - if (strpos($header, 'Content-Transfer-Encoding: quoted-printable') !== false) { - $html = quoted_printable_decode($html); - } - } - - return [$plain, $html]; - } -} diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php index 563ef002..71fbb2d2 100644 --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -1,606 +1,629 @@ 'John', 'last_name' => 'Doe', 'organization' => 'Test Domain Owner', ]; /** * Some users for the hosted domain, ultimately including the owner. * * @var \App\User[] */ protected $domainUsers = []; /** * A specific user that is a regular user in the hosted domain. * * @var ?\App\User */ protected $jack; /** * A specific user that is a controller on the wallet to which the hosted domain is charged. * * @var ?\App\User */ protected $jane; /** * A specific user that has a second factor configured. * * @var ?\App\User */ protected $joe; /** * One of the domains that is available for public registration. * * @var ?\App\Domain */ protected $publicDomain; /** * A newly generated user in a public domain. * * @var ?\App\User */ protected $publicDomainUser; /** * A placeholder for a password that can be generated. * * Should be generated with `\App\Utils::generatePassphrase()`. * * @var ?string */ protected $userPassword; /** * Register the beta entitlement for a user */ protected function addBetaEntitlement($user, $titles = []): void { // Add beta + $title entitlements $beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $user->assignSku($beta_sku); if (!empty($titles)) { Sku::withEnvTenantContext()->whereIn('title', (array) $titles)->get() ->each(function ($sku) use ($user) { $user->assignSku($sku); }); } } /** * Assert that the entitlements for the user match the expected list of entitlements. * * @param \App\User|\App\Domain $object The object for which the entitlements need to be pulled. * @param array $expected An array of expected \App\Sku titles. */ protected function assertEntitlements($object, $expected) { // Assert the user entitlements $skus = $object->entitlements()->get() ->map(function ($ent) { return $ent->sku->title; }) ->toArray(); sort($skus); Assert::assertSame($expected, $skus); } protected function backdateEntitlements($entitlements, $targetDate, $targetCreatedDate = null) { $wallets = []; $ids = []; foreach ($entitlements as $entitlement) { $ids[] = $entitlement->id; $wallets[] = $entitlement->wallet_id; } \App\Entitlement::whereIn('id', $ids)->update([ 'created_at' => $targetCreatedDate ?: $targetDate, 'updated_at' => $targetDate, ]); if (!empty($wallets)) { $wallets = array_unique($wallets); $owners = \App\Wallet::whereIn('id', $wallets)->pluck('user_id')->all(); \App\User::whereIn('id', $owners)->update([ 'created_at' => $targetCreatedDate ?: $targetDate ]); } } /** * Removes all beta entitlements from the database */ protected function clearBetaEntitlements(): void { $beta_handlers = [ 'App\Handlers\Beta', 'App\Handlers\Beta\Distlists', 'App\Handlers\Beta\Resources', 'App\Handlers\Beta\SharedFolders', ]; $betas = Sku::whereIn('handler_class', $beta_handlers)->pluck('id')->all(); \App\Entitlement::whereIn('sku_id', $betas)->delete(); } /** * Creates the application. * * @return \Illuminate\Foundation\Application */ public function createApplication() { $app = require __DIR__ . '/../bootstrap/app.php'; $app->make(Kernel::class)->bootstrap(); return $app; } /** * Create a set of transaction log entries for a wallet */ protected function createTestTransactions($wallet) { $result = []; $date = Carbon::now(); $debit = 0; $entitlementTransactions = []; foreach ($wallet->entitlements as $entitlement) { if ($entitlement->cost) { $debit += $entitlement->cost; $entitlementTransactions[] = $entitlement->createTransaction( Transaction::ENTITLEMENT_BILLED, $entitlement->cost ); } } $transaction = Transaction::create( [ 'user_email' => 'jeroen@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_DEBIT, 'amount' => $debit * -1, 'description' => 'Payment', ] ); $result[] = $transaction; Transaction::whereIn('id', $entitlementTransactions)->update(['transaction_id' => $transaction->id]); $transaction = Transaction::create( [ 'user_email' => null, 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_CREDIT, 'amount' => 2000, 'description' => 'Payment', ] ); $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); $result[] = $transaction; $types = [ Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY, ]; // The page size is 10, so we generate so many to have at least two pages $loops = 10; while ($loops-- > 0) { $type = $types[count($result) % count($types)]; $transaction = Transaction::create([ 'user_email' => 'jeroen.@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => $type, 'amount' => 11 * (count($result) + 1) * ($type == Transaction::WALLET_PENALTY ? -1 : 1), 'description' => 'TRANS' . $loops, ]); $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); $result[] = $transaction; } return $result; } /** * Delete a test domain whatever it takes. * * @coversNothing */ protected function deleteTestDomain($name) { Queue::fake(); $domain = Domain::withTrashed()->where('namespace', $name)->first(); if (!$domain) { return; } $job = new \App\Jobs\Domain\DeleteJob($domain->id); $job->handle(); $domain->forceDelete(); } /** * Delete a test group whatever it takes. * * @coversNothing */ protected function deleteTestGroup($email) { Queue::fake(); $group = Group::withTrashed()->where('email', $email)->first(); if (!$group) { return; } LDAP::deleteGroup($group); $group->forceDelete(); } /** * Delete a test resource whatever it takes. * * @coversNothing */ protected function deleteTestResource($email) { Queue::fake(); $resource = Resource::withTrashed()->where('email', $email)->first(); if (!$resource) { return; } LDAP::deleteResource($resource); $resource->forceDelete(); } /** * Delete a test shared folder whatever it takes. * * @coversNothing */ protected function deleteTestSharedFolder($email) { Queue::fake(); $folder = SharedFolder::withTrashed()->where('email', $email)->first(); if (!$folder) { return; } LDAP::deleteSharedFolder($folder); $folder->forceDelete(); } /** * Delete a test user whatever it takes. * * @coversNothing */ protected function deleteTestUser($email) { Queue::fake(); $user = User::withTrashed()->where('email', $email)->first(); if (!$user) { return; } LDAP::deleteUser($user); $user->forceDelete(); } /** * Helper to access protected property of an object */ protected static function getObjectProperty($object, $property_name) { $reflection = new \ReflectionClass($object); $property = $reflection->getProperty($property_name); $property->setAccessible(true); return $property->getValue($object); } /** * Get Domain object by namespace, create it if needed. * Skip LDAP jobs. * * @coversNothing */ protected function getTestDomain($name, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Domain::firstOrCreate(['namespace' => $name], $attrib); } /** * Get Group object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestGroup($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Group::firstOrCreate(['email' => $email], $attrib); } /** * Get Resource object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestResource($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $resource = Resource::where('email', $email)->first(); if (!$resource) { list($local, $domain) = explode('@', $email, 2); $resource = new Resource(); $resource->email = $email; $resource->domainName = $domain; if (!isset($attrib['name'])) { $resource->name = $local; } } foreach ($attrib as $key => $val) { $resource->{$key} = $val; } $resource->save(); return $resource; } /** * Get SharedFolder object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestSharedFolder($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $folder = SharedFolder::where('email', $email)->first(); if (!$folder) { list($local, $domain) = explode('@', $email, 2); $folder = new SharedFolder(); $folder->email = $email; $folder->domainName = $domain; if (!isset($attrib['name'])) { $folder->name = $local; } } foreach ($attrib as $key => $val) { $folder->{$key} = $val; } $folder->save(); return $folder; } /** * Get User object by email, create it if needed. * Skip LDAP jobs. * * @coversNothing */ protected function getTestUser($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $user = User::firstOrCreate(['email' => $email], $attrib); if ($user->trashed()) { // Note: we do not want to use user restore here User::where('id', $user->id)->forceDelete(); $user = User::create(['email' => $email] + $attrib); } return $user; } /** * Call protected/private method of a class. * * @param object $object Instantiated object that we will run method on. * @param string $methodName Method name to call * @param array $parameters Array of parameters to pass into method. * * @return mixed Method return. */ - protected function invokeMethod($object, $methodName, array $parameters = array()) + protected function invokeMethod($object, $methodName, array $parameters = []) { $reflection = new \ReflectionClass(get_class($object)); $method = $reflection->getMethod($methodName); $method->setAccessible(true); return $method->invokeArgs($object, $parameters); } + /** + * Extract content of an email message. + * + * @param \Illuminate\Mail\Mailable $mail Mailable object + * + * @return array Parsed message data: + * - 'plain': Plain text body + * - 'html: HTML body + * - 'subject': Mail subject + */ + protected function renderMail(\Illuminate\Mail\Mailable $mail): array + { + $mail->build(); // @phpstan-ignore-line + + $result = $this->invokeMethod($mail, 'renderForAssertions'); + + return [ + 'plain' => $result[1], + 'html' => $result[0], + 'subject' => $mail->subject, + ]; + } + protected function setUpTest() { $this->userPassword = \App\Utils::generatePassphrase(); $this->domainHosted = $this->getTestDomain( 'test.domain', [ 'type' => \App\Domain::TYPE_EXTERNAL, 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED ] ); $this->getTestDomain( 'test2.domain2', [ 'type' => \App\Domain::TYPE_EXTERNAL, 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED ] ); $packageKolab = \App\Package::where('title', 'kolab')->first(); $this->domainOwner = $this->getTestUser('john@test.domain', ['password' => $this->userPassword]); $this->domainOwner->assignPackage($packageKolab); $this->domainOwner->setSettings($this->domainOwnerSettings); $this->domainOwner->setAliases(['alias1@test2.domain2']); // separate for regular user $this->jack = $this->getTestUser('jack@test.domain', ['password' => $this->userPassword]); // separate for wallet controller $this->jane = $this->getTestUser('jane@test.domain', ['password' => $this->userPassword]); $this->joe = $this->getTestUser('joe@test.domain', ['password' => $this->userPassword]); $this->domainUsers[] = $this->jack; $this->domainUsers[] = $this->jane; $this->domainUsers[] = $this->joe; $this->domainUsers[] = $this->getTestUser('jill@test.domain', ['password' => $this->userPassword]); foreach ($this->domainUsers as $user) { $this->domainOwner->assignPackage($packageKolab, $user); } $this->domainUsers[] = $this->domainOwner; // assign second factor to joe $this->joe->assignSku(Sku::where('title', '2fa')->first()); \App\Auth\SecondFactor::seed($this->joe->email); usort( $this->domainUsers, function ($a, $b) { return $a->email > $b->email; } ); $this->domainHosted->assignPackage( \App\Package::where('title', 'domain-hosting')->first(), $this->domainOwner ); $wallet = $this->domainOwner->wallets()->first(); $wallet->addController($this->jane); $this->publicDomain = \App\Domain::where('type', \App\Domain::TYPE_PUBLIC)->first(); $this->publicDomainUser = $this->getTestUser( 'john@' . $this->publicDomain->namespace, ['password' => $this->userPassword] ); $this->publicDomainUser->assignPackage($packageKolab); } public function tearDown(): void { foreach ($this->domainUsers as $user) { if ($user == $this->domainOwner) { continue; } $this->deleteTestUser($user->email); } if ($this->domainOwner) { $this->deleteTestUser($this->domainOwner->email); } if ($this->domainHosted) { $this->deleteTestDomain($this->domainHosted->namespace); } if ($this->publicDomainUser) { $this->deleteTestUser($this->publicDomainUser->email); } parent::tearDown(); } } diff --git a/src/tests/Unit/Mail/DegradedAccountReminderTest.php b/src/tests/Unit/Mail/DegradedAccountReminderTest.php index 3244f9ef..f598e92b 100644 --- a/src/tests/Unit/Mail/DegradedAccountReminderTest.php +++ b/src/tests/Unit/Mail/DegradedAccountReminderTest.php @@ -1,45 +1,42 @@ getTestUser('ned@kolab.org'); $wallet = $user->wallets->first(); - $mail = $this->fakeMail(new DegradedAccountReminder($wallet, $user)); + $mail = $this->renderMail(new DegradedAccountReminder($wallet, $user)); $html = $mail['html']; $plain = $mail['plain']; $dashboardUrl = \App\Utils::serviceUrl('/dashboard'); $dashboardLink = sprintf('%s', $dashboardUrl, $dashboardUrl); $appName = $user->tenant->title; - $this->assertMailSubject("$appName Reminder: Your account is free", $mail['message']); + $this->assertSame("$appName Reminder: Your account is free", $mail['subject']); $this->assertStringStartsWith('', $html); $this->assertTrue(strpos($html, $user->name(true)) > 0); $this->assertTrue(strpos($html, $dashboardLink) > 0); $this->assertTrue(strpos($html, "your account is a free account") > 0); $this->assertTrue(strpos($html, "$appName Team") > 0); $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); $this->assertTrue(strpos($plain, $dashboardUrl) > 0); $this->assertTrue(strpos($plain, "your account is a free account") > 0); $this->assertTrue(strpos($plain, "$appName Team") > 0); } } diff --git a/src/tests/Unit/Mail/NegativeBalanceBeforeDeleteTest.php b/src/tests/Unit/Mail/NegativeBalanceBeforeDeleteTest.php index 1e5d11e5..3385b0ab 100644 --- a/src/tests/Unit/Mail/NegativeBalanceBeforeDeleteTest.php +++ b/src/tests/Unit/Mail/NegativeBalanceBeforeDeleteTest.php @@ -1,125 +1,122 @@ getTestUser('ned@kolab.org'); $wallet = $user->wallets->first(); $wallet->balance = -100; $wallet->save(); $threshold = WalletCheck::threshold($wallet, WalletCheck::THRESHOLD_DELETE); \config([ 'app.support_url' => 'https://kolab.org/support', ]); - $mail = $this->fakeMail(new NegativeBalanceBeforeDelete($wallet, $user)); + $mail = $this->renderMail(new NegativeBalanceBeforeDelete($wallet, $user)); $html = $mail['html']; $plain = $mail['plain']; $walletUrl = \App\Utils::serviceUrl('/wallet'); $walletLink = sprintf('%s', $walletUrl, $walletUrl); $supportUrl = \config('app.support_url'); $supportLink = sprintf('%s', $supportUrl, $supportUrl); $appName = $user->tenant->title; - $this->assertMailSubject("$appName Final Warning", $mail['message']); + $this->assertSame("$appName Final Warning", $mail['subject']); $this->assertStringStartsWith('', $html); $this->assertTrue(strpos($html, $user->name(true)) > 0); $this->assertTrue(strpos($html, $walletLink) > 0); $this->assertTrue(strpos($html, $supportLink) > 0); $this->assertTrue(strpos($html, "This is a final reminder to settle your $appName") > 0); $this->assertTrue(strpos($html, $threshold->toDateString()) > 0); $this->assertTrue(strpos($html, "$appName Support") > 0); $this->assertTrue(strpos($html, "$appName Team") > 0); $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); $this->assertTrue(strpos($plain, $walletUrl) > 0); $this->assertTrue(strpos($plain, $supportUrl) > 0); $this->assertTrue(strpos($plain, "This is a final reminder to settle your $appName") > 0); $this->assertTrue(strpos($plain, $threshold->toDateString()) > 0); $this->assertTrue(strpos($plain, "$appName Support") > 0); $this->assertTrue(strpos($plain, "$appName Team") > 0); // Test with user that is not the same tenant as in .env $user = $this->getTestUser('user@sample-tenant.dev-local'); $tenant = $user->tenant; $wallet = $user->wallets->first(); $wallet->balance = -100; $wallet->save(); $threshold = WalletCheck::threshold($wallet, WalletCheck::THRESHOLD_DELETE); $tenant->setSettings([ 'app.support_url' => 'https://test.org/support', 'app.public_url' => 'https://test.org', ]); - $mail = $this->fakeMail(new NegativeBalanceBeforeDelete($wallet, $user)); + $mail = $this->renderMail(new NegativeBalanceBeforeDelete($wallet, $user)); $html = $mail['html']; $plain = $mail['plain']; $walletUrl = 'https://test.org/wallet'; $walletLink = sprintf('%s', $walletUrl, $walletUrl); $supportUrl = 'https://test.org/support'; $supportLink = sprintf('%s', $supportUrl, $supportUrl); - $this->assertMailSubject("{$tenant->title} Final Warning", $mail['message']); + $this->assertSame("{$tenant->title} Final Warning", $mail['subject']); $this->assertStringStartsWith('', $html); $this->assertTrue(strpos($html, $user->name(true)) > 0); $this->assertTrue(strpos($html, $walletLink) > 0); $this->assertTrue(strpos($html, $supportLink) > 0); $this->assertTrue(strpos($html, "This is a final reminder to settle your {$tenant->title}") > 0); $this->assertTrue(strpos($html, $threshold->toDateString()) > 0); $this->assertTrue(strpos($html, "{$tenant->title} Support") > 0); $this->assertTrue(strpos($html, "{$tenant->title} Team") > 0); $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); $this->assertTrue(strpos($plain, $walletUrl) > 0); $this->assertTrue(strpos($plain, $supportUrl) > 0); $this->assertTrue(strpos($plain, "This is a final reminder to settle your {$tenant->title}") > 0); $this->assertTrue(strpos($plain, $threshold->toDateString()) > 0); $this->assertTrue(strpos($plain, "{$tenant->title} Support") > 0); $this->assertTrue(strpos($plain, "{$tenant->title} Team") > 0); } } diff --git a/src/tests/Unit/Mail/NegativeBalanceDegradedTest.php b/src/tests/Unit/Mail/NegativeBalanceDegradedTest.php index 1fd471ed..fd498db8 100644 --- a/src/tests/Unit/Mail/NegativeBalanceDegradedTest.php +++ b/src/tests/Unit/Mail/NegativeBalanceDegradedTest.php @@ -1,58 +1,55 @@ getTestUser('ned@kolab.org'); $wallet = $user->wallets->first(); $wallet->balance = -100; $wallet->save(); \config([ 'app.support_url' => 'https://kolab.org/support', ]); - $mail = $this->fakeMail(new NegativeBalanceDegraded($wallet, $user)); + $mail = $this->renderMail(new NegativeBalanceDegraded($wallet, $user)); $html = $mail['html']; $plain = $mail['plain']; $walletUrl = \App\Utils::serviceUrl('/wallet'); $walletLink = sprintf('%s', $walletUrl, $walletUrl); $supportUrl = \config('app.support_url'); $supportLink = sprintf('%s', $supportUrl, $supportUrl); $appName = $user->tenant->title; - $this->assertMailSubject("$appName Account Degraded", $mail['message']); + $this->assertSame("$appName Account Degraded", $mail['subject']); $this->assertStringStartsWith('', $html); $this->assertTrue(strpos($html, $user->name(true)) > 0); $this->assertTrue(strpos($html, $walletLink) > 0); $this->assertTrue(strpos($html, $supportLink) > 0); $this->assertTrue(strpos($html, "Your $appName account has been degraded") > 0); $this->assertTrue(strpos($html, "$appName Support") > 0); $this->assertTrue(strpos($html, "$appName Team") > 0); $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); $this->assertTrue(strpos($plain, $walletUrl) > 0); $this->assertTrue(strpos($plain, $supportUrl) > 0); $this->assertTrue(strpos($plain, "Your $appName account has been degraded") > 0); $this->assertTrue(strpos($plain, "$appName Support") > 0); $this->assertTrue(strpos($plain, "$appName Team") > 0); } } diff --git a/src/tests/Unit/Mail/NegativeBalanceReminderDegradeTest.php b/src/tests/Unit/Mail/NegativeBalanceReminderDegradeTest.php index c4d9fd8f..f2d319da 100644 --- a/src/tests/Unit/Mail/NegativeBalanceReminderDegradeTest.php +++ b/src/tests/Unit/Mail/NegativeBalanceReminderDegradeTest.php @@ -1,64 +1,61 @@ getTestUser('ned@kolab.org'); $wallet = $user->wallets->first(); $wallet->balance = -100; $wallet->save(); $threshold = WalletCheck::threshold($wallet, WalletCheck::THRESHOLD_DEGRADE); \config([ 'app.support_url' => 'https://kolab.org/support', ]); - $mail = $this->fakeMail(new NegativeBalanceReminderDegrade($wallet, $user)); + $mail = $this->renderMail(new NegativeBalanceReminderDegrade($wallet, $user)); $html = $mail['html']; $plain = $mail['plain']; $walletUrl = \App\Utils::serviceUrl('/wallet'); $walletLink = sprintf('%s', $walletUrl, $walletUrl); $supportUrl = \config('app.support_url'); $supportLink = sprintf('%s', $supportUrl, $supportUrl); $appName = $user->tenant->title; - $this->assertMailSubject("$appName Payment Reminder", $mail['message']); + $this->assertSame("$appName Payment Reminder", $mail['subject']); $this->assertStringStartsWith('', $html); $this->assertTrue(strpos($html, $user->name(true)) > 0); $this->assertTrue(strpos($html, $walletLink) > 0); $this->assertTrue(strpos($html, $supportLink) > 0); $this->assertTrue(strpos($html, "you are behind on paying for your $appName account") > 0); $this->assertTrue(strpos($html, "your account will be degraded") > 0); $this->assertTrue(strpos($html, $threshold->toDateString()) > 0); $this->assertTrue(strpos($html, "$appName Support") > 0); $this->assertTrue(strpos($html, "$appName Team") > 0); $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); $this->assertTrue(strpos($plain, $walletUrl) > 0); $this->assertTrue(strpos($plain, $supportUrl) > 0); $this->assertTrue(strpos($plain, "you are behind on paying for your $appName account") > 0); $this->assertTrue(strpos($plain, "your account will be degraded") > 0); $this->assertTrue(strpos($plain, $threshold->toDateString()) > 0); $this->assertTrue(strpos($plain, "$appName Support") > 0); $this->assertTrue(strpos($plain, "$appName Team") > 0); } } diff --git a/src/tests/Unit/Mail/NegativeBalanceReminderTest.php b/src/tests/Unit/Mail/NegativeBalanceReminderTest.php index f68d58b5..9a669d77 100644 --- a/src/tests/Unit/Mail/NegativeBalanceReminderTest.php +++ b/src/tests/Unit/Mail/NegativeBalanceReminderTest.php @@ -1,62 +1,59 @@ getTestUser('ned@kolab.org'); $wallet = $user->wallets->first(); $wallet->balance = -100; $wallet->save(); $threshold = WalletCheck::threshold($wallet, WalletCheck::THRESHOLD_SUSPEND); \config([ 'app.support_url' => 'https://kolab.org/support', ]); - $mail = $this->fakeMail(new NegativeBalanceReminder($wallet, $user)); + $mail = $this->renderMail(new NegativeBalanceReminder($wallet, $user)); $html = $mail['html']; $plain = $mail['plain']; $walletUrl = \App\Utils::serviceUrl('/wallet'); $walletLink = sprintf('%s', $walletUrl, $walletUrl); $supportUrl = \config('app.support_url'); $supportLink = sprintf('%s', $supportUrl, $supportUrl); $appName = $user->tenant->title; - $this->assertMailSubject("$appName Payment Reminder", $mail['message']); + $this->assertSame("$appName Payment Reminder", $mail['subject']); $this->assertStringStartsWith('', $html); $this->assertTrue(strpos($html, $user->name(true)) > 0); $this->assertTrue(strpos($html, $walletLink) > 0); $this->assertTrue(strpos($html, $supportLink) > 0); $this->assertTrue(strpos($html, "you are behind on paying for your $appName account") > 0); $this->assertTrue(strpos($html, $threshold->toDateString()) > 0); $this->assertTrue(strpos($html, "$appName Support") > 0); $this->assertTrue(strpos($html, "$appName Team") > 0); $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); $this->assertTrue(strpos($plain, $walletUrl) > 0); $this->assertTrue(strpos($plain, $supportUrl) > 0); $this->assertTrue(strpos($plain, "you are behind on paying for your $appName account") > 0); $this->assertTrue(strpos($plain, $threshold->toDateString()) > 0); $this->assertTrue(strpos($plain, "$appName Support") > 0); $this->assertTrue(strpos($plain, "$appName Team") > 0); } } diff --git a/src/tests/Unit/Mail/NegativeBalanceSuspendedTest.php b/src/tests/Unit/Mail/NegativeBalanceSuspendedTest.php index fc59ad50..5a3cd110 100644 --- a/src/tests/Unit/Mail/NegativeBalanceSuspendedTest.php +++ b/src/tests/Unit/Mail/NegativeBalanceSuspendedTest.php @@ -1,62 +1,59 @@ getTestUser('ned@kolab.org'); $wallet = $user->wallets->first(); $wallet->balance = -100; $wallet->save(); $threshold = WalletCheck::threshold($wallet, WalletCheck::THRESHOLD_DELETE); \config([ 'app.support_url' => 'https://kolab.org/support', ]); - $mail = $this->fakeMail(new NegativeBalanceSuspended($wallet, $user)); + $mail = $this->renderMail(new NegativeBalanceSuspended($wallet, $user)); $html = $mail['html']; $plain = $mail['plain']; $walletUrl = \App\Utils::serviceUrl('/wallet'); $walletLink = sprintf('%s', $walletUrl, $walletUrl); $supportUrl = \config('app.support_url'); $supportLink = sprintf('%s', $supportUrl, $supportUrl); $appName = $user->tenant->title; - $this->assertMailSubject("$appName Account Suspended", $mail['message']); + $this->assertSame("$appName Account Suspended", $mail['subject']); $this->assertStringStartsWith('', $html); $this->assertTrue(strpos($html, $user->name(true)) > 0); $this->assertTrue(strpos($html, $walletLink) > 0); $this->assertTrue(strpos($html, $supportLink) > 0); $this->assertTrue(strpos($html, "Your $appName account has been suspended") > 0); $this->assertTrue(strpos($html, $threshold->toDateString()) > 0); $this->assertTrue(strpos($html, "$appName Support") > 0); $this->assertTrue(strpos($html, "$appName Team") > 0); $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); $this->assertTrue(strpos($plain, $walletUrl) > 0); $this->assertTrue(strpos($plain, $supportUrl) > 0); $this->assertTrue(strpos($plain, "Your $appName account has been suspended") > 0); $this->assertTrue(strpos($plain, $threshold->toDateString()) > 0); $this->assertTrue(strpos($plain, "$appName Support") > 0); $this->assertTrue(strpos($plain, "$appName Team") > 0); } } diff --git a/src/tests/Unit/Mail/NegativeBalanceTest.php b/src/tests/Unit/Mail/NegativeBalanceTest.php index 01285fa4..842cd356 100644 --- a/src/tests/Unit/Mail/NegativeBalanceTest.php +++ b/src/tests/Unit/Mail/NegativeBalanceTest.php @@ -1,55 +1,52 @@ 'https://kolab.org/support', ]); - $mail = $this->fakeMail(new NegativeBalance($wallet, $user)); + $mail = $this->renderMail(new NegativeBalance($wallet, $user)); $html = $mail['html']; $plain = $mail['plain']; $walletUrl = \App\Utils::serviceUrl('/wallet'); $walletLink = sprintf('%s', $walletUrl, $walletUrl); $supportUrl = \config('app.support_url'); $supportLink = sprintf('%s', $supportUrl, $supportUrl); $appName = \config('app.name'); - $this->assertMailSubject("$appName Payment Required", $mail['message']); + $this->assertSame("$appName Payment Required", $mail['subject']); $this->assertStringStartsWith('', $html); $this->assertTrue(strpos($html, $user->name(true)) > 0); $this->assertTrue(strpos($html, $walletLink) > 0); $this->assertTrue(strpos($html, $supportLink) > 0); $this->assertTrue(strpos($html, "your $appName account balance has run into the nega") > 0); $this->assertTrue(strpos($html, "$appName Support") > 0); $this->assertTrue(strpos($html, "$appName Team") > 0); $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); $this->assertTrue(strpos($plain, $walletUrl) > 0); $this->assertTrue(strpos($plain, $supportUrl) > 0); $this->assertTrue(strpos($plain, "your $appName account balance has run into the nega") > 0); $this->assertTrue(strpos($plain, "$appName Support") > 0); $this->assertTrue(strpos($plain, "$appName Team") > 0); } } diff --git a/src/tests/Unit/Mail/PasswordResetTest.php b/src/tests/Unit/Mail/PasswordResetTest.php index fb0f3dd8..95a1bba2 100644 --- a/src/tests/Unit/Mail/PasswordResetTest.php +++ b/src/tests/Unit/Mail/PasswordResetTest.php @@ -1,50 +1,47 @@ 123456789, 'mode' => 'password-reset', 'code' => 'code', 'short_code' => 'short-code', ]); $code->user = new User([ 'name' => 'User Name', ]); - $mail = $this->fakeMail(new PasswordReset($code)); + $mail = $this->renderMail(new PasswordReset($code)); $html = $mail['html']; $plain = $mail['plain']; $url = Utils::serviceUrl('/password-reset/' . $code->short_code . '-' . $code->code); $link = "$url"; $appName = \config('app.name'); - $this->assertMailSubject("$appName Password Reset", $mail['message']); + $this->assertSame("$appName Password Reset", $mail['subject']); $this->assertStringStartsWith('', $html); $this->assertTrue(strpos($html, $link) > 0); $this->assertTrue(strpos($html, $code->user->name(true)) > 0); $this->assertStringStartsWith("Dear " . $code->user->name(true), $plain); $this->assertTrue(strpos($plain, $link) > 0); } } diff --git a/src/tests/Unit/Mail/PaymentFailureTest.php b/src/tests/Unit/Mail/PaymentFailureTest.php index 6cf8f46f..255d0ebf 100644 --- a/src/tests/Unit/Mail/PaymentFailureTest.php +++ b/src/tests/Unit/Mail/PaymentFailureTest.php @@ -1,54 +1,51 @@ amount = 123; \config(['app.support_url' => 'https://kolab.org/support']); - $mail = $this->fakeMail(new PaymentFailure($payment, $user)); + $mail = $this->renderMail(new PaymentFailure($payment, $user)); $html = $mail['html']; $plain = $mail['plain']; $walletUrl = \App\Utils::serviceUrl('/wallet'); $walletLink = sprintf('%s', $walletUrl, $walletUrl); $supportUrl = \config('app.support_url'); $supportLink = sprintf('%s', $supportUrl, $supportUrl); $appName = \config('app.name'); - $this->assertMailSubject("$appName Payment Failed", $mail['message']); + $this->assertSame("$appName Payment Failed", $mail['subject']); $this->assertStringStartsWith('', $html); $this->assertTrue(strpos($html, $user->name(true)) > 0); $this->assertTrue(strpos($html, $walletLink) > 0); $this->assertTrue(strpos($html, $supportLink) > 0); $this->assertTrue(strpos($html, "$appName Support") > 0); $this->assertTrue(strpos($html, "Something went wrong with auto-payment for your $appName account") > 0); $this->assertTrue(strpos($html, "$appName Team") > 0); $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); $this->assertTrue(strpos($plain, $walletUrl) > 0); $this->assertTrue(strpos($plain, $supportUrl) > 0); $this->assertTrue(strpos($plain, "$appName Support") > 0); $this->assertTrue(strpos($plain, "Something went wrong with auto-payment for your $appName account") > 0); $this->assertTrue(strpos($plain, "$appName Team") > 0); } } diff --git a/src/tests/Unit/Mail/PaymentMandateDisabledTest.php b/src/tests/Unit/Mail/PaymentMandateDisabledTest.php index 4e43f475..0c1ca4cf 100644 --- a/src/tests/Unit/Mail/PaymentMandateDisabledTest.php +++ b/src/tests/Unit/Mail/PaymentMandateDisabledTest.php @@ -1,53 +1,50 @@ 'https://kolab.org/support']); - $mail = $this->fakeMail(new PaymentMandateDisabled($wallet, $user)); + $mail = $this->renderMail(new PaymentMandateDisabled($wallet, $user)); $html = $mail['html']; $plain = $mail['plain']; $walletUrl = \App\Utils::serviceUrl('/wallet'); $walletLink = sprintf('%s', $walletUrl, $walletUrl); $supportUrl = \config('app.support_url'); $supportLink = sprintf('%s', $supportUrl, $supportUrl); $appName = \config('app.name'); - $this->assertMailSubject("$appName Auto-payment Problem", $mail['message']); + $this->assertSame("$appName Auto-payment Problem", $mail['subject']); $this->assertStringStartsWith('', $html); $this->assertTrue(strpos($html, $user->name(true)) > 0); $this->assertTrue(strpos($html, $walletLink) > 0); $this->assertTrue(strpos($html, $supportLink) > 0); $this->assertTrue(strpos($html, "$appName Support") > 0); $this->assertTrue(strpos($html, "Your $appName account balance") > 0); $this->assertTrue(strpos($html, "$appName Team") > 0); $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); $this->assertTrue(strpos($plain, $walletUrl) > 0); $this->assertTrue(strpos($plain, $supportUrl) > 0); $this->assertTrue(strpos($plain, "$appName Support") > 0); $this->assertTrue(strpos($plain, "Your $appName account balance") > 0); $this->assertTrue(strpos($plain, "$appName Team") > 0); } } diff --git a/src/tests/Unit/Mail/PaymentSuccessTest.php b/src/tests/Unit/Mail/PaymentSuccessTest.php index e155d520..e219c515 100644 --- a/src/tests/Unit/Mail/PaymentSuccessTest.php +++ b/src/tests/Unit/Mail/PaymentSuccessTest.php @@ -1,54 +1,51 @@ amount = 123; \config(['app.support_url' => 'https://kolab.org/support']); - $mail = $this->fakeMail(new PaymentSuccess($payment, $user)); + $mail = $this->renderMail(new PaymentSuccess($payment, $user)); $html = $mail['html']; $plain = $mail['plain']; $walletUrl = \App\Utils::serviceUrl('/wallet'); $walletLink = sprintf('%s', $walletUrl, $walletUrl); $supportUrl = \config('app.support_url'); $supportLink = sprintf('%s', $supportUrl, $supportUrl); $appName = \config('app.name'); - $this->assertMailSubject("$appName Payment Succeeded", $mail['message']); + $this->assertSame("$appName Payment Succeeded", $mail['subject']); $this->assertStringStartsWith('', $html); $this->assertTrue(strpos($html, $user->name(true)) > 0); $this->assertTrue(strpos($html, $walletLink) > 0); $this->assertTrue(strpos($html, $supportLink) > 0); $this->assertTrue(strpos($html, "$appName Support") > 0); $this->assertTrue(strpos($html, "The auto-payment for your $appName account") > 0); $this->assertTrue(strpos($html, "$appName Team") > 0); $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); $this->assertTrue(strpos($plain, $walletUrl) > 0); $this->assertTrue(strpos($plain, $supportUrl) > 0); $this->assertTrue(strpos($plain, "$appName Support") > 0); $this->assertTrue(strpos($plain, "The auto-payment for your $appName account") > 0); $this->assertTrue(strpos($plain, "$appName Team") > 0); } } diff --git a/src/tests/Unit/Mail/SignupInvitationTest.php b/src/tests/Unit/Mail/SignupInvitationTest.php index c27b4188..36a18b3e 100644 --- a/src/tests/Unit/Mail/SignupInvitationTest.php +++ b/src/tests/Unit/Mail/SignupInvitationTest.php @@ -1,44 +1,41 @@ 'abc', 'email' => 'test@email', ]); - $mail = $this->fakeMail(new SignupInvitation($invitation)); + $mail = $this->renderMail(new SignupInvitation($invitation)); $html = $mail['html']; $plain = $mail['plain']; $url = Utils::serviceUrl('/signup/invite/' . $invitation->id); $link = "$url"; $appName = \config('app.name'); - $this->assertMailSubject("$appName Invitation", $mail['message']); + $this->assertSame("$appName Invitation", $mail['subject']); $this->assertStringStartsWith('', $html); $this->assertTrue(strpos($html, $link) > 0); $this->assertTrue(strpos($html, "invited to join $appName") > 0); $this->assertStringStartsWith("Hi,", $plain); $this->assertTrue(strpos($plain, "invited to join $appName") > 0); $this->assertTrue(strpos($plain, $url) > 0); } } diff --git a/src/tests/Unit/Mail/SignupVerificationTest.php b/src/tests/Unit/Mail/SignupVerificationTest.php index 3fa1ab0e..9cbea0b8 100644 --- a/src/tests/Unit/Mail/SignupVerificationTest.php +++ b/src/tests/Unit/Mail/SignupVerificationTest.php @@ -1,46 +1,43 @@ 'code', 'short_code' => 'short-code', 'email' => 'test@email', 'first_name' => 'First', 'last_name' => 'Last', ]); - $mail = $this->fakeMail(new SignupVerification($code)); + $mail = $this->renderMail(new SignupVerification($code)); $html = $mail['html']; $plain = $mail['plain']; $url = Utils::serviceUrl('/signup/' . $code->short_code . '-' . $code->code); $link = "$url"; $appName = \config('app.name'); - $this->assertMailSubject("$appName Registration", $mail['message']); + $this->assertSame("$appName Registration", $mail['subject']); $this->assertStringStartsWith('', $html); $this->assertTrue(strpos($html, $link) > 0); $this->assertTrue(strpos($html, 'First Last') > 0); $this->assertStringStartsWith('Dear First Last', $plain); $this->assertTrue(strpos($plain, $url) > 0); } } diff --git a/src/tests/Unit/Mail/SuspendedDebtorTest.php b/src/tests/Unit/Mail/SuspendedDebtorTest.php index 746fddd5..320402cb 100644 --- a/src/tests/Unit/Mail/SuspendedDebtorTest.php +++ b/src/tests/Unit/Mail/SuspendedDebtorTest.php @@ -1,65 +1,62 @@ 'https://kolab.org/support', 'app.kb.account_suspended' => 'https://kb.kolab.org/account-suspended', 'app.kb.account_delete' => 'https://kb.kolab.org/account-delete', ]); - $mail = $this->fakeMail(new SuspendedDebtor($user)); + $mail = $this->renderMail(new SuspendedDebtor($user)); $html = $mail['html']; $plain = $mail['plain']; $walletUrl = \App\Utils::serviceUrl('/wallet'); $walletLink = sprintf('%s', $walletUrl, $walletUrl); $supportUrl = \config('app.support_url'); $supportLink = sprintf('%s', $supportUrl, $supportUrl); $deleteUrl = \config('app.kb.account_delete'); $deleteLink = sprintf('%s', $deleteUrl, $deleteUrl); $moreUrl = \config('app.kb.account_suspended'); $moreLink = sprintf('here', $moreUrl); $appName = \config('app.name'); - $this->assertMailSubject("$appName Account Suspended", $mail['message']); + $this->assertSame("$appName Account Suspended", $mail['subject']); $this->assertStringStartsWith('', $html); $this->assertTrue(strpos($html, $user->name(true)) > 0); $this->assertTrue(strpos($html, $walletLink) > 0); $this->assertTrue(strpos($html, $supportLink) > 0); $this->assertTrue(strpos($html, $deleteLink) > 0); $this->assertTrue(strpos($html, "You have been behind on paying for your $appName account") > 0); $this->assertTrue(strpos($html, "over 14 days") > 0); $this->assertTrue(strpos($html, "See $moreLink for more information") > 0); $this->assertTrue(strpos($html, "$appName Support") > 0); $this->assertTrue(strpos($html, "$appName Team") > 0); $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); $this->assertTrue(strpos($plain, $walletUrl) > 0); $this->assertTrue(strpos($plain, $supportUrl) > 0); $this->assertTrue(strpos($plain, $deleteUrl) > 0); $this->assertTrue(strpos($plain, "You have been behind on paying for your $appName account") > 0); $this->assertTrue(strpos($plain, "over 14 days") > 0); $this->assertTrue(strpos($plain, "See $moreUrl for more information") > 0); $this->assertTrue(strpos($plain, "$appName Support") > 0); $this->assertTrue(strpos($plain, "$appName Team") > 0); } }