diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ $ cat .env.local >> .env $ ./artisan key:generate $ ./artisan jwt:secret -f -$ artisan clear-compiled +$ ./artisan clear-compiled $ npm run dev $ rm -rf database/database.sqlite $ touch database/database.sqlite diff --git a/bin/phpunit-fast b/bin/phpunit-fast new file mode 100755 --- /dev/null +++ b/bin/phpunit-fast @@ -0,0 +1,13 @@ +#!/bin/bash + +cwd=$(dirname $0) + +pushd ${cwd}/../src/ + +php vendor/bin/phpunit \ + --no-coverage \ + --stop-on-defect \ + --stop-on-error \ + --stop-on-failure $* + +popd diff --git a/bin/quickstart.sh b/bin/quickstart.sh --- a/bin/quickstart.sh +++ b/bin/quickstart.sh @@ -56,7 +56,7 @@ pushd ${base_dir}/src/ rm -rf vendor/ composer.lock -composer install +php -dmemory_limit=-1 /bin/composer install npm install find bootstrap/cache/ -type f ! -name ".gitignore" -delete ./artisan key:generate diff --git a/docker/ds389/Dockerfile b/docker/ds389/Dockerfile new file mode 100644 --- /dev/null +++ b/docker/ds389/Dockerfile @@ -0,0 +1,41 @@ +FROM centos/centos7:latest + +MAINTAINER Liutauras Adomaitis + +RUN yum -y install \ + epel-release \ + 389-ds-base \ + 389-adminutil \ + gettext && \ + yum clean all + +COPY *.tpl / +COPY kolab-schema.ldif /etc/dirsrv/schema/99kolab-schema.ldif + +RUN for F in $(ls *.tpl); do eval "echo \"$(cat $F)\"" | tee ${F%%.tpl}; done + +RUN rm -fr /var/lock /usr/lib/systemd/system && \ + setup-ds.pl -ddd --silent --file /ds_setup.inf && \ + chown nobody.nobody -R /var/lib/dirsrv/ && \ + mv mgmt_com-install.ldif ${DOMAIN_DB}-install.ldif && \ + mv hosted_com-install.ldif ${HOSTED_DOMAIN_DB}-install.ldif && \ + mv *.ldif /var/lib/dirsrv/slapd-${DS_INSTANCE_NAME}/ldif/. + +# This only copies another kolab-schema.ldif +#COPY *.ldif /var/lib/dirsrv/slapd-${DS_INSTANCE_NAME}/ldif/ + +EXPOSE 389 + +CMD cd /var/lib/dirsrv/slapd-${DS_INSTANCE_NAME}/ldif/ && \ + for ldif in $(ls *-import.ldif || ls *-install.ldif || true); do \ + sed -r -i -e 's/mailHost: .*$/mailHost: localhost/g' ${ldif}; \ + chmod 644 ${ldif}; \ + namespace=$(echo "${ldif}" | sed -e 's/-import.ldif$//' -e 's/-install.ldif$//'); \ + /usr/lib64/dirsrv/slapd-${DS_INSTANCE_NAME}/ldif2db \ + -Z ${DS_INSTANCE_NAME} \ + -n ${namespace} \ + -i /var/lib/dirsrv/slapd-${DS_INSTANCE_NAME}/ldif/${ldif}; \ + done && \ + /usr/lib64/dirsrv/slapd-hkccp/start-slapd && \ + tail -F /var/log/dirsrv/slapd-${DS_INSTANCE_NAME}/{access,audit,errors} + diff --git a/docker/ds389/ds_adjustments.ldif.tpl b/docker/ds389/ds_adjustments.ldif.tpl new file mode 100644 --- /dev/null +++ b/docker/ds389/ds_adjustments.ldif.tpl @@ -0,0 +1,105 @@ +dn: cn=config +changetype: modify +replace: nsslapd-accesslog-logging-enabled +nsslapd-accesslog-logging-enabled: ${DS389_ACCESSLOG:-on} + +dn: cn=config +changetype: modify +replace: nsslapd-auditlog-logging-enabled +nsslapd-auditlog-logging-enabled: ${DS389_AUDITLOG:-on} + +dn: cn=config +changetype: modify +replace: nsslapd-sizelimit +nsslapd-sizelimit: -1 + +dn: cn=config +changetype: modify +replace: nsslapd-idletimeout +nsslapd-idletimeout: 0 + +dn: cn=config +changetype: modify +replace: nsslapd-timelimit +nsslapd-timelimit: -1 + +dn: cn=config +changetype: modify +replace: nsslapd-lookthroughlimit +nsslapd-lookthroughlimit: -1 + +dn: cn=config +changetype: modify +replace: nsslapd-allow-anonymous-access +nsslapd-allow-anonymous-access: rootdse + +dn: cn=alias,cn=default indexes,cn=config,cn=ldbm database,cn=plugins,cn=config +changetype: add +objectClass: top +objectClass: nsIndex +cn: alias +nsSystemIndex: false +nsIndexType: pres +nsIndexType: eq +nsIndexType: sub + +dn: cn=mailAlternateAddress,cn=default indexes,cn=config,cn=ldbm database,cn=plugins,cn=config +changetype: modify +add: nsIndexType +nsIndexType: pres +nsIndexType: sub + +dn: cn=associateddomain,cn=default indexes,cn=config,cn=ldbm database,cn=plugins,cn=config +changetype: add +objectclass: top +objectclass: nsindex +cn: associateddomain +nsSystemIndex: false +nsindextype: pres +nsindextype: eq + +dn: cn=ACL Plugin,cn=plugins,cn=config +changetype: modify +replace: nsslapd-aclpb-max-selected-acls +nsslapd-aclpb-max-selected-acls: 8192 + +dn: cn=7-bit check,cn=plugins,cn=config +changetype: modify +replace: nsslapd-pluginEnabled +nsslapd-pluginEnabled: off + +dn: cn=attribute uniqueness,cn=plugins,cn=config +changetype: modify +replace: nsslapd-pluginEnabled +nsslapd-pluginEnabled: on + +dn: cn=referential integrity postoperation,cn=plugins,cn=config +changetype: modify +replace: nsslapd-pluginEnabled +nsslapd-pluginEnabled: on + +dn: cn=Account Policy Plugin,cn=plugins,cn=config +changetype: modify +replace: nsslapd-pluginEnabled +nsslapd-pluginEnabled: on + +dn: cn=Account Policy Plugin,cn=plugins,cn=config +changetype: modify +replace: nsslapd-pluginarg0 +nsslapd-pluginarg0: cn=config,cn=Account Policy Plugin,cn=plugins,cn=config + +dn: cn=config,cn=Account Policy Plugin,cn=plugins,cn=config +changetype: modify +replace: alwaysrecordlogin +alwaysrecordlogin: yes + +dn: cn=config,cn=Account Policy Plugin,cn=plugins,cn=config +changetype: modify +replace: stateattrname +stateattrname: lastLoginTime + +dn: cn=config,cn=Account Policy Plugin,cn=plugins,cn=config +changetype: modify +replace: altstateattrname +altstateattrname: createTimestamp + diff --git a/docker/ds389/ds_admin_backend.ldif.tpl b/docker/ds389/ds_admin_backend.ldif.tpl new file mode 100644 --- /dev/null +++ b/docker/ds389/ds_admin_backend.ldif.tpl @@ -0,0 +1,20 @@ +dn: cn=\"${LDAP_ADMIN_ROOT_DN}\",cn=mapping tree,cn=config +objectClass: top +objectClass: extensibleObject +objectClass: nsMappingTree +cn: ${LDAP_ADMIN_ROOT_DN} +nsslapd-state: backend +nsslapd-backend: ${DOMAIN_DB} + +dn: cn=${DOMAIN_DB},cn=ldbm database,cn=plugins,cn=config +objectClass: top +objectClass: extensibleObject +objectClass: nsBackendInstance +cn: ${DOMAIN_DB} +nsslapd-suffix: ${LDAP_ADMIN_ROOT_DN} +nsslapd-cachesize: -1 +nsslapd-cachememsize: 10485760 +nsslapd-readonly: off +nsslapd-require-index: off +nsslapd-directory: /var/lib/dirsrv/slapd-${DS_INSTANCE_NAME:-$(hostname -s)}/db/${DOMAIN_DB} +nsslapd-dncachememsize: 10485760 diff --git a/docker/ds389/ds_hosted_backend.ldif.tpl b/docker/ds389/ds_hosted_backend.ldif.tpl new file mode 100644 --- /dev/null +++ b/docker/ds389/ds_hosted_backend.ldif.tpl @@ -0,0 +1,21 @@ +dn: cn=\"${LDAP_HOSTED_ROOT_DN}\",cn=mapping tree,cn=config +objectClass: top +objectClass: extensibleObject +objectClass: nsMappingTree +nsslapd-state: backend +cn: ${LDAP_HOSTED_ROOT_DN} +nsslapd-backend: ${HOSTED_DOMAIN_DB} + +dn: cn=${HOSTED_DOMAIN_DB},cn=ldbm database,cn=plugins,cn=config +objectClass: top +objectClass: extensibleobject +objectClass: nsbackendinstance +cn: ${HOSTED_DOMAIN_DB} +nsslapd-suffix: ${LDAP_HOSTED_ROOT_DN} +nsslapd-cachesize: -1 +nsslapd-cachememsize: 10485760 +nsslapd-readonly: off +nsslapd-require-index: off +nsslapd-directory: /var/lib/dirsrv/slapd-${DS_INSTANCE_NAME:-$(hostname -s)}/db/${HOSTED_DOMAIN_DB} +nsslapd-dncachememsize: 10485760 + diff --git a/docker/ds389/ds_setup.inf.tpl b/docker/ds389/ds_setup.inf.tpl new file mode 100644 --- /dev/null +++ b/docker/ds389/ds_setup.inf.tpl @@ -0,0 +1,26 @@ +[General] +FullMachineName = ${FULL_MACHINE_NAME} +SuiteSpotUserID = nobody +SuiteSpotGroup = nobody +AdminDomain = ${DOMAIN} +StrictHostCheck = ${STRICT_HOST_CHECK} +ConfigDirectoryLdapURL = ldap://${DS_INSTANCE_NAME}:389/o=NetscapeRoot +ConfigDirectoryAdminID = admin +ConfigDirectoryAdminPwd = ${LDAP_ADMIN_BIND_PW} + +[slapd] +start_server = 0 +SlapdConfigForMC = Yes +UseExistingMC = 0 +ServerPort = 389 +ServerIdentifier = ${DS_INSTANCE_NAME} +RootDN = ${LDAP_ADMIN_BIND_DN} +RootDNPwd = ${LDAP_ADMIN_BIND_PW} +AddSampleEntries = No +## InstallLdifFile = /ds_install.ldif +ConfigFile = /ds_adjustments.ldif +ds_bename = ${DOMAIN_DB} +Suffix = ${LDAP_ADMIN_ROOT_DN} +ConfigFile = /ds_admin_backend.ldif +ConfigFile = /ds_hosted_backend.ldif + diff --git a/docker/ds389/hosted_com-install.ldif.tpl b/docker/ds389/hosted_com-install.ldif.tpl new file mode 100644 --- /dev/null +++ b/docker/ds389/hosted_com-install.ldif.tpl @@ -0,0 +1,77 @@ +# ${HOSTED_DOMAIN}, ${LDAP_DOMAIN_BASE_DN} +dn: associateddomain=${HOSTED_DOMAIN},${LDAP_DOMAIN_BASE_DN} +objectclass: top +objectclass: domainrelatedobject +objectclass: inetdomain +inetdomainstatus: active +associateddomain: ${HOSTED_DOMAIN} +inetdomainbasedn: ${LDAP_HOSTED_ROOT_DN} + +# ${LDAP_HOSTED_ROOT_DN} +dn: ${LDAP_HOSTED_ROOT_DN} +aci: (targetattr=\"carLicense || description || displayName || facsimileTelephoneNumber || homePhone || homePostalAddress || initials || jpegPhoto || labeledURI || mobile || pager || photo || postOfficeBox || postalAddress || postalCode || preferredDeliveryMethod || preferredLanguage || registeredAddress || roomNumber || secretary || seeAlso || st || street || telephoneNumber || telexNumber || title || userCertificate || userPassword || userSMIMECertificate || x500UniqueIdentifier\")(version 3.0; acl \"Enable self write for common attributes\"; allow (write) userdn=\"ldap:///self\";) +aci: (targetattr=\"*\")(version 3.0;acl \"Directory Administrators Group\";allow (all) (groupdn=\"ldap:///cn=Directory Administrators,${LDAP_HOSTED_ROOT_DN}\" or roledn=\"ldap:///cn=kolab-admin,${LDAP_HOSTED_ROOT_DN}\");) +aci: (targetattr=\"*\")(version 3.0; acl \"Configuration Administrators Group\"; allow (all) groupdn=\"ldap:///cn=Configuration Administrators,ou=Groups,ou=TopologyManagement,o=NetscapeRoot\";) +aci: (targetattr=\"*\")(version 3.0; acl \"Configuration Administrator\"; allow (all) userdn=\"ldap:///uid=admin,ou=Administrators,ou=TopologyManagement,o=NetscapeRoot\";) +aci: (targetattr=\"*\")(version 3.0; acl \"SIE Group\"; allow (all) groupdn = \"ldap:///cn=slapd-${DS_INSTANCE_NAME},cn=389 Directory Server,cn=Server Group,cn=${FULL_MACHINE_NAME},ou=${DOMAIN},o=NetscapeRoot\";) +aci: (targetattr=\"*\") (version 3.0;acl \"Search Access\";allow (read,compare,search)(userdn = \"ldap:///${LDAP_HOSTED_ROOT_DN}??sub?(objectclass=*)\");) +aci: (targetattr=\"*\") (version 3.0;acl \"Service Search Access\";allow (read,compare,search)(userdn = \"ldap:///${LDAP_SERVICE_BIND_DN}\");) +objectClass: top +objectClass: domain +dc: ${HOSTED_DOMAIN%.com} + +# cn=2fa-user, ${LDAP_HOSTED_ROOT_DN} +dn: cn=2fa-user,${LDAP_HOSTED_ROOT_DN} +cn: 2fa-user +description: 2fa-user role +objectclass: top +objectclass: ldapsubentry +objectclass: nsmanagedroledefinition +objectclass: nsroledefinition +objectclass: nssimpleroledefinition + +# cn=activesync-user, ${LDAP_HOSTED_ROOT_DN} +dn: cn=activesync-user,${LDAP_HOSTED_ROOT_DN} +cn: activesync-user +description: activesync-user role +objectclass: top +objectclass: ldapsubentry +objectclass: nsmanagedroledefinition +objectclass: nsroledefinition +objectclass: nssimpleroledefinition + +# cn=imap-user, ${LDAP_HOSTED_ROOT_DN} +dn: cn=imap-user,${LDAP_HOSTED_ROOT_DN} +cn: imap-user +description: imap-user role +objectclass: top +objectclass: ldapsubentry +objectclass: nsmanagedroledefinition +objectclass: nsroledefinition +objectclass: nssimpleroledefinition + +# ou=Groups, ${LDAP_HOSTED_ROOT_DN} +dn: ou=Groups,${LDAP_HOSTED_ROOT_DN} +ou: Groups +objectClass: top +objectClass: organizationalunit + +# ou=People, ${LDAP_HOSTED_ROOT_DN} +dn: ou=People,${LDAP_HOSTED_ROOT_DN} +aci: (targetattr=\"*\") (version 3.0;acl \"Hosted Kolab Services\";allow (all)(userdn = \"ldap:///${LDAP_HOSTED_BIND_DN}\");) +ou: People +objectClass: top +objectClass: organizationalunit + +# ou=Resources, ${LDAP_HOSTED_ROOT_DN} +dn: ou=Resources,${LDAP_HOSTED_ROOT_DN} +ou: Resources +objectClass: top +objectClass: organizationalunit + +# ou=Shared Folders, ${LDAP_HOSTED_ROOT_DN} +dn: ou=Shared Folders,${LDAP_HOSTED_ROOT_DN} +ou: Shared Folders +objectClass: top +objectClass: organizationalunit + diff --git a/docker/ds389/kolab-schema.ldif b/docker/ds389/kolab-schema.ldif new file mode 100644 --- /dev/null +++ b/docker/ds389/kolab-schema.ldif @@ -0,0 +1,384 @@ +# $Id$ +# (c) 2003, 2004 Tassilo Erlewein +# (c) 2003-2009 Martin Konold +# (c) 2003 Achim Frank +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# The name of the author may not be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED +# WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO +# EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; +# OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +# OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +# ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# This schema highly depends on the core.schema, cosine.schema and the inetorgperson.schema +# as provided by 3rd parties like OpenLDAP. +# +# slapd.conf then looks like +# include /kolab/etc/openldap/schema/core.schema +# include /kolab/etc/openldap/schema/cosine.schema +# include /kolab/etc/openldap/schema/inetorgperson.schema +# include /kolab/etc/openldap/schema/rfc2739.schema +# include /kolab/etc/openldap/schema/kolab3.schema +# Prefix for OIDs: 1.3.6.1.4.1.19414 <- registered +# Prefix for OIDs: 1.3.6.1.4.1.19414.2000 <-- temporarily reserved for ob +# Prefix for attributes: 1.3.6.1.4.1.19414.1 +# Prefix for attributes: 1.3.6.1.4.1.19414.2 +# Prefix for objectclasses: 1.3.6.1.4.1.19414.3 +# nameprefix: kolab +# +dn: cn=schema +#################### +# kolab attributes # +#################### +# kolabDeleteflag used to be a boolean but describes with Kolab 2 +# the fqdn of the server which is requested to delete this objects +# in its local store +attributeTypes: ( 1.3.6.1.4.1.19414.2.1.2 + NAME 'kolabDeleteflag' + DESC 'Per host deletion status' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} ) +# alias used to provide alternative rfc822 email addresses for kolab users +attributeTypes: ( 1.3.6.1.4.1.19414.2.1.3 + NAME 'alias' + DESC 'RFC1274: RFC822 Mailbox' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} ) +# Specifies the email delegates. +# An email delegate can send email on behalf of the account +# which means using the "from" of the account. +# Delegates are specified by the syntax of rfc822 email addresses. +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.3 + NAME 'kolabDelegate' + DESC 'Kolab user allowed to act as delegates - RFC822 Mailbox/Alias' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} ) +# For user, group and resource Kolab accounts +# Describes how to respond to invitations +# We keep the attribute as a string, but actually it can only have one +# of the following values: +# +# ACT_ALWAYS_ACCEPT +# ACT_ALWAYS_REJECT +# ACT_REJECT_IF_CONFLICTS +# ACT_MANUAL_IF_CONFLICTS +# ACT_MANUAL +# In addition one of these values may be prefixed with a primary email +# address followed by a colon like +# user@domain.tld: ACT_ALWAYS_ACCEPT +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.4 + NAME ( 'kolabInvitationPolicy' 'kolabResourceAction' ) + DESC 'defines how to respond to invitations' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} ) +# Begin date of Kolab vacation period. Sender will +# be notified every kolabVacationResendIntervall days +# that recipient is absent until kolabVacationEnd. +# Values in this syntax are encoded as printable strings, +# represented as specified in X.208. +# Note that the time zone must be specified. +# For Kolab we limit ourself to GMT +# YYYYMMDDHHMMZ e.g. 200512311458Z. +# see also: rfc 2252. +# Currently this attribute is not used in Kolab. +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.8 + NAME 'kolabVacationBeginDateTime' + DESC 'Begin date of vacation' + EQUALITY generalizedTimeMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 + SINGLE-VALUE ) +# End date of Kolab vacation period. Sender will +# be notified every kolabVacationResendIntervall days +# that recipient is absent starting from kolabVacationBeginDateTime. +# Values in this syntax are encoded as printable strings, +# represented as specified in X.208. +# Note that the time zone must be specified. +# For Kolab we limit ourself to GMT +# YYYYMMDDHHMMZ e.g. 200601012258Z. +# see also: rfc 2252. +# Currently this attribute is not used in Kolab. +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.9 + NAME 'kolabVacationEndDateTime' + DESC 'End date of vacation' + EQUALITY generalizedTimeMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 + SINGLE-VALUE ) +# Intervall in days after which senders get +# another vacation message. +# Currently this attribute is not used in Kolab. +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.10 + NAME 'kolabVacationResendInterval' + DESC 'Vacation notice interval in days' + EQUALITY integerMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) +# Email recipient addresses which are handled by the +# vacation script. There can be multiple kolabVacationAddress +# entries for each kolabInetOrgPerson. +# Default is the primary email address and all +# email aliases of the kolabInetOrgPerson. +# Currently this attribute is not used in Kolab. +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.11 + NAME 'kolabVacationAddress' + DESC 'Email address for vacation to response upon' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} ) +# Enable sending vacation notices in reaction +# unsolicited commercial email. +# Default is no. +# Currently this attribute is not used in Kolab. +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.12 + NAME 'kolabVacationReplyToUCE' + DESC 'Enable vacation notices to UCE' + EQUALITY booleanMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 + SINGLE-VALUE ) +# Email recipient domains which are handled by the +# vacation script. There can be multiple kolabVacationReactDomain +# entries for each kolabInetOrgPerson +# Default is to handle all domains. +# Currently this attribute is not used in Kolab. +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.13 + NAME 'kolabVacationReactDomain' + DESC 'Multivalued -- Email domain for vacation to response upon' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} ) +# Keep local copy when forwarding emails to list of +# kolabForwardAddress. +# Default is no. +# Currently this attribute is not used in Kolab. +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.15 + NAME 'kolabForwardKeepCopy' + DESC 'Keep copy when forwarding' + EQUALITY booleanMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 + SINGLE-VALUE ) +# Enable forwarding of UCE. +# Default is yes. +# Currently this attribute is not used in Kolab. +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.16 + NAME 'kolabForwardUCE' + DESC 'Enable forwarding of mails known as UCE' + EQUALITY booleanMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 + SINGLE-VALUE ) +# Describes the allowed or disallowed smtp recipient addresses for mail sent +# by the user associated with the LDAP object this attribute is associated with. +# +# If this attribute is not set for a user or distribution group, +# no Kolab recipient policy does apply. +# +# Example entries: +# .tld - allow mail to every recipient for this tld +# domain.tld - allow mail to everyone in domain.tld +# .domain.tld - allow mail to everyone in domain.tld and its subdomains +# user@domain.tld - allow mail to explicit user@domain.tld +# user@ - allow mail to this user but any domain +# -.tld - disallow mail to every recipient for this tld +# -domain.tld - disallow mail to everyone in domain.tld +# -.domain.tld - disallow mail to everyone in domain.tld and its subdomains +# -user@domain.tld - disallow mail to explicit user@domain.tld +# -user@ - disallow mail to this user but any domain +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.18 + NAME 'kolabAllowSMTPRecipient' + DESC 'SMTP address allowed for destination (multi-valued)' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{512} ) +# Jeroen van Meeuwen (Kolab Systems): Unnecessary in this deployment, as users +# will be created on one server only, however we keep this in here to allow the +# mail server to use to be specified from the user provisioning batch operation. +# +# Create the user mailbox on the kolabHomeServer only. +# Default is no. +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.19 + NAME 'kolabHomeServerOnly' + DESC 'Create the user mailbox on the kolabHomeServer only' + EQUALITY booleanMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 + SINGLE-VALUE ) +# Describes the allowed or disallowed smtp envelope sender addresses used for +# the recipient this attribute is associated with. +# +# If this attribute is not set for a user or distribution +# kolab sender policy does apply. +# +# Example entries: +# .tld - allow mail to every recipient for this tld +# domain.tld - allow mail to everyone in domain.tld +# .domain.tld - allow mail to everyone in domain.tld and its subdomains +# user@domain.tld - allow mail to explicit user@domain.tld +# user@ - allow mail to this user but any domain +# -.tld - disallow mail to every recipient for this tld +# -domain.tld - disallow mail to everyone in domain.tld +# -.domain.tld - disallow mail to everyone in domain.tld and its subdomains +# -user@domain.tld - disallow mail to explicit user@domain.tld +# -user@ - disallow mail to this user but any domain +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.43 + NAME 'kolabAllowSMTPSender' + DESC 'SMTP envelope sender address accepted for delivery (multi-valued)' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{512} ) +# kolabFolderType describes the kind of Kolab folder +# as defined in the kolab format specification. +# We will annotate all folders with an entry +# /vendor/kolab/folder-type containing the attribute +# value.shared set to: [.]. +# The can be: mail, event, journal, task, note, +# or contact. The for a mail folder can be +# inbox, drafts, sentitems, or junkemail (this one holds +# spam mails). For the other s, it can only be +# default, or not set. For other types of folders +# supported by the clients, these should be prefixed with +# "k-" for KMail, "h-" for Horde and "o-" for Outlook, and +# look like for example "kolab.o-voicemail". Other third-party +# clients shall use the "x-" prefix. +# We then use the ANNOTATEMORE IMAP extension to +# associate the folder type with a folder. +attributeTypes: ( 1.3.6.1.4.1.19414.2.1.7 + NAME 'kolabFolderType' + DESC 'type of a kolab folder' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} + SINGLE-VALUE ) +attributeTypes: ( 1.3.6.1.4.1.19414.2.1.8 + NAME 'kolabTargetFolder' + DESC 'Target for a Kolab Shared Folder delivery' + EQUALITY caseExactMatch + SUBSTR caseExactSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{512} + SINGLE-VALUE ) +# cyrus imapd access control list +# acls work with users and groups +attributeTypes: ( 1.3.6.1.4.1.19414.2.1.651 + NAME 'acl' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} ) +# Extended attributes for Resources +attributeTypes: ( 1.3.6.1.4.1.19414.3.1.1 + NAME 'kolabDescAttribute' + DESC 'Descriptive attribute or parameter for a Resource' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} ) +########################## +# kolabfilter attributes # +########################## +# enable trustable From: +attributeTypes: ( 1.3.6.1.4.1.19414.2.1.750 + NAME 'kolabfilter-verify-from-header' + EQUALITY booleanMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 ) +# should Sender header be allowed instead of From +# when present? +attributeTypes: ( 1.3.6.1.4.1.19414.2.1.751 + NAME 'kolabfilter-allow-sender-header' + EQUALITY booleanMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 ) +# Should reject messages with From headers that dont match +# the envelope? Default is to rewrite the header +attributeTypes: ( 1.3.6.1.4.1.19414.2.1.752 + NAME 'kolabfilter-reject-forged-from-header' + EQUALITY booleanMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 ) +######################## +# kolab object classes # +######################## +# public folders are typically visible to everyone subscribed to +# the server without the need for an extra login. Subfolders are +# defined using the hiarchy seperator '/' e.g. "sf/sub1". Please note +# that the term public folder is prefered to shared folder because +# normal user mailboxes can also share folders using acls. +objectClasses: ( 1.3.6.1.4.1.19414.2.2.9 + NAME 'kolabSharedFolder' + DESC 'Kolab public shared folder' + SUP top AUXILIARY + MUST cn + MAY ( acl $ + alias $ + mailHost $ + kolabFolderType $ + kolabDeleteflag $ + kolabDelegate $ + kolabTargetFolder $ + kolabAllowSMTPRecipient $ + kolabAllowSMTPSender $ + owner ) ) +# kolab account +# we use an auxiliary in order to ease integration +# with existing inetOrgPerson objects +# Please note that userPassword is a may +# attribute in the schema but is mandatory for +# Kolab +objectClasses: ( 1.3.6.1.4.1.19414.3.2.2 + NAME 'kolabInetOrgPerson' + DESC 'Kolab Internet Organizational Person' + SUP top AUXILIARY + MAY ( alias $ + mailHost $ + kolabHomeServerOnly $ + kolabDelegate $ + kolabInvitationPolicy $ + kolabVacationBeginDateTime $ + kolabVacationEndDateTime $ + kolabVacationResendInterval $ + kolabVacationAddress $ + kolabVacationReplyToUCE $ + kolabVacationReactDomain $ + kolabForwardKeepCopy $ + kolabForwardUCE $ + kolabAllowSMTPRecipient $ + kolabAllowSMTPSender $ + kolabDeleteflag ) ) +# kolab groupOfNames with extra kolabDeleteflag and the required +# attribute mail. +# The mail attribute for kolab objects of the type kolabGroupOfNames +# is not arbitrary but MUST be a single attribute of the form +# of an valid SMTP address with the CN as the local part. +# E.g cn@kolabdomain (e.g. employees@mydomain.com). The +# mail attribute MUST be globally unique. +objectClasses: ( 1.3.6.1.4.1.19414.3.2.8 + NAME 'kolabGroupOfUniqueNames' + DESC 'Kolab group of names (DNs) derived from RFC2256' + SUP top AUXILIARY + MAY ( mail $ + alias $ + kolabDelegate $ + kolabDeleteflag $ + kolabAllowSMTPRecipient $ + kolabAllowSMTPSender ) ) +# kolab resources +objectClasses: ( 1.3.6.1.4.1.19414.3.2.9 + NAME 'kolabResource' + DESC 'Kolab Resource' + SUP top AUXILIARY + MAY ( kolabInvitationPolicy $ + kolabDescAttribute $ + description $ + owner ) ) diff --git a/docker/ds389/mgmt_com-install.ldif.tpl b/docker/ds389/mgmt_com-install.ldif.tpl new file mode 100644 --- /dev/null +++ b/docker/ds389/mgmt_com-install.ldif.tpl @@ -0,0 +1,119 @@ +# ${LDAP_ADMIN_ROOT_DN} +dn: ${LDAP_ADMIN_ROOT_DN} +aci: (targetattr = \"carLicense || description || displayName || facsimileTelephoneNumber || homePhone || homePostalAddress || initials || jpegPhoto || l || labeledURI || mobile || o || pager || photo || postOfficeBox || postalAddress || postalCode || preferredDeliveryMethod || preferredLanguage || registeredAddress || roomNumber || secretary || seeAlso || st || street || telephoneNumber || telexNumber || title || userCertificate || userPassword || userSMIMECertificate || x500UniqueIdentifier || kolabDelegate || kolabInvitationPolicy || kolabAllowSMTPSender\") (version 3.0; acl \"Enable self write for common attributes\"; allow (read,compare,search,write)(userdn = \"ldap:///self\");) +aci: (targetattr = \"*\") (version 3.0;acl \"Directory Administrators Group\";allow (all)(groupdn = \"ldap:///cn=Directory Administrators,${LDAP_ADMIN_ROOT_DN}\" or roledn = \"ldap:///cn=kolab-admin,${LDAP_ADMIN_ROOT_DN}\");) +aci: (targetattr=\"*\")(version 3.0; acl \"Configuration Administrators Group\"; allow (all) groupdn=\"ldap:///cn=Configuration Administrators,ou=Groups,ou=TopologyManagement,o=NetscapeRoot\";) +aci: (targetattr=\"*\")(version 3.0; acl \"Configuration Administrator\"; allow (all) userdn=\"ldap:///uid=admin,ou=Administrators,ou=TopologyManagement,o=NetscapeRoot\";) +aci: (targetattr = \"*\")(version 3.0; acl \"SIE Group\"; allow (all) groupdn = \"ldap:///cn=slapd-ldap-k8s,cn=389 Directory Server,cn=Server Group,cn=${FULL_MACHINE_NAME},ou=${DOMAIN},o=NetscapeRoot\";) +aci: (targetattr != \"userPassword\") (version 3.0;acl \"Search Access\";allow (read,compare,search)(userdn = \"ldap:///all\");) +objectClass: top +objectClass: domain + +# Directory Administrators, ${DOMAIN} +dn: cn=Directory Administrators,${LDAP_ADMIN_ROOT_DN} +objectClass: top +objectClass: groupofuniquenames +cn: Directory Administrators +uniqueMember: cn=Directory Manager + +# Domains definition location ${DOMAIN} +dn: ${LDAP_DOMAIN_BASE_DN} +objectclass: top +objectclass: extensibleobject +ou: Domains +aci: (targetattr = \"*\") (version 3.0;acl \"Kolab Services\";allow (read,compare,search)(userdn = \"ldap:///uid=kolab-service,ou=Special Users,${LDAP_ADMIN_ROOT_DN}\");) + +# Groups, ${DOMAIN} +dn: ou=Groups,${LDAP_ADMIN_ROOT_DN} +objectClass: top +objectClass: organizationalunit +ou: Groups + +# People, ${DOMAIN} +dn: ou=People,${LDAP_ADMIN_ROOT_DN} +objectClass: top +objectClass: organizationalunit +ou: People + +# Resources, ${DOMAIN} +dn: ou=Resources,${LDAP_ADMIN_ROOT_DN} +objectClass: top +objectClass: organizationalunit +ou: Resources + +# Shared Folders, ${DOMAIN} +dn: ou=Shared Folders,${LDAP_ADMIN_ROOT_DN} +objectClass: top +objectClass: organizationalunit +ou: Shared Folders + +# Special User, ${DOMAIN} +dn: ou=Special Users,${LDAP_ADMIN_ROOT_DN} +objectClass: top +objectClass: organizationalUnit +ou: Special Users +description: Special Administrative Accounts + +# Add kolab-admin role +dn: cn=kolab-admin,${LDAP_ADMIN_ROOT_DN} +objectClass: top +objectClass: ldapsubentry +objectClass: nsroledefinition +objectClass: nssimpleroledefinition +objectClass: nsmanagedroledefinition +cn: kolab-admin +description: Kolab Administrator + +# cyrus-admin, Special Users, ${DOMAIN} +dn: uid=cyrus-admin,ou=Special Users,${LDAP_ADMIN_ROOT_DN} +sn: Administrator +uid: cyrus-admin +objectClass: top +objectClass: person +objectClass: inetorgperson +objectClass: organizationalperson +givenName: Cyrus +cn: Cyrus Administrator +userPassword: ${IMAP_ADMIN_PASSWORD} + +# kolab-service, Special Users, ${DOMAIN} +dn: ${LDAP_SERVICE_BIND_DN} +sn: Service +uid: kolab-service +objectClass: top +objectClass: person +objectClass: inetorgperson +objectClass: organizationalperson +givenName: Kolab +cn: Kolab Service +userPassword: ${LDAP_SERVICE_BIND_PW} +nsIdleTimeout: -1 +nsTimeLimit: -1 +nsSizeLimit: -1 +nsLookThroughLimit: -1 + +# hosted-kolab-service, Special Users, ${DOMAIN} +dn: ${LDAP_HOSTED_BIND_DN} +objectclass: top +objectclass: inetorgperson +objectclass: person +uid: hosted-kolab-service +cn: Hosted Kolab Service Account +sn: Service Account +givenname: Hosted Kolab +userpassword: ${LDAP_HOSTED_BIND_PW} +nsIdleTimeout: -1 +nsTimeLimit: -1 +nsSizeLimit: -1 +nsLookThroughLimit: -1 + +# ${DOMAIN}, ${LDAP_DOMAIN_BASE_DN} +dn: associateddomain=${DOMAIN},${LDAP_DOMAIN_BASE_DN} +objectclass: top +objectclass: domainrelatedobject +associateddomain: ${DOMAIN} +associateddomain: localhost.localdomain +associateddomain: localhost +aci: (targetattr = \"*\")(version 3.0;acl \"Deny Rest\";deny (all)(userdn != \"ldap:///${LDAP_SERVICE_BIND_DN} || ldap:///${LDAP_ADMIN_ROOT_DN}??sub?\(objectclass=*\)\");) +aci: (targetattr = \"*\")(version 3.0;acl \"Deny Hosted Kolab\";deny (all)(userdn = \"ldap:///${LDAP_HOSTED_BIND_DN}\");) + diff --git a/docker/kolab/Dockerfile b/docker/kolab/Dockerfile --- a/docker/kolab/Dockerfile +++ b/docker/kolab/Dockerfile @@ -1,15 +1,63 @@ -FROM kolab/centos7:latest +FROM centos:7 -RUN yum -y install rsyslog && \ - yum --enablerepo=kolab-16-updates-testing -y update pykolab && \ +LABEL maintainer="contact@kolabsystems.com" +LABEL dist=centos7 +LABEL tier=${TIER} + +ENV container docker +ENV SYSTEMD_PAGER='' +ENV DISTRO=centos7 +ENV LANG=en_US.utf8 +ENV LC_ALL=en_US.utf8 + +RUN (cd /lib/systemd/system/sysinit.target.wants/; for i in *; do [ $i == systemd-tmpfiles-setup.service ] || rm -f $i; done); \ + rm -f /lib/systemd/system/multi-user.target.wants/*; \ + rm -f /etc/systemd/system/*.wants/*; \ + rm -f /lib/systemd/system/local-fs.target.wants/*; \ + rm -f /lib/systemd/system/sockets.target.wants/*udev*; \ + rm -f /lib/systemd/system/sockets.target.wants/*initctl*; \ + rm -f /lib/systemd/system/basic.target.wants/*; \ + rm -f /lib/systemd/system/anaconda.target.wants/*; + +# To speed things up, disable fastestmirror. +RUN sed -r -i \ + -e 's/^enabled.*$/enabled = 0/g' \ + /etc/yum/pluginconf.d/fastestmirror.conf + +# Avoid using a mirrorlist (use a transparent proxy and cache everything instead). +RUN sed -r -i \ + -e 's/^mirrorlist/#mirrorlist/g' \ + -e 's/^#baseurl/baseurl/g' \ + /etc/yum.repos.d/*.repo + +RUN sed -i -e '/tsflags=nodocs/d' /etc/yum.conf + +# Add EPEL. +RUN yum -y install \ + epel-release && \ yum clean all +# Add the EPEL key. +RUN rpm --import /etc/pki/rpm-gpg/RPM-GPG-KEY-EPEL-7 + +RUN rpm --import https://mirror.kolabenterprise.com/maipo.asc + +RUN yum -y install https://mirror.kolabenterprise.com/kolab-16-for-el7.rpm && \ + yum -y install kolab-16-release-development && \ + yum clean all + +RUN yum -y --setopt tsflags= install kolab + COPY kolab-init.service /etc/systemd/system/kolab-init.service +COPY kolab-setenv.service /etc/systemd/system/kolab-setenv.service COPY kolab-vlv.service /etc/systemd/system/kolab-vlv.service +COPY utils /root/utils RUN rm -rf /etc/systemd/system/multi-user.target.wants/{avahi-daemon,sshd}.* && \ ln -s /etc/systemd/system/kolab-init.service \ /etc/systemd/system/multi-user.target.wants/kolab-init.service && \ + ln -s /etc/systemd/system/kolab-setenv.service \ + /etc/systemd/system/multi-user.target.wants/kolab-setenv.service && \ ln -s /etc/systemd/system/kolab-vlv.service \ /etc/systemd/system/multi-user.target.wants/kolab-vlv.service @@ -23,6 +71,10 @@ COPY kolab-vlv.sh /usr/local/sbin/ RUN chmod 750 /usr/local/sbin/kolab-vlv.sh +VOLUME [ "/sys/fs/cgroup" ] + +WORKDIR /root/ + CMD ["/lib/systemd/systemd"] EXPOSE 21/tcp 22/tcp 25/tcp 53/tcp 53/udp 80/tcp 110/tcp 143/tcp 389/tcp 443/tcp 465/tcp 587/tcp 993/tcp 995/tcp 5353/udp 8880/tcp 8443/tcp 8447/tcp diff --git a/docker/kolab/kolab-init.service b/docker/kolab/kolab-init.service --- a/docker/kolab/kolab-init.service +++ b/docker/kolab/kolab-init.service @@ -1,8 +1,11 @@ [Unit] Description=Kolab Setup Service +Requires=kolab-setenv.service +After=kolab-setenv.service [Service] Type=oneshot +EnvironmentFile=/etc/openshift-environment ExecStart=/usr/local/sbin/kolab-init.sh [Install] diff --git a/docker/kolab/kolab-init.sh b/docker/kolab/kolab-init.sh --- a/docker/kolab/kolab-init.sh +++ b/docker/kolab/kolab-init.sh @@ -6,26 +6,26 @@ pushd /root/utils/ -./01-reverse-etc-hosts.sh -./02-write-my.cnf.sh -./03-setup-kolab.sh -./04-reset-mysql-kolab-password.sh -./05-replace-localhost.sh -./06-mysql-for-kolabdev.sh -./07-adjust-base-dns.sh -./08-disable-amavisd.sh -./09-enable-debugging.sh -./10-reset-kolab-service-password.sh -./11-reset-cyrus-admin-password.sh -./12-create-hosted-kolab-service.sh -./13-create-ou-domains.sh -./14-create-management-domain.sh -./15-create-hosted-domain.sh -./16-remove-cn-kolab-cn-config.sh -./17-remove-hosted-service-access-from-mgmt-domain.sh -./18-adjust-kolab-conf.sh -./19-turn-on-vlv-in-roundcube.sh -./20-add-alias-attribute-index.sh -./21-adjust-postfix-config.sh +./01-reverse-etc-hosts.sh && echo "01 done" +./02-write-my.cnf.sh && echo "02 done" +./03-setup-kolab.sh && echo "03 done" +./04-reset-mysql-kolab-password.sh && echo "04 done" +./05-replace-localhost.sh && echo "05 done" +./06-mysql-for-kolabdev.sh && echo "06 done" +./07-adjust-base-dns.sh && echo "07 done" +./08-disable-amavisd.sh && echo "08 done" +./09-enable-debugging.sh && echo "09 done" +./10-reset-kolab-service-password.sh && echo "10 done" +./11-reset-cyrus-admin-password.sh && echo "11 done" +./12-create-hosted-kolab-service.sh && echo "12 done" +./13-create-ou-domains.sh && echo "13 done" +./14-create-management-domain.sh && echo "14 done" +./15-create-hosted-domain.sh && echo "15 done" +./16-remove-cn-kolab-cn-config.sh && echo "16 done" +./17-remove-hosted-service-access-from-mgmt-domain.sh && echo "17 done" +./18-adjust-kolab-conf.sh && echo "18 done" +./19-turn-on-vlv-in-roundcube.sh && echo "19 done" +./20-add-alias-attribute-index.sh && echo "20 done" +./21-adjust-postfix-config.sh && echo "21 done" touch /tmp/kolab-init.done diff --git a/docker/kolab/kolab-setenv.service b/docker/kolab/kolab-setenv.service new file mode 100644 --- /dev/null +++ b/docker/kolab/kolab-setenv.service @@ -0,0 +1,9 @@ +[Unit] +Description=Kolab Set Environment + +[Service] +Type=oneshot +ExecStart=/bin/bash -c "cat /proc/1/environ | tr '\0' '\n' > /etc/openshift-environment" + +[Install] +WantedBy=multi-user.target diff --git a/docker/kolab/utils/02-write-my.cnf.sh b/docker/kolab/utils/02-write-my.cnf.sh --- a/docker/kolab/utils/02-write-my.cnf.sh +++ b/docker/kolab/utils/02-write-my.cnf.sh @@ -2,8 +2,7 @@ cat > /root/.my.cnf << EOF [client] -host=127.0.0.1 +host=${DB_HOST:-127.0.0.1} user=root -password=Welcome2KolabSystems +password=${DB_ROOT_PASSWORD:-Welcome2KolabSystems} EOF - diff --git a/docker/kolab/utils/03-setup-kolab.sh b/docker/kolab/utils/03-setup-kolab.sh --- a/docker/kolab/utils/03-setup-kolab.sh +++ b/docker/kolab/utils/03-setup-kolab.sh @@ -1,11 +1,38 @@ #!/bin/bash -setup-kolab \ - --default \ - --fqdn=kolab.mgmt.com \ +. ./settings.sh + +if [ -f /root/kolab.conf.template ]; then + eval "echo \"$(cat /root/kolab.conf.template)\"" > /root/kolab.conf.ref + KOLAB_CONFIG_REF="--config=/root/kolab.conf.ref" + cp -f ${KOLAB_CONFIG_REF#--config=} /etc/kolab/kolab.conf +fi + +CMD="$(which setup-kolab) \ + --default ${LDAP_HOST+--without-ldap} ${KOLAB_CONFIG_REF} \ + --fqdn=kolab.${domain} \ --timezone=Europe/Zurich \ - --mysqlhost=127.0.0.1 \ + --mysqlhost=${DB_HOST:-127.0.0.1} \ --mysqlserver=existing \ - --mysqlrootpw=Welcome2KolabSystems \ - --directory-manager-pwd=Welcome2KolabSystems 2>&1 | tee /root/setup-kolab.log + --mysqlrootpw=${DB_ROOT_PASSWORD:-Welcome2KolabSystems} \ + --directory-manager-pwd=${LDAP_ADMIN_BIND_PW:-Welcome2KolabSystems}" + +echo ${CMD} | tee -a /root/setup-kolab.log +echo -n "Wait for MariaDB container: " | tee -a /root/setup-kolab.log +while ! mysqladmin -u root ping > /dev/null 2>&1 ; do + echo -n '.' + sleep 3 +done | tee -a /root/setup-kolab.log +echo "OK!" | tee -a /root/setup-kolab.log + +if [ ! -z "${LDAP_HOST}" ]; then + echo -n "Wait for DS389 container: " | tee -a /root/setup-kolab.log + while ! ldapsearch -h ${LDAP_HOST} -D "${LDAP_ADMIN_BIND_DN}" -w "${LDAP_ADMIN_BIND_PW}" -b "" -s base > /dev/null 2>&1 ; do + echo -n '.' + sleep 3 + done | tee -a /root/setup-kolab.log + echo "OK!" | tee -a /root/setup-kolab.log +fi + +${CMD} 2>&1 | tee -a /root/setup-kolab.log diff --git a/docker/kolab/utils/04-reset-mysql-kolab-password.sh b/docker/kolab/utils/04-reset-mysql-kolab-password.sh --- a/docker/kolab/utils/04-reset-mysql-kolab-password.sh +++ b/docker/kolab/utils/04-reset-mysql-kolab-password.sh @@ -2,6 +2,11 @@ sqlpw=$(grep ^sql_uri /etc/kolab/kolab.conf | awk -F':' '{print $3}' | awk -F'@' '{print $1}') -mysql -h 127.0.0.1 -u root --password=Welcome2KolabSystems \ - -e "SET PASSWORD FOR 'kolab'@'localhost' = PASSWORD('${sqlpw}');" +mysql -h ${DB_HOST} -u root --password=${DB_ROOT_PASSWORD} \ + -e "SET PASSWORD FOR '${DB_HKCCP_USERNAME:-kolabdev}'@'%' = PASSWORD('${DB_HKCCP_PASSWORD:-Welcome2KolabSystems}');" +mysql -h ${DB_HOST} -u root --password=${DB_ROOT_PASSWORD} \ + -e "SET PASSWORD FOR '${DB_KOLAB_USERNAME:-kolab}'@'%' = PASSWORD('${DB_KOLAB_PASSWORD:=$sqlpw}');" + +mysql -h ${DB_HOST} -u root --password=${DB_ROOT_PASSWORD} \ + -e "SET PASSWORD FOR '${DB_RC_USERNAME:-roundcube}'@'%' = PASSWORD('${DB_RC_PASSWORD:-Welcome2KolabSystems}');" diff --git a/docker/kolab/utils/05-replace-localhost.sh b/docker/kolab/utils/05-replace-localhost.sh --- a/docker/kolab/utils/05-replace-localhost.sh +++ b/docker/kolab/utils/05-replace-localhost.sh @@ -1,23 +1,31 @@ #!/bin/bash -mysql -h 127.0.0.1 -u root --password=Welcome2KolabSystems \ - -e "UPDATE mysql.db SET Host = '127.0.0.1' WHERE Host = 'localhost';" +if [[ ${DB_HOST} == "localhost" || ${DB_HOST} == "127.0.0.1" ]]; then + mysql -h ${DB_HOST:-127.0.0.1} -u root --password=${DB_ROOT_PASSWORD:-Welcome2KolabSystems} \ + -e "UPDATE mysql.db SET Host = '127.0.0.1' WHERE Host = 'localhost';" + + mysql -h ${DB_HOST:-127.0.0.1} -u root --password=${DB_ROOT_PASSWORD:-Welcome2KolabSystems} \ + -e "UPDATE mysql.user SET Host = '127.0.0.1' WHERE Host = 'localhost';" + + mysql -h ${DB_HOST:-127.0.0.1} -u root --password=${DB_ROOT_PASSWORD:-Welcome2KolabSystems} \ + -e "FLUSH PRIVILEGES;" +fi -mysql -h 127.0.0.1 -u root --password=Welcome2KolabSystems \ - -e "UPDATE mysql.user SET Host = '127.0.0.1' WHERE Host = 'localhost';" - -mysql -h 127.0.0.1 -u root --password=Welcome2KolabSystems \ - -e "FLUSH PRIVILEGES;" - -sed -i -e 's/localhost/127.0.0.1/g' \ - /etc/imapd.conf \ - /etc/iRony/dav.inc.php \ - /etc/kolab/kolab.conf \ - /etc/kolab-freebusy/config.ini \ - /etc/postfix/ldap/*.cf \ - /etc/roundcubemail/password.inc.php \ - /etc/roundcubemail/kolab_auth.inc.php \ - /etc/roundcubemail/config.inc.php \ - /etc/roundcubemail/calendar.inc.php +sed -i -e "s#^ldap_servers:.*#ldap_servers: ldap://${LDAP_HOST:-127.0.0.1}:389#" /etc/imapd.conf +sed -i -e "/hosts/s/localhost/${LDAP_HOST:-127.0.0.1}/" /etc/iRony/dav.inc.php +sed -i -e "s#^ldap_uri.*#ldap_uri = ldap://${LDAP_HOST:-127.0.0.1}:389#" \ + -e "s#^cache_uri.*mysql://\(.*\):\(.*\)@\(.*\)\/\(.*\)#cache_uri = mysql://${DB_KOLAB_USERNAME:-\1}:${DB_KOLAB_PASSWORD:-\2}@${DB_HOST:-127.0.0.1}/${DB_KOLAB_DATABASE:-\4}#" \ + -e "s#^sql_uri.*mysql://\(.*\):\(.*\)@\(.*\)\/\(.*\)#sql_uri = mysql://${DB_KOLAB_USERNAME:-\1}:${DB_KOLAB_PASSWORD:-\2}@${DB_HOST:-127.0.0.1}/${DB_KOLAB_DATABASE:-\4}#" \ + -e "s#^uri.*#uri = imaps://${IMAP_HOST:-127.0.0.1}:993#" /etc/kolab/kolab.conf +sed -i -e "/host/s/localhost/${LDAP_HOST:-127.0.0.1}/g" \ + -e "/fbsource/s/localhost/${IMAP_HOST:-127.0.0.1}/g" /etc/kolab-freebusy/config.ini +sed -i -e "s/server_host.*/server_host = ${LDAP_HOST:-127.0.0.1}/g" /etc/postfix/ldap/* +sed -i -e "/password_ldap_host/s/localhost/${LDAP_HOST:-127.0.0.1}/" /etc/roundcubemail/password.inc.php +sed -i -e "/hosts/s/localhost/${LDAP_HOST:-127.0.0.1}/" /etc/roundcubemail/kolab_auth.inc.php +sed -i -e "#db_dsnw#s#=.*$#= mysqli//${DB_RC_USERNAME:-roundcube}:${DB_RC_PASSWORD:-Welcome2KolabSystems}@${DB_HOST:-127.0.0.1}/${DB_RC_DATABASE:-roundcube}#" \ + -e "/default_host/s/localhost/${IMAP_HOST:-127.0.0.1}/" \ + -e "/smtp_server/s/localhost/${MAIL_HOST:-127.0.0.1}/" \ + -e "/hosts/s/localhost/${LDAP_HOST:-127.0.0.1}/" /etc/roundcubemail/config.inc.php +sed -i -e "/hosts/s/localhost/${LDAP_HOST:-127.0.0.1}/" /etc/roundcubemail/calendar.inc.php systemctl restart cyrus-imapd postfix diff --git a/docker/kolab/utils/06-mysql-for-kolabdev.sh b/docker/kolab/utils/06-mysql-for-kolabdev.sh --- a/docker/kolab/utils/06-mysql-for-kolabdev.sh +++ b/docker/kolab/utils/06-mysql-for-kolabdev.sh @@ -1,11 +1,11 @@ #!/bin/bash -mysql -h 127.0.0.1 -u root --password=Welcome2KolabSystems \ - -e "CREATE DATABASE kolabdev;" +mysql -h ${DB_HOST:-127.0.0.1} -u root --password=${DB_ROOT_PASSWORD:-Welcome2KolabSystems} \ + -e "CREATE DATABASE IF NOT EXISTS ${DB_HKCCP_DATABASE:-kolabdev};" -mysql -h 127.0.0.1 -u root --password=Welcome2KolabSystems \ - -e "GRANT ALL PRIVILEGES ON kolabdev.* TO 'kolabdev'@'127.0.0.1' IDENTIFIED BY 'kolab';" +mysql -h ${DB_HOST:-127.0.0.1} -u root --password=${DB_ROOT_PASSWORD:-Welcome2KolabSystems} \ + -e "GRANT ALL PRIVILEGES ON ${DB_HKCCP_DATABASE:-kolabdev}.* TO '${DB_HKCCP_USERNAME:-kolabdev}'@'%' IDENTIFIED BY '${DB_HKCCP_PASSWORD:-kolab}';" -mysql -h 127.0.0.1 -u root --password=Welcome2KolabSystems \ +mysql -h ${DB_HOST:-127.0.0.1} -u root --password=${DB_ROOT_PASSWORD:-Welcome2KolabSystems} \ -e "FLUSH PRIVILEGES;" diff --git a/docker/kolab/utils/07-adjust-base-dns.sh b/docker/kolab/utils/07-adjust-base-dns.sh --- a/docker/kolab/utils/07-adjust-base-dns.sh +++ b/docker/kolab/utils/07-adjust-base-dns.sh @@ -6,6 +6,8 @@ sed -i -r \ -e "s/(\s+)base => '.*',$/\1base => '${hosted_domain_rootdn}',/g" \ + -e "/\\\$mydomain = / a\ +\$myhostname = '${HOSTNAME:-kolab}.${DOMAIN:-mgmt.com}';" \ -e "s/^base_dn = .*$/base_dn = ${hosted_domain_rootdn}/g" \ -e "s/^search_base = .*$/search_base = ${hosted_domain_rootdn}/g" \ -e "s/(\s+)'base_dn'(\s+)=> '.*',/\1'base_dn'\2=> '${hosted_domain_rootdn}',/g" \ diff --git a/docker/kolab/utils/10-reset-kolab-service-password.sh b/docker/kolab/utils/10-reset-kolab-service-password.sh --- a/docker/kolab/utils/10-reset-kolab-service-password.sh +++ b/docker/kolab/utils/10-reset-kolab-service-password.sh @@ -6,14 +6,14 @@ echo "dn: uid=kolab-service,ou=Special Users,${rootdn}" echo "changetype: modify" echo "replace: userpassword" - echo "userpassword: ${ldap_bindpw}" + echo "userpassword: ${kolab_service_pw}" echo "" ) | ldapmodify -x -h ${ldap_host} -D "${ldap_binddn}" -w "${ldap_bindpw}" oldpw=$(grep ^service_bind_pw /etc/kolab/kolab.conf | awk '{print $3}') sed -i -r \ - -e "s/${oldpw}/${ldap_bindpw}/g" \ + -e "s/${oldpw}/${kolab_service_pw}/g" \ $(grep -rn -- ${oldpw} /etc/ | awk -F':' '{print $1}' | sort -u) systemctl restart \ diff --git a/docker/kolab/utils/11-reset-cyrus-admin-password.sh b/docker/kolab/utils/11-reset-cyrus-admin-password.sh --- a/docker/kolab/utils/11-reset-cyrus-admin-password.sh +++ b/docker/kolab/utils/11-reset-cyrus-admin-password.sh @@ -6,14 +6,14 @@ echo "dn: uid=cyrus-admin,ou=Special Users,${rootdn}" echo "changetype: modify" echo "replace: userpassword" - echo "userpassword: ${ldap_bindpw}" + echo "userpassword: ${cyrus_admin_pw}" echo "" ) | ldapmodify -x -h ${ldap_host} -D "${ldap_binddn}" -w "${ldap_bindpw}" oldpw=$(grep ^admin_password /etc/kolab/kolab.conf | awk '{print $3}') sed -i -r \ - -e "s/${oldpw}/${ldap_bindpw}/g" \ + -e "s/${oldpw}/${cyrus_admin_pw}/g" \ /etc/kolab/kolab.conf systemctl restart kolabd wallace diff --git a/docker/kolab/utils/12-create-hosted-kolab-service.sh b/docker/kolab/utils/12-create-hosted-kolab-service.sh --- a/docker/kolab/utils/12-create-hosted-kolab-service.sh +++ b/docker/kolab/utils/12-create-hosted-kolab-service.sh @@ -1,6 +1,7 @@ #!/bin/bash - . ./settings.sh +. ./settings.sh + ( echo "dn: uid=hosted-kolab-service,ou=Special Users,${rootdn}" echo "objectclass: top" diff --git a/docker/kolab/utils/13-create-ou-domains.sh b/docker/kolab/utils/13-create-ou-domains.sh --- a/docker/kolab/utils/13-create-ou-domains.sh +++ b/docker/kolab/utils/13-create-ou-domains.sh @@ -1,6 +1,7 @@ #!/bin/bash . ./settings.sh + ( echo "dn: ou=Domains,${rootdn}" echo "ou: Domains" diff --git a/docker/kolab/utils/14-create-management-domain.sh b/docker/kolab/utils/14-create-management-domain.sh --- a/docker/kolab/utils/14-create-management-domain.sh +++ b/docker/kolab/utils/14-create-management-domain.sh @@ -1,6 +1,7 @@ #!/bin/bash - . ./settings.sh +. ./settings.sh + ( echo "dn: associateddomain=${domain},${domain_base_dn}" echo "aci: (targetattr = \"*\")(version 3.0;acl \"Deny Rest\";deny (all)(userdn != \"ldap:///uid=kolab-service,ou=Special Users,${rootdn} || ldap:///${rootdn}??sub?(objectclass=*)\");)" diff --git a/docker/kolab/utils/15-create-hosted-domain.sh b/docker/kolab/utils/15-create-hosted-domain.sh --- a/docker/kolab/utils/15-create-hosted-domain.sh +++ b/docker/kolab/utils/15-create-hosted-domain.sh @@ -3,7 +3,7 @@ . ./settings.sh ( - echo "dn: associateddomain=${hosted_domain},ou=Domains,${rootdn}" + echo "dn: associateddomain=${hosted_domain},${domain_base_dn}" echo "objectclass: top" echo "objectclass: domainrelatedobject" echo "objectclass: inetdomain" @@ -31,7 +31,7 @@ echo "nsslapd-cachememsize: 10485760" echo "nsslapd-readonly: off" echo "nsslapd-require-index: off" - echo "nsslapd-directory: /var/lib/dirsrv/slapd-$(hostname -s)/db/$(echo ${hosted_domain} | sed -e 's/\./_/g')" + echo "nsslapd-directory: /var/lib/dirsrv/slapd-${DS_INSTANCE_NAME:-$(hostname -s)}/db/$(echo ${hosted_domain} | sed -e 's/\./_/g')" echo "nsslapd-dncachememsize: 10485760" echo "" diff --git a/docker/kolab/utils/17-remove-hosted-service-access-from-mgmt-domain.sh b/docker/kolab/utils/17-remove-hosted-service-access-from-mgmt-domain.sh --- a/docker/kolab/utils/17-remove-hosted-service-access-from-mgmt-domain.sh +++ b/docker/kolab/utils/17-remove-hosted-service-access-from-mgmt-domain.sh @@ -1,6 +1,7 @@ #!/bin/bash - . ./settings.sh +. ./settings.sh + ( echo "dn: associateddomain=${domain},ou=Domains,${rootdn}" echo "changetype: modify" diff --git a/docker/kolab/utils/20-add-alias-attribute-index.sh b/docker/kolab/utils/20-add-alias-attribute-index.sh --- a/docker/kolab/utils/20-add-alias-attribute-index.sh +++ b/docker/kolab/utils/20-add-alias-attribute-index.sh @@ -1,6 +1,6 @@ #!/bin/bash - . ./settings.sh +. ./settings.sh export index_attr=alias diff --git a/docker/kolab/utils/settings.sh b/docker/kolab/utils/settings.sh --- a/docker/kolab/utils/settings.sh +++ b/docker/kolab/utils/settings.sh @@ -1,23 +1,24 @@ #!/bin/bash -export rootdn="dc=mgmt,dc=com" -export domain="mgmt.com" -export domain_db="mgmt_com" -export ldap_host="127.0.0.1" -export ldap_binddn="cn=Directory Manager" -export ldap_bindpw="Welcome2KolabSystems" +export rootdn=${LDAP_ADMIN_ROOT_DN:-"dc=mgmt,dc=com"} +export domain=${DOMAIN:-"mgmt.com"} +export domain_db=${DOMAIN_DB:-"mgmt_com"} +export ldap_host=${LDAP_HOST:-"127.0.0.1"} +export ldap_binddn=${LDAP_ADMIN_BIND_DN:-"cn=Directory Manager"} +export ldap_bindpw=${LDAP_ADMIN_BIND_PW:-"Welcome2KolabSystems"} -export cyrus_admin="cyrus-admin" +export cyrus_admin=${IMAP_ADMIN_LOGIN:-"cyrus-admin"} -export imap_host="127.0.0.1" -export cyrus_admin_pw="Welcome2KolabSystems" +export imap_host=${IMAP_HOST:-"127.0.0.1"} +export cyrus_admin_pw=${IMAP_ADMIN_PASSWORD:-"Welcome2KolabSystems"} -export hosted_kolab_service_pw="Welcome2KolabSystems" +export kolab_service_pw=${LDAP_SERVICE_BIND_PW:-"Welcome2KolabSystems"} +export hosted_kolab_service_pw=${LDAP_HOSTED_BIND_PW:-"Welcome2KolabSystems"} -export hosted_domain="hosted.com" -export hosted_domain_db="hosted_com" -export hosted_domain_rootdn="dc=hosted,dc=com" +export hosted_domain=${HOSTED_DOMAIN:-"hosted.com"} +export hosted_domain_db=${HOSTED_DOMAIN_DB:-"hosted_com"} +export hosted_domain_rootdn=${LDAP_HOSTED_ROOT_DN:-"dc=hosted,dc=com"} -export domain_base_dn="ou=Domains,dc=mgmt,dc=com" +export domain_base_dn=${LDAP_DOMAIN_BASE_DN:-"ou=Domains,dc=mgmt,dc=com"} -export default_user_password="Welcome2KolabSystems" +export default_user_password=${DEFAULT_USER_PASSWORD:-"Welcome2KolabSystems"} diff --git a/docker/mariadb/mysql-init/80-add-users.sh b/docker/mariadb/mysql-init/80-add-users.sh new file mode 100644 --- /dev/null +++ b/docker/mariadb/mysql-init/80-add-users.sh @@ -0,0 +1,29 @@ +create_arbitrary_users() { + + # Do not care what option is compulsory here, just create what is specified + log_info "Creating user specified by (${2}) ..." +mysql $mysql_flags <> $(rm -vrf vendor/ composer.lock)" + +if [ -f ".env.local" ]; then + # Ensure there's a line ending + echo "---->> Append .env.local" + echo "" >> .env + cat .env.local >> .env +fi + +#env + +/usr/libexec/s2i/assemble + +#cat >> /opt/app-root/etc/conf.d/99-loglevel.conf << EOF +#LogLevel warn mod_rewrite.c:trace4 +#EOF + +# Won't work due to: +# Cannot install, php_dir for channel "pecl.php.net" is not writeable by the current user +#pecl channel-update pecl.php.net +#pecl install swoole + +pushd /opt/app-root/src + +echo "---->> Run npm run prod" +npm install cross-env +npm run prod + diff --git a/src/.s2i/bin/run b/src/.s2i/bin/run new file mode 100755 --- /dev/null +++ b/src/.s2i/bin/run @@ -0,0 +1,60 @@ +#!/bin/bash + +shopt -s dotglob + +pushd /opt/app-root/src + +echo "----> Remove bootstrap cache" +find bootstrap/cache/ -type f ! -name ".gitignore" -delete + +if [ -z ${APP_KEY} ]; then + echo "----> Run artisan key:generate" + ./artisan key:generate +fi + +if [ -z ${JWT_SECRET} ]; then + echo "----> Run artisan jwt:secret" + ./artisan jwt:secret --always-no +fi + +echo "----> Run artisan clear-compiled" +./artisan clear-compiled + +echo "----> Run artisan cache:clear" +./artisan ${ARTISAN_VERBOSITY} cache:clear || true + +# rpm -qv chromium +# if [ ! -z "$(rpm -qv chromium 2>/dev/null)" ]; then +# echo "---- Run artisan dusk:chrome-driver" +# chver=$(rpmquery --queryformat="%{VERSION}" chromium | awk -F'.' '{print $1}') +# ./artisan dusk:chrome-driver ${chver} +# fi + +if [ ! -f 'resources/countries.php' ]; then + echo "----> Run artisan data:countries" + ./artisan data:countries +fi + +rm -rvf bootstrap/cache/ +mkdir -vp bootstrap/cache/ +chown default bootstrap/cache + +./artisan db:ping --wait || exit 1 + +./artisan migrate --force || : +#./artisan db:seed --force || : + +case ${HKCCP_APP} in + worker|WORKER ) + echo "----> Running worker " + ./artisan queue:work;; + server|SERVER ) + echo "----> Running server " + ./artisan serve;; + apache|APACHE|httpd|HTTPD ) + echo "----> Starting httpd " + /usr/libexec/s2i/run 2>&1;; + * ) + echo "----> Sleeping" + sleep 10000;; +esac diff --git a/src/app/Auth/LDAPUserProvider.php b/src/app/Auth/LDAPUserProvider.php --- a/src/app/Auth/LDAPUserProvider.php +++ b/src/app/Auth/LDAPUserProvider.php @@ -22,7 +22,7 @@ */ public function retrieveByCredentials(array $credentials) { - $entries = User::where('email', '=', $credentials['email'])->get(); + $entries = User::where('email', \strtolower($credentials['email']))->get(); $count = $entries->count(); @@ -51,7 +51,7 @@ { $authenticated = false; - if ($user->email == $credentials['email']) { + if ($user->email === \strtolower($credentials['email'])) { if (!empty($user->password)) { if (Hash::check($credentials['password'], $user->password)) { $authenticated = true; diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php --- a/src/app/Backends/LDAP.php +++ b/src/app/Backends/LDAP.php @@ -22,6 +22,8 @@ * Starts a new LDAP connection that will be used by all methods * until you call self::disconnect() explicitely. Normally every * method uses a separate connection. + * + * @throws \Exception */ public static function connect(): void { @@ -47,9 +49,9 @@ * * @param \App\Domain $domain The domain to create. * - * @return void + * @throws \Exception */ - public static function createDomain(Domain $domain) + public static function createDomain(Domain $domain): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); @@ -90,8 +92,14 @@ $dn = "associateddomain={$domain->namespace},{$config['domain_base_dn']}"; + self::setDomainAttributes($domain, $entry); + if (!$ldap->get_entry($dn)) { - $ldap->add_entry($dn, $entry); + $result = $ldap->add_entry($dn, $entry); + + if (!$result) { + self::throwException($ldap, "Failed to create domain {$domain->namespace} in LDAP"); + } } // create ou, roles, ous @@ -155,7 +163,7 @@ } } - foreach (['kolab-admin', 'billing-user'] as $item) { + foreach (['kolab-admin'] as $item) { if (!$ldap->get_entry("cn={$item},{$domainBaseDN}")) { $ldap->add_entry( "cn={$item},{$domainBaseDN}", @@ -174,6 +182,8 @@ } } + // TODO: Assign kolab-admin role to the owner? + if (empty(self::$ldap)) { $ldap->close(); } @@ -199,9 +209,9 @@ * * @param \App\User $user The user account to create. * - * @return bool|void + * @throws \Exception */ - public static function createUser(User $user) + public static function createUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); @@ -223,7 +233,11 @@ if (!self::getUserEntry($ldap, $user->email, $dn) && $dn) { self::setUserAttributes($user, $entry); - $ldap->add_entry($dn, $entry); + $result = $ldap->add_entry($dn, $entry); + + if (!$result) { + self::throwException($ldap, "Failed to create user {$user->email} in LDAP"); + } } if (empty(self::$ldap)) { @@ -232,25 +246,39 @@ } /** - * Update a domain in LDAP. + * Delete a domain from LDAP. * * @param \App\Domain $domain The domain to update. * - * @return void + * @throws \Exception */ - public static function updateDomain(Domain $domain) + public static function deleteDomain(Domain $domain): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); - $ldapDomain = $ldap->find_domain($domain->namespace); + $hostedRootDN = \config('ldap.hosted.root_dn'); + $mgmtRootDN = \config('ldap.admin.root_dn'); - $oldEntry = $ldap->get_entry($ldapDomain['dn']); - $newEntry = $oldEntry; + $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; - self::setDomainAttributes($domain, $newEntry); + if ($ldap->get_entry($domainBaseDN)) { + $result = $ldap->delete_entry_recursive($domainBaseDN); + + if (!$result) { + self::throwException($ldap, "Failed to delete domain {$domain->namespace} from LDAP"); + } + } - $ldap->modify_entry($ldapDomain['dn'], $oldEntry, $newEntry); + 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"); + } + } + } if (empty(self::$ldap)) { $ldap->close(); @@ -258,29 +286,22 @@ } /** - * Delete a domain from LDAP. + * Delete a user from LDAP. * - * @param \App\Domain $domain The domain to update. + * @param \App\User $user The user account to update. * - * @return void + * @throws \Exception */ - public static function deleteDomain(Domain $domain) + public static function deleteUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); - $hostedRootDN = \config('ldap.hosted.root_dn'); - $mgmtRootDN = \config('ldap.admin.root_dn'); - - $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; - - if ($ldap->get_entry($domainBaseDN)) { - $ldap->delete_entry_recursive($domainBaseDN); - } + if (self::getUserEntry($ldap, $user->email, $dn)) { + $result = $ldap->delete_entry($dn); - if ($ldap_domain = $ldap->find_domain($domain->namespace)) { - if ($ldap->get_entry($ldap_domain['dn'])) { - $ldap->delete_entry($ldap_domain['dn']); + if (!$result) { + self::throwException($ldap, "Failed to delete user {$user->email} from LDAP"); } } @@ -290,24 +311,29 @@ } /** - * Delete a user from LDAP. + * Get a domain data from LDAP. * - * @param \App\User $user The user account to update. + * @param string $namespace The domain name * - * @return void + * @return array|false|null + * @throws \Exception */ - public static function deleteUser(User $user) + public static function getDomain(string $namespace) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); - if (self::getUserEntry($ldap, $user->email, $dn)) { - $ldap->delete_entry($dn); + $ldapDomain = $ldap->find_domain($namespace); + + if ($ldapDomain) { + $domain = $ldap->get_entry($ldapDomain['dn']); } if (empty(self::$ldap)) { $ldap->close(); } + + return $domain ?? null; } /** @@ -316,6 +342,7 @@ * @param string $email The user email. * * @return array|false|null + * @throws \Exception */ public static function getUser(string $email) { @@ -331,14 +358,52 @@ 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"); + } + + if (empty(self::$ldap)) { + $ldap->close(); + } + } + /** * Update a user in LDAP. * * @param \App\User $user The user account to update. * - * @return false|void + * @throws \Exception */ - public static function updateUser(User $user) + public static function updateUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); @@ -346,12 +411,30 @@ $newEntry = $oldEntry = self::getUserEntry($ldap, $user->email, $dn, true); if (!$oldEntry) { - return false; + self::throwException($ldap, "Failed to update user {$user->email} in LDAP (user not found)"); } self::setUserAttributes($user, $newEntry); - $ldap->modify_entry($dn, $oldEntry, $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"); + } if (empty(self::$ldap)) { $ldap->close(); @@ -369,11 +452,17 @@ $ldap = new \Net_LDAP3($config); - $ldap->connect(); + $connected = $ldap->connect(); - $ldap->bind(\config("ldap.{$privilege}.bind_dn"), \config("ldap.{$privilege}.bind_pw")); + if (!$connected) { + throw new \Exception("Failed to connect to LDAP"); + } - // TODO: error handling + $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; } @@ -447,14 +536,6 @@ $hostedRootDN = \config('ldap.hosted.root_dn'); - if (empty($roles)) { - if (array_key_exists('nsroledn', $entry)) { - unset($entry['nsroledn']); - } - - return; - } - $entry['nsroledn'] = []; if (in_array("2fa", $roles)) { @@ -468,10 +549,6 @@ if (!in_array("groupware", $roles)) { $entry['nsroledn'][] = "cn=imap-user,{$hostedRootDN}"; } - - if (empty($entry['nsroledn'])) { - unset($entry['nsroledn']); - } } /** @@ -502,14 +579,14 @@ * * @return false|null|array User entry, False on error, NULL if not found */ - protected static function getUserEntry($ldap, $email, &$dn = null, $full = false) + private static function getUserEntry($ldap, $email, &$dn = null, $full = false) { list($_local, $_domain) = explode('@', $email, 2); $domain = $ldap->find_domain($_domain); if (!$domain) { - return false; + return $domain; } $base_dn = $ldap->domain_root_dn($_domain); @@ -582,4 +659,21 @@ \Log::{$function}($msg); } + + /** + * 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)) { + $ldap->close(); + } + + throw new \Exception($message); + } } diff --git a/src/app/Console/Commands/DataCountries.php b/src/app/Console/Commands/DataCountries.php --- a/src/app/Console/Commands/DataCountries.php +++ b/src/app/Console/Commands/DataCountries.php @@ -6,6 +6,11 @@ class DataCountries extends Command { + private $currency_fixes = [ + // Country code => currency + 'LT' => 'EUR', + ]; + /** * The name and signature of the console command. * @@ -29,111 +34,61 @@ { $countries = []; $currencies = []; - $currencies_url = 'http://en.wikipedia.org/wiki/ISO_4217'; - $countries_url = 'http://en.wikipedia.org/wiki/ISO_3166-1'; + $currencies_url = 'http://country.io/currency.json'; + $countries_url = 'http://country.io/names.json'; $this->info("Fetching currencies from $currencies_url..."); // fetch currency table and create an index by country page url - $page = file_get_contents($currencies_url); + $currencies_json = file_get_contents($currencies_url); - if (!$page) { + if (!$currencies_json) { $this->error("Failed to fetch currencies"); return; } - $table_regexp = '!!ims'; - if (preg_match_all($table_regexp, $page, $matches, PREG_PATTERN_ORDER)) { - foreach ($matches[0] as $currency_table) { - preg_match_all('!\s*\s*!Ums', $currency_table, $rows); - - foreach ($rows[1] as $row) { - $cells = preg_split('!\s*]*>!', $row); - - if (count($cells) == 5) { - // actual currency table - $currency = preg_match('/([A-Z]{3})/', $cells[0], $m) ? $m[1] : ''; - - if (preg_match('/(\d+)/', $cells[1], $m)) { - $isocode = $m[1]; - $currencies[$m[1]] = $currency; - } - - preg_match_all('!]+href="(/wiki/[^"]+)"[^>]*>!', $cells[4], $links, PREG_PATTERN_ORDER); - - foreach ($links[1] as $link) { - $currencies[strtolower($link)] = $currency; - } - } elseif (count($cells) == 7) { - // replacements table - $currency = preg_match('/([A-Z]{3})/', $cells[6], $m) ? $m[1] : ''; - - if (preg_match('/(\d+)/', $cells[1], $m)) { - $currencies[$m[1]] = $currency; - } - } - } - } - } - - $namecol = 0; - $codecol = 1; - $numcol = 3; - $lang = 'en'; - $this->info("Fetching countries from $countries_url..."); - $page = file_get_contents($countries_url); + $countries_json = file_get_contents($countries_url); - if (!$page) { + if (!$countries_json) { $this->error("Failed to fetch countries"); return; } - if (preg_match($table_regexp, $page, $matches)) { - preg_match_all('!\s*\s*!Ums', $matches[0], $rows); - - foreach ($rows[1] as $row) { - $cells = preg_split('!\s*]*>!', $row); - - if (count($cells) < 5) { - continue; - } - - $regexp = '!]+href="(/wiki/[^"]+)"[^>]*>([^>]+)!i'; - $content = preg_match($regexp, $cells[$namecol], $m) ? $m : null; - - if (preg_match('/>([A-Z]{2})error("Invalid countries data"); + return; + } + + if (!is_array($currencies) || empty($currencies)) { + $this->error("Invalid currencies data"); + return; } $file = resource_path('countries.php'); $this->info("Generating resource file $file..."); + asort($countries); + $out = " $names) { - if (!empty($names['en']) && !empty($names['currency'])) { - $out .= sprintf(" '%s' => ['%s','%s'],\n", $code, $names['currency'], addslashes($names['en'])); + foreach ($countries as $code => $name) { + $currency = $currencies[$code] ?? null; + + if (!empty($this->currency_fixes[$code])) { + $currency = $this->currency_fixes[$code]; + } + + if (!$currency) { + $this->error("Unknown currency for {$name} ({$code}). Skipped."); + continue; } + + $out .= sprintf(" '%s' => ['%s','%s'],\n", $code, $currency, addslashes($name)); } $out .= "];\n"; diff --git a/src/app/Console/Commands/DomainAdd.php b/src/app/Console/Commands/DomainAdd.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/DomainAdd.php @@ -0,0 +1,76 @@ +argument('domain')); + + // must use withTrashed(), because unique constraint + $domain = Domain::withTrashed()->where('namespace', $namespace)->first(); + + if ($domain && !$this->option('force')) { + $this->error("Domain {$namespace} already exists."); + return 1; + } + + Queue::fake(); // ignore LDAP for now + + if ($domain) { + if ($domain->deleted_at) { + // revive domain + $domain->deleted_at = null; + $domain->status = 0; + $domain->save(); + + // remove existing entitlement + $entitlement = Entitlement::withTrashed()->where( + [ + 'entitleable_id' => $domain->id, + 'entitleable_type' => \App\Domain::class + ] + )->first(); + + if ($entitlement) { + $entitlement->forceDelete(); + } + } else { + $this->error("Domain {$namespace} not marked as deleted... examine more closely"); + return 1; + } + } else { + $domain = Domain::create([ + 'namespace' => $namespace, + 'type' => Domain::TYPE_EXTERNAL, + ]); + } + + $this->info($domain->id); + } +} diff --git a/src/app/Console/Commands/DomainStatus.php b/src/app/Console/Commands/DomainSetStatus.php copy from src/app/Console/Commands/DomainStatus.php copy to src/app/Console/Commands/DomainSetStatus.php --- a/src/app/Console/Commands/DomainStatus.php +++ b/src/app/Console/Commands/DomainSetStatus.php @@ -4,32 +4,23 @@ use App\Domain; use Illuminate\Console\Command; +use Illuminate\Support\Facades\Queue; -class DomainStatus extends Command +class DomainSetStatus extends Command { /** * The name and signature of the console command. * * @var string */ - protected $signature = 'domain:status {domain}'; + protected $signature = 'domain:set-status {domain} {status}'; /** * The console command description. * * @var string */ - protected $description = 'Display the status of a domain'; - - /** - * Create a new command instance. - * - * @return void - */ - public function __construct() - { - parent::__construct(); - } + protected $description = "Set a domain's status."; /** * Execute the console command. @@ -44,7 +35,10 @@ return 1; } - $this->info("Found domain: {$domain->id}"); + Queue::fake(); // ignore LDAP for now + + $domain->status = (int) $this->argument('status'); + $domain->save(); $this->info($domain->status); } diff --git a/src/app/Console/Commands/DomainSetWallet.php b/src/app/Console/Commands/DomainSetWallet.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/DomainSetWallet.php @@ -0,0 +1,68 @@ +argument('domain'))->first(); + + if (!$domain) { + $this->error("Domain not found."); + return 1; + } + + $wallet = Wallet::find($this->argument('wallet')); + + if (!$wallet) { + $this->error("Wallet not found."); + return 1; + } + + if ($domain->entitlement) { + $this->error("Domain already assigned to a wallet: {$domain->entitlement->wallet->id}."); + return 1; + } + + $sku = Sku::where('title', 'domain-hosting')->first(); + + Queue::fake(); // ignore LDAP for now (note: adding entitlements updates the domain) + + Entitlement::create( + [ + 'wallet_id' => $wallet->id, + 'sku_id' => $sku->id, + 'cost' => 0, + 'entitleable_id' => $domain->id, + 'entitleable_type' => Domain::class, + ] + ); + } +} diff --git a/src/app/Console/Commands/DomainStatus.php b/src/app/Console/Commands/DomainStatus.php --- a/src/app/Console/Commands/DomainStatus.php +++ b/src/app/Console/Commands/DomainStatus.php @@ -44,8 +44,6 @@ return 1; } - $this->info("Found domain: {$domain->id}"); - $this->info($domain->status); } } diff --git a/src/app/Console/Commands/DomainStatus.php b/src/app/Console/Commands/Job/DomainCreate.php copy from src/app/Console/Commands/DomainStatus.php copy to src/app/Console/Commands/Job/DomainCreate.php --- a/src/app/Console/Commands/DomainStatus.php +++ b/src/app/Console/Commands/Job/DomainCreate.php @@ -1,35 +1,25 @@ info("Found domain: {$domain->id}"); - - $this->info($domain->status); + $job = new \App\Jobs\DomainCreate($domain); + $job->handle(); } } diff --git a/src/app/Console/Commands/DomainStatus.php b/src/app/Console/Commands/Job/DomainUpdate.php copy from src/app/Console/Commands/DomainStatus.php copy to src/app/Console/Commands/Job/DomainUpdate.php --- a/src/app/Console/Commands/DomainStatus.php +++ b/src/app/Console/Commands/Job/DomainUpdate.php @@ -1,35 +1,25 @@ info("Found domain: {$domain->id}"); - - $this->info($domain->status); + $job = new \App\Jobs\DomainUpdate($domain->id); + $job->handle(); } } diff --git a/src/app/Console/Commands/Job/UserCreate.php b/src/app/Console/Commands/Job/UserCreate.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Job/UserCreate.php @@ -0,0 +1,40 @@ +argument('user'))->first(); + + if (!$user) { + return 1; + } + + $job = new \App\Jobs\UserCreate($user); + $job->handle(); + } +} diff --git a/src/app/Console/Commands/Job/UserUpdate.php b/src/app/Console/Commands/Job/UserUpdate.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Job/UserUpdate.php @@ -0,0 +1,40 @@ +argument('user'))->first(); + + if (!$user) { + return 1; + } + + $job = new \App\Jobs\UserUpdate($user); + $job->handle(); + } +} diff --git a/src/app/Console/Commands/Job/WalletCheck.php b/src/app/Console/Commands/Job/WalletCheck.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Job/WalletCheck.php @@ -0,0 +1,40 @@ +argument('wallet')); + + if (!$wallet) { + return 1; + } + + $job = new \App\Jobs\WalletCheck($wallet); + $job->handle(); + } +} diff --git a/src/app/Console/Commands/UserAddAlias.php b/src/app/Console/Commands/UserAddAlias.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/UserAddAlias.php @@ -0,0 +1,69 @@ +argument('user'))->first(); + + if (!$user) { + return 1; + } + + $alias = \strtolower($this->argument('alias')); + + // Check if the alias already exists + if ($user->aliases()->where('alias', $alias)->first()) { + $this->error("Address is already assigned to the user."); + return 1; + } + + $controller = $user->wallet()->owner; + + // Validate the alias + $error = UsersController::validateEmail($alias, $controller, true); + + if ($error) { + if (!$this->option('force')) { + $this->error($error); + return 1; + } + } + + $user->aliases()->create(['alias' => $alias]); + } +} diff --git a/src/app/Console/Commands/DomainStatus.php b/src/app/Console/Commands/WalletAddTransaction.php copy from src/app/Console/Commands/DomainStatus.php copy to src/app/Console/Commands/WalletAddTransaction.php --- a/src/app/Console/Commands/DomainStatus.php +++ b/src/app/Console/Commands/WalletAddTransaction.php @@ -2,24 +2,23 @@ namespace App\Console\Commands; -use App\Domain; use Illuminate\Console\Command; -class DomainStatus extends Command +class WalletAddTransaction extends Command { /** * The name and signature of the console command. * * @var string */ - protected $signature = 'domain:status {domain}'; + protected $signature = 'wallet:add-transaction {wallet} {qty} {--message=}'; /** * The console command description. * * @var string */ - protected $description = 'Display the status of a domain'; + protected $description = 'Add a transaction to a wallet'; /** * Create a new command instance. @@ -38,14 +37,20 @@ */ public function handle() { - $domain = Domain::where('namespace', $this->argument('domain'))->first(); + $wallet = \App\Wallet::find($this->argument('wallet')); - if (!$domain) { + if (!$wallet) { return 1; } - $this->info("Found domain: {$domain->id}"); + $qty = (int) $this->argument('qty'); - $this->info($domain->status); + $message = $this->option('message'); + + if ($qty < 0) { + $wallet->debit($qty, $message); + } else { + $wallet->credit($qty, $message); + } } } diff --git a/src/app/Console/Commands/WalletCharge.php b/src/app/Console/Commands/WalletCharge.php --- a/src/app/Console/Commands/WalletCharge.php +++ b/src/app/Console/Commands/WalletCharge.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Wallet; use Illuminate\Console\Command; class WalletCharge extends Command @@ -11,7 +12,7 @@ * * @var string */ - protected $signature = 'wallet:charge'; + protected $signature = 'wallet:charge {wallet?}'; /** * The console command description. @@ -37,7 +38,22 @@ */ public function handle() { - $wallets = \App\Wallet::all(); + if ($wallet = $this->argument('wallet')) { + // Find specified wallet by ID + $wallet = Wallet::find($wallet); + + if (!$wallet || !$wallet->owner) { + return 1; + } + + $wallets = [$wallet]; + } else { + // Get all wallets, excluding deleted accounts + $wallets = Wallet::select('wallets.*') + ->join('users', 'users.id', '=', 'wallets.user_id') + ->whereNull('users.deleted_at') + ->get(); + } foreach ($wallets as $wallet) { $charge = $wallet->chargeEntitlements(); @@ -50,6 +66,11 @@ // Top-up the wallet if auto-payment enabled for the wallet \App\Jobs\WalletCharge::dispatch($wallet); } + + if ($wallet->balance < 0) { + // Check the account balance, send notifications, suspend, delete + \App\Jobs\WalletCheck::dispatch($wallet); + } } } } diff --git a/src/app/Domain.php b/src/app/Domain.php --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -399,7 +399,7 @@ public function wallet(): ?Wallet { // Note: Not all domains have a entitlement/wallet - $entitlement = $this->entitlement()->first(); + $entitlement = $this->entitlement()->withTrashed()->first(); return $entitlement ? $entitlement->wallet : null; } diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php --- a/src/app/Http/Controllers/API/AuthController.php +++ b/src/app/Http/Controllers/API/AuthController.php @@ -20,6 +20,11 @@ $user = Auth::guard()->user(); $response = V4\UsersController::userResponse($user); + if (!empty(request()->input('refresh_token'))) { + // @phpstan-ignore-next-line + return $this->respondWithToken(Auth::guard()->refresh(), $response); + } + return response()->json($response); } @@ -34,13 +39,7 @@ // @phpstan-ignore-next-line $token = Auth::guard()->login($user); - return response()->json([ - 'status' => 'success', - 'access_token' => $token, - 'token_type' => 'bearer', - // @phpstan-ignore-next-line - 'expires_in' => Auth::guard()->factory()->getTTL() * 60, - ]); + return self::respondWithToken($token, ['status' => 'success']); } /** @@ -109,19 +108,18 @@ /** * Get the token array structure. * - * @param string $token Respond with this token. + * @param string $token Respond with this token. + * @param array $response Additional response data * * @return \Illuminate\Http\JsonResponse */ - protected function respondWithToken($token) + protected static function respondWithToken($token, array $response = []) { - return response()->json( - [ - 'access_token' => $token, - 'token_type' => 'bearer', - // @phpstan-ignore-next-line - 'expires_in' => Auth::guard()->factory()->getTTL() * 60 - ] - ); + $response['access_token'] = $token; + $response['token_type'] = 'bearer'; + // @phpstan-ignore-next-line + $response['expires_in'] = Auth::guard()->factory()->getTTL() * 60; + + return response()->json($response); } } diff --git a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php --- a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php @@ -4,6 +4,7 @@ use App\Domain; use App\User; +use Illuminate\Http\Request; class DomainsController extends \App\Http\Controllers\API\V4\DomainsController { @@ -29,7 +30,7 @@ } } - $result = $result->sortBy('namespace'); + $result = $result->sortBy('namespace')->values(); } } elseif (!empty($search)) { if ($domain = Domain::where('namespace', $search)->first()) { @@ -52,4 +53,52 @@ return response()->json($result); } + + /** + * Suspend the domain + * + * @param \Illuminate\Http\Request $request The API request. + * @params string $id Domain identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function suspend(Request $request, $id) + { + $domain = Domain::find($id); + + if (empty($domain) || $domain->isPublic()) { + return $this->errorResponse(404); + } + + $domain->suspend(); + + return response()->json([ + 'status' => 'success', + 'message' => __('app.domain-suspend-success'), + ]); + } + + /** + * Un-Suspend the domain + * + * @param \Illuminate\Http\Request $request The API request. + * @params string $id Domain identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function unsuspend(Request $request, $id) + { + $domain = Domain::find($id); + + if (empty($domain) || $domain->isPublic()) { + return $this->errorResponse(404); + } + + $domain->unsuspend(); + + return response()->json([ + 'status' => 'success', + 'message' => __('app.domain-unsuspend-success'), + ]); + } } diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php --- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\API\V4\Admin; use App\Domain; +use App\Sku; use App\User; use App\UserAlias; use App\UserSetting; @@ -28,32 +29,34 @@ } } elseif (strpos($search, '@')) { // Search by email - $user = User::where('email', $search)->first(); - if ($user) { - $result->push($user); - } else { + $result = User::withTrashed()->where('email', $search) + ->orderBy('email')->get(); + + if ($result->isEmpty()) { // Search by an alias $user_ids = UserAlias::where('alias', $search)->get()->pluck('user_id'); - if ($user_ids->isEmpty()) { - // Search by an external email - $user_ids = UserSetting::where('key', 'external_email') - ->where('value', $search)->get()->pluck('user_id'); - } + + // Search by an external email + $ext_user_ids = UserSetting::where('key', 'external_email') + ->where('value', $search)->get()->pluck('user_id'); + + $user_ids = $user_ids->merge($ext_user_ids)->unique(); if (!$user_ids->isEmpty()) { - $result = User::whereIn('id', $user_ids)->orderBy('email')->get(); + $result = User::withTrashed()->whereIn('id', $user_ids) + ->orderBy('email')->get(); } } } elseif (is_numeric($search)) { // Search by user ID - if ($user = User::find($search)) { + if ($user = User::withTrashed()->find($search)) { $result->push($user); } } elseif (!empty($search)) { // Search by domain - if ($domain = Domain::where('namespace', $search)->first()) { + if ($domain = Domain::withTrashed()->where('namespace', $search)->first()) { if ($wallet = $domain->wallet()) { - $result->push($wallet->owner); + $result->push($wallet->owner()->withTrashed()->first()); } } } @@ -74,6 +77,36 @@ return response()->json($result); } + /** + * Reset 2-Factor Authentication for the user + * + * @param \Illuminate\Http\Request $request The API request. + * @params string $id User identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function reset2FA(Request $request, $id) + { + $user = User::find($id); + + if (empty($user)) { + return $this->errorResponse(404); + } + + $sku = Sku::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' => __('app.user-reset-2fa-success'), + ]); + } + /** * Suspend the user * diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php --- a/src/app/Http/Controllers/API/V4/PaymentsController.php +++ b/src/app/Http/Controllers/API/V4/PaymentsController.php @@ -265,6 +265,13 @@ return false; } + $provider = PaymentProvider::factory($wallet); + $mandate = (array) $provider->getMandate($wallet); + + if (empty($mandate['isValid'])) { + return false; + } + // The defined top-up amount is not enough // Disable auto-payment and notify the user if ($wallet->balance + $amount < 0) { @@ -274,13 +281,6 @@ return false; } - $provider = PaymentProvider::factory($wallet); - $mandate = (array) $provider->getMandate($wallet); - - if (empty($mandate['isValid'])) { - return false; - } - $request = [ 'type' => PaymentProvider::TYPE_RECURRING, 'currency' => 'CHF', diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -214,7 +214,17 @@ $state = 'failed'; } + // Check if the user is a controller of his wallet + $isController = $user->canDelete($user); + $hasCustomDomain = $user->wallet()->entitlements() + ->where('entitleable_type', Domain::class) + ->count() > 0; + return [ + // TODO: This will change when we enable all users to create domains + 'enableDomains' => $isController && $hasCustomDomain, + 'enableUsers' => $isController, + 'enableWallets' => $isController, 'process' => $process, 'processState' => $state, 'isReady' => $all === $checked, @@ -621,6 +631,10 @@ list($login, $domain) = explode('@', $email); + if (strlen($login) === 0 || strlen($domain) === 0) { + return \trans('validation.entryinvalid', ['attribute' => $attribute]); + } + // Check if domain exists $domain = Domain::where('namespace', Str::lower($domain))->first(); @@ -639,14 +653,7 @@ } // Check if it is one of domains available to the user - // TODO: We should have a helper that returns "flat" array with domain names - // I guess we could use pluck() somehow - $domains = array_map( - function ($domain) { - return $domain->namespace; - }, - $user->domains() - ); + $domains = \collect($user->domains())->pluck('namespace')->all(); if (!in_array($domain->namespace, $domains)) { return \trans('validation.entryexists', ['attribute' => 'domain']); @@ -655,7 +662,8 @@ // Check if a user/alias with specified address already exists // Allow assigning the same alias to a user in the same group account, // but only for non-public domains - if ($exists = User::emailExists($email, true, $alias_exists)) { + // Allow an alias in a custom domain to an address that was a user before + if ($exists = User::emailExists($email, true, $alias_exists, $is_alias && !$domain->isPublic())) { if ( !$is_alias || !$alias_exists diff --git a/src/app/Http/Controllers/API/V4/WalletsController.php b/src/app/Http/Controllers/API/V4/WalletsController.php --- a/src/app/Http/Controllers/API/V4/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/WalletsController.php @@ -284,24 +284,28 @@ */ 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; } - if ($wallet->owner->created_at > Carbon::now()->subDays(14)) { + // 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()) { - if ($wallet->owner->created_at > Carbon::now()->subDays(30)) { - return \trans('app.wallet-notice-trial-end'); - } - return \trans('app.wallet-notice-today'); } diff --git a/src/app/Http/Middleware/TrustProxies.php b/src/app/Http/Middleware/TrustProxies.php --- a/src/app/Http/Middleware/TrustProxies.php +++ b/src/app/Http/Middleware/TrustProxies.php @@ -12,7 +12,12 @@ * * @var array|string */ - protected $proxies = [ '127.0.0.1' ]; + 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. diff --git a/src/app/Jobs/PaymentEmail.php b/src/app/Jobs/PaymentEmail.php --- a/src/app/Jobs/PaymentEmail.php +++ b/src/app/Jobs/PaymentEmail.php @@ -62,25 +62,49 @@ $this->controller = $wallet->owner; } - $ext_email = $this->controller->getSetting('external_email'); - $cc = []; - - if ($ext_email && $ext_email != $this->controller->email) { - $cc[] = $ext_email; + 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; } - Mail::to($this->controller->email)->cc($cc)->send($mail); + list($to, $cc) = \App\Mail\Helper::userEmails($this->controller); + + if (!empty($to)) { + try { + Mail::to($to)->cc($cc)->send($mail); + + $msg = sprintf( + "[Payment] %s mail sent for %s (%s)", + $label, + $wallet->id, + empty($cc) ? $to : implode(', ', array_merge([$to], $cc)) + ); + + \Log::info($msg); + } catch (\Exception $e) { + $msg = sprintf( + "[Payment] Failed to send mail for wallet %s (%s): %s", + $wallet->id, + empty($cc) ? $to : implode(', ', array_merge([$to], $cc)), + $e->getMessage() + ); + + \Log::error($msg); + throw $e; + } + } /* // Send the email to all wallet controllers too diff --git a/src/app/Jobs/PaymentMandateDisabledEmail.php b/src/app/Jobs/PaymentMandateDisabledEmail.php --- a/src/app/Jobs/PaymentMandateDisabledEmail.php +++ b/src/app/Jobs/PaymentMandateDisabledEmail.php @@ -60,16 +60,37 @@ $this->controller = $this->wallet->owner; } - $ext_email = $this->controller->getSetting('external_email'); - $cc = []; - - if ($ext_email && $ext_email != $this->controller->email) { - $cc[] = $ext_email; + if (empty($this->controller)) { + return; } $mail = new PaymentMandateDisabled($this->wallet, $this->controller); - Mail::to($this->controller->email)->cc($cc)->send($mail); + list($to, $cc) = \App\Mail\Helper::userEmails($this->controller); + + if (!empty($to)) { + try { + Mail::to($to)->cc($cc)->send($mail); + + $msg = sprintf( + "[PaymentMandateDisabled] Sent mail for %s (%s)", + $this->wallet->id, + empty($cc) ? $to : implode(', ', array_merge([$to], $cc)) + ); + + \Log::info($msg); + } catch (\Exception $e) { + $msg = sprintf( + "[PaymentMandateDisabled] Failed to send mail for wallet %s (%s): %s", + $this->wallet->id, + empty($cc) ? $to : implode(', ', array_merge([$to], $cc)), + $e->getMessage() + ); + + \Log::error($msg); + throw $e; + } + } /* // Send the email to all controllers too diff --git a/src/app/Jobs/UserVerify.php b/src/app/Jobs/UserVerify.php --- a/src/app/Jobs/UserVerify.php +++ b/src/app/Jobs/UserVerify.php @@ -44,22 +44,7 @@ */ public function handle() { - // Verify a mailbox sku is among the user entitlements. - $skuMailbox = \App\Sku::where('title', 'mailbox')->first(); - - if (!$skuMailbox) { - return; - } - - $mailbox = \App\Entitlement::where( - [ - 'sku_id' => $skuMailbox->id, - 'entitleable_id' => $this->user->id, - 'entitleable_type' => User::class - ] - )->first(); - - if (!$mailbox) { + if (!$this->user->hasSku('mailbox')) { return; } diff --git a/src/app/Jobs/WalletCheck.php b/src/app/Jobs/WalletCheck.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/WalletCheck.php @@ -0,0 +1,311 @@ +wallet = $wallet; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + if ($this->wallet->balance >= 0) { + return; + } + + $now = Carbon::now(); + + // Delete the account + if (self::threshold($this->wallet, self::THRESHOLD_DELETE) < $now) { + $this->deleteAccount(); + return; + } + + // Warn about the upcomming account deletion + if (self::threshold($this->wallet, self::THRESHOLD_BEFORE_DELETE) < $now) { + $this->warnBeforeDelete(); + return; + } + + // Suspend the account + if (self::threshold($this->wallet, self::THRESHOLD_SUSPEND) < $now) { + $this->suspendAccount(); + return; + } + + // Send the second reminder + if (self::threshold($this->wallet, self::THRESHOLD_REMINDER) < $now) { + $this->secondReminder(); + return; + } + + // Send the initial reminder + if (self::threshold($this->wallet, self::THRESHOLD_INITIAL) < $now) { + $this->initialReminder(); + return; + } + } + + /** + * Send the initial reminder + */ + protected function initialReminder() + { + if ($this->wallet->getSetting('balance_warning_initial')) { + return; + } + + // TODO: Should we check if the account is already suspended? + + $label = "Notification sent for"; + + $this->sendMail(\App\Mail\NegativeBalance::class, false, $label); + + $now = \Carbon\Carbon::now()->toDateTimeString(); + $this->wallet->setSetting('balance_warning_initial', $now); + } + + /** + * Send the second reminder + */ + protected function secondReminder() + { + if ($this->wallet->getSetting('balance_warning_reminder')) { + return; + } + + // TODO: Should we check if the account is already suspended? + + $label = "Reminder sent for"; + + $this->sendMail(\App\Mail\NegativeBalanceReminder::class, false, $label); + + $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 ( + $entitlement->entitleable_type == \App\Domain::class + || $entitlement->entitleable_type == \App\User::class + ) { + $entitlement->entitleable->suspend(); + } + } + + $label = "Account suspended"; + + $this->sendMail(\App\Mail\NegativeBalanceSuspended::class, false, $label); + + $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; + } + + $label = "Last warning sent for"; + + $this->sendMail(\App\Mail\NegativeBalanceBeforeDelete::class, true, $label); + + $now = \Carbon\Carbon::now()->toDateTimeString(); + $this->wallet->setSetting('balance_warning_before_delete', $now); + } + + /** + * 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 + * @param ?string $log_label Log label + */ + protected function sendMail($class, $with_external = false, $log_label = null): 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)) { + try { + Mail::to($to)->cc($cc)->send($mail); + + if ($log_label) { + $msg = sprintf( + "[WalletCheck] %s %s (%s)", + $log_label, + $this->wallet->id, + empty($cc) ? $to : implode(', ', array_merge([$to], $cc)), + ); + + \Log::info($msg); + } + } catch (\Exception $e) { + $msg = sprintf( + "[WalletCheck] Failed to send mail for %s (%s): %s", + $this->wallet->id, + empty($cc) ? $to : implode(', ', array_merge([$to], $cc)), + $e->getMessage() + ); + + \Log::error($msg); + throw $e; + } + } + } + + /** + * 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); + } + + $remind = 7; // remind after first X days + $suspend = 14; // suspend after next X days + $delete = 21; // delete after next X days + $warn = 3; // warn about delete on X days before delete + + // Acount deletion + if ($type == self::THRESHOLD_DELETE) { + return $negative_since->addDays($delete + $suspend + $remind); + } + + // Warning about the upcomming account deletion + if ($type == self::THRESHOLD_BEFORE_DELETE) { + return $negative_since->addDays($delete + $suspend + $remind - $warn); + } + + // Account suspension + if ($type == self::THRESHOLD_SUSPEND) { + return $negative_since->addDays($suspend + $remind); + } + + // Second notification + if ($type == self::THRESHOLD_REMINDER) { + return $negative_since->addDays($remind); + } + + // 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); + } + + return null; + } +} diff --git a/src/app/Mail/Helper.php b/src/app/Mail/Helper.php --- a/src/app/Mail/Helper.php +++ b/src/app/Mail/Helper.php @@ -30,4 +30,32 @@ // HTML output return $mail->build()->render(); // @phpstan-ignore-line } + + /** + * Return user's email addresses, separately for use in To and Cc. + * + * @param \App\User $user The user + * @param bool $external Include users's external email + * + * @return array To address as the first element, Cc address(es) as the second. + */ + public static function userEmails(\App\User $user, bool $external = false): array + { + $to = $user->email; + $cc = []; + + // If user has no mailbox entitlement we should not send + // the email to his main address, but use external address, if defined + if (!$user->hasSku('mailbox')) { + $to = $user->getSetting('external_email'); + } elseif ($external) { + $ext_email = $user->getSetting('external_email'); + + if ($ext_email && $ext_email != $to) { + $cc[] = $ext_email; + } + } + + return [$to, $cc]; + } } diff --git a/src/app/Mail/NegativeBalance.php b/src/app/Mail/NegativeBalance.php --- a/src/app/Mail/NegativeBalance.php +++ b/src/app/Mail/NegativeBalance.php @@ -4,6 +4,7 @@ use App\User; use App\Utils; +use App\Wallet; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; @@ -13,20 +14,25 @@ use Queueable; use SerializesModels; - /** @var \App\User A user (account) that is behind with payments */ - protected $account; + /** @var \App\Wallet A wallet with a negative balance */ + protected $wallet; + + /** @var \App\User A wallet controller to whom the email is being sent */ + protected $user; /** * Create a new message instance. * - * @param \App\User $account A user (account) + * @param \App\Wallet $wallet A wallet + * @param \App\User $user An email recipient * * @return void */ - public function __construct(User $account) + public function __construct(Wallet $wallet, User $user) { - $this->account = $account; + $this->wallet = $wallet; + $this->user = $user; } /** @@ -36,8 +42,6 @@ */ public function build() { - $user = $this->account; - $subject = \trans('mail.negativebalance-subject', ['site' => \config('app.name')]); $this->view('emails.html.negative_balance') @@ -46,7 +50,7 @@ ->with([ 'site' => \config('app.name'), 'subject' => $subject, - 'username' => $user->name(true), + 'username' => $this->user->name(true), 'supportUrl' => \config('app.support_url'), 'walletUrl' => Utils::serviceUrl('/wallet'), ]); @@ -63,9 +67,10 @@ */ public static function fakeRender(string $type = 'html'): string { + $wallet = new Wallet(); $user = new User(); - $mail = new self($user); + $mail = new self($wallet, $user); return Helper::render($mail, $type); } diff --git a/src/app/Mail/NegativeBalance.php b/src/app/Mail/NegativeBalanceBeforeDelete.php copy from src/app/Mail/NegativeBalance.php copy to src/app/Mail/NegativeBalanceBeforeDelete.php --- a/src/app/Mail/NegativeBalance.php +++ b/src/app/Mail/NegativeBalanceBeforeDelete.php @@ -2,31 +2,38 @@ namespace App\Mail; +use App\Jobs\WalletCheck; use App\User; use App\Utils; +use App\Wallet; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; -class NegativeBalance extends Mailable +class NegativeBalanceBeforeDelete extends Mailable { use Queueable; use SerializesModels; - /** @var \App\User A user (account) that is behind with payments */ - protected $account; + /** @var \App\Wallet A wallet with a negative balance */ + protected $wallet; + + /** @var \App\User A wallet controller to whom the email is being sent */ + protected $user; /** * Create a new message instance. * - * @param \App\User $account A user (account) + * @param \App\Wallet $wallet A wallet + * @param \App\User $user An email recipient * * @return void */ - public function __construct(User $account) + public function __construct(Wallet $wallet, User $user) { - $this->account = $account; + $this->wallet = $wallet; + $this->user = $user; } /** @@ -36,19 +43,20 @@ */ public function build() { - $user = $this->account; + $threshold = WalletCheck::threshold($this->wallet, WalletCheck::THRESHOLD_DELETE); - $subject = \trans('mail.negativebalance-subject', ['site' => \config('app.name')]); + $subject = \trans('mail.negativebalancebeforedelete-subject', ['site' => \config('app.name')]); - $this->view('emails.html.negative_balance') - ->text('emails.plain.negative_balance') + $this->view('emails.html.negative_balance_before_delete') + ->text('emails.plain.negative_balance_before_delete') ->subject($subject) ->with([ 'site' => \config('app.name'), 'subject' => $subject, - 'username' => $user->name(true), + 'username' => $this->user->name(true), 'supportUrl' => \config('app.support_url'), 'walletUrl' => Utils::serviceUrl('/wallet'), + 'date' => $threshold->toDateString(), ]); return $this; @@ -63,9 +71,10 @@ */ public static function fakeRender(string $type = 'html'): string { + $wallet = new Wallet(); $user = new User(); - $mail = new self($user); + $mail = new self($wallet, $user); return Helper::render($mail, $type); } diff --git a/src/app/Mail/NegativeBalance.php b/src/app/Mail/NegativeBalanceReminder.php copy from src/app/Mail/NegativeBalance.php copy to src/app/Mail/NegativeBalanceReminder.php --- a/src/app/Mail/NegativeBalance.php +++ b/src/app/Mail/NegativeBalanceReminder.php @@ -2,31 +2,38 @@ namespace App\Mail; +use App\Jobs\WalletCheck; use App\User; use App\Utils; +use App\Wallet; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; -class NegativeBalance extends Mailable +class NegativeBalanceReminder extends Mailable { use Queueable; use SerializesModels; - /** @var \App\User A user (account) that is behind with payments */ - protected $account; + /** @var \App\Wallet A wallet with a negative balance */ + protected $wallet; + + /** @var \App\User A wallet controller to whom the email is being sent */ + protected $user; /** * Create a new message instance. * - * @param \App\User $account A user (account) + * @param \App\Wallet $wallet A wallet + * @param \App\User $user An email recipient * * @return void */ - public function __construct(User $account) + public function __construct(Wallet $wallet, User $user) { - $this->account = $account; + $this->wallet = $wallet; + $this->user = $user; } /** @@ -36,19 +43,20 @@ */ public function build() { - $user = $this->account; + $threshold = WalletCheck::threshold($this->wallet, WalletCheck::THRESHOLD_SUSPEND); - $subject = \trans('mail.negativebalance-subject', ['site' => \config('app.name')]); + $subject = \trans('mail.negativebalancereminder-subject', ['site' => \config('app.name')]); - $this->view('emails.html.negative_balance') - ->text('emails.plain.negative_balance') + $this->view('emails.html.negative_balance_reminder') + ->text('emails.plain.negative_balance_reminder') ->subject($subject) ->with([ 'site' => \config('app.name'), 'subject' => $subject, - 'username' => $user->name(true), + 'username' => $this->user->name(true), 'supportUrl' => \config('app.support_url'), 'walletUrl' => Utils::serviceUrl('/wallet'), + 'date' => $threshold->toDateString(), ]); return $this; @@ -63,9 +71,10 @@ */ public static function fakeRender(string $type = 'html'): string { + $wallet = new Wallet(); $user = new User(); - $mail = new self($user); + $mail = new self($wallet, $user); return Helper::render($mail, $type); } diff --git a/src/app/Mail/NegativeBalance.php b/src/app/Mail/NegativeBalanceSuspended.php copy from src/app/Mail/NegativeBalance.php copy to src/app/Mail/NegativeBalanceSuspended.php --- a/src/app/Mail/NegativeBalance.php +++ b/src/app/Mail/NegativeBalanceSuspended.php @@ -2,31 +2,38 @@ namespace App\Mail; +use App\Jobs\WalletCheck; use App\User; use App\Utils; +use App\Wallet; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; -class NegativeBalance extends Mailable +class NegativeBalanceSuspended extends Mailable { use Queueable; use SerializesModels; - /** @var \App\User A user (account) that is behind with payments */ - protected $account; + /** @var \App\Wallet A wallet with a negative balance */ + protected $wallet; + + /** @var \App\User A wallet controller to whom the email is being sent */ + protected $user; /** * Create a new message instance. * - * @param \App\User $account A user (account) + * @param \App\Wallet $wallet A wallet + * @param \App\User $user An email recipient * * @return void */ - public function __construct(User $account) + public function __construct(Wallet $wallet, User $user) { - $this->account = $account; + $this->wallet = $wallet; + $this->user = $user; } /** @@ -36,19 +43,20 @@ */ public function build() { - $user = $this->account; + $threshold = WalletCheck::threshold($this->wallet, WalletCheck::THRESHOLD_DELETE); - $subject = \trans('mail.negativebalance-subject', ['site' => \config('app.name')]); + $subject = \trans('mail.negativebalancesuspended-subject', ['site' => \config('app.name')]); - $this->view('emails.html.negative_balance') - ->text('emails.plain.negative_balance') + $this->view('emails.html.negative_balance_suspended') + ->text('emails.plain.negative_balance_suspended') ->subject($subject) ->with([ 'site' => \config('app.name'), 'subject' => $subject, - 'username' => $user->name(true), + 'username' => $this->user->name(true), 'supportUrl' => \config('app.support_url'), 'walletUrl' => Utils::serviceUrl('/wallet'), + 'date' => $threshold->toDateString(), ]); return $this; @@ -63,9 +71,10 @@ */ public static function fakeRender(string $type = 'html'): string { + $wallet = new Wallet(); $user = new User(); - $mail = new self($user); + $mail = new self($wallet, $user); return Helper::render($mail, $type); } diff --git a/src/app/Mail/PasswordReset.php b/src/app/Mail/PasswordReset.php --- a/src/app/Mail/PasswordReset.php +++ b/src/app/Mail/PasswordReset.php @@ -39,7 +39,7 @@ public function build() { $href = Utils::serviceUrl( - sprintf('/login/reset/%s-%s', $this->code->short_code, $this->code->code) + sprintf('/password-reset/%s-%s', $this->code->short_code, $this->code->code) ); $this->view('emails.html.password_reset') diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -97,18 +97,29 @@ { // Start calculating the costs for the consumption of this entitlement if the // existing consumption spans >= 14 days. - // anything's free for 14 days + // + // Effect is that anything's free for the first 14 days if ($entitlement->created_at >= Carbon::now()->subDays(14)) { return; } + $owner = $entitlement->wallet->owner; + + // Determine if we're still within the free first month + $freeMonthEnds = $owner->created_at->copy()->addMonthsWithoutOverflow(1); + + if ($freeMonthEnds >= Carbon::now()) { + return; + } + $cost = 0; + $now = Carbon::now(); // get the discount rate applied to the wallet. $discount = $entitlement->wallet->getDiscountRate(); // just in case this had not been billed yet, ever - $diffInMonths = $entitlement->updated_at->diffInMonths(Carbon::now()); + $diffInMonths = $entitlement->updated_at->diffInMonths($now); $cost += (int) ($entitlement->cost * $discount * $diffInMonths); // this moves the hypothetical updated at forward to however many months past the original @@ -116,26 +127,23 @@ // now we have the diff in days since the last "billed" period end. // This may be an entitlement paid up until February 28th, 2020, with today being March - // 12th 2020. Calculating the costs for the entitlement is based on the daily price for the - // past month -- i.e. $price/29 in the case at hand -- times the number of (full) days in - // between the period end and now. - // - // a) The number of days left in the past month, 1 - // b) The cost divided by the number of days in the past month, for example, 555/29, - // c) a) + Todays day-of-month, 12, so 13. - // + // 12th 2020. Calculating the costs for the entitlement is based on the daily price - $diffInDays = $updatedAt->diffInDays(Carbon::now()); + // 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 $daysInMonth=30 - $dayOfThisMonth = Carbon::now()->day; + $diffInDays = $updatedAt->diffInDays($now); - // days in the month for the month prior to this one. - // the price per day is based on the number of days left in the last month - $daysInLastMonth = \App\Utils::daysInLastMonth(); + if ($now->day >= $diffInDays) { + $daysInMonth = $now->daysInMonth; + } else { + $daysInMonth = \App\Utils::daysInLastMonth(); + } - $pricePerDay = (float)$entitlement->cost / $daysInLastMonth; + $pricePerDay = $entitlement->cost / $daysInMonth; - $cost += (int) (round($pricePerDay * $diffInDays, 0)); + $cost += (int) (round($pricePerDay * $discount * $diffInDays, 0)); if ($cost == 0) { return; diff --git a/src/app/Observers/UserAliasObserver.php b/src/app/Observers/UserAliasObserver.php --- a/src/app/Observers/UserAliasObserver.php +++ b/src/app/Observers/UserAliasObserver.php @@ -21,17 +21,22 @@ { $alias->alias = \strtolower($alias->alias); - if ($exists = User::emailExists($alias->alias, true, $alias_exists)) { + list($login, $domain) = explode('@', $alias->alias); + + $domain = Domain::where('namespace', $domain)->first(); + + if (!$domain) { + \Log::error("Failed creating alias {$alias->alias}. Domain does not exist."); + return false; + } +/* + if ($exists = User::emailExists($alias->alias, true, $alias_exists, !$domain->isPublic())) { if (!$alias_exists) { \Log::error("Failed creating alias {$alias->alias}. Email address exists."); return false; } - list($login, $domain) = explode('@', $alias->alias); - - $domain = Domain::where('namespace', $domain)->first(); - - if (!$domain || $domain->isPublic()) { + if ($domain->isPublic()) { \Log::error("Failed creating alias {$alias->alias}. Alias exists in public domain."); return false; } @@ -41,7 +46,7 @@ return false; } } - +*/ return true; } diff --git a/src/app/Observers/WalletObserver.php b/src/app/Observers/WalletObserver.php --- a/src/app/Observers/WalletObserver.php +++ b/src/app/Observers/WalletObserver.php @@ -69,4 +69,44 @@ return true; } + + /** + * Handle the wallet "updated" event. + * + * @param \App\Wallet $wallet The wallet. + * + * @return void + */ + public function updated(Wallet $wallet) + { + $negative_since = $wallet->getSetting('balance_negative_since'); + + if ($wallet->balance < 0) { + if (!$negative_since) { + $now = \Carbon\Carbon::now()->toDateTimeString(); + $wallet->setSetting('balance_negative_since', $now); + } + } elseif ($negative_since) { + $wallet->setSettings([ + 'balance_negative_since' => null, + 'balance_warning_initial' => null, + 'balance_warning_reminder' => null, + 'balance_warning_suspended' => null, + 'balance_warning_before_delete' => null, + ]); + + // Unsuspend the account/domains/users + if ($wallet->owner) { + $wallet->owner->unsuspend(); + } + foreach ($wallet->entitlements as $entitlement) { + if ( + $entitlement->entitleable_type == \App\Domain::class + || $entitlement->entitleable_type == \App\User::class + ) { + $entitlement->entitleable->unsuspend(); + } + } + } + } } diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -39,6 +40,8 @@ \App\VerificationCode::observe(\App\Observers\VerificationCodeObserver::class); \App\Wallet::observe(\App\Observers\WalletObserver::class); + Schema::defaultStringLength(191); + // Log SQL queries in debug mode if (\config('app.debug')) { DB::listen(function ($query) { diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php --- a/src/app/Providers/Payment/Mollie.php +++ b/src/app/Providers/Payment/Mollie.php @@ -18,7 +18,11 @@ */ public function customerLink(Wallet $wallet): ?string { - $customer_id = self::mollieCustomerId($wallet); + $customer_id = self::mollieCustomerId($wallet, false); + + if (!$customer_id) { + return null; + } return sprintf( '%s', @@ -43,7 +47,7 @@ public function createMandate(Wallet $wallet, array $payment): ?array { // Register the user in Mollie, if not yet done - $customer_id = self::mollieCustomerId($wallet); + $customer_id = self::mollieCustomerId($wallet, true); $request = [ 'amount' => [ @@ -54,7 +58,7 @@ 'sequenceType' => 'first', 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), - 'redirectUrl' => \url('/wallet'), + 'redirectUrl' => Utils::serviceUrl('/wallet'), 'locale' => 'en_US', // 'method' => 'creditcard', ]; @@ -155,7 +159,7 @@ } // Register the user in Mollie, if not yet done - $customer_id = self::mollieCustomerId($wallet); + $customer_id = self::mollieCustomerId($wallet, true); // Note: Required fields: description, amount/currency, amount/value @@ -171,7 +175,7 @@ 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'locale' => 'en_US', // 'method' => 'creditcard', - 'redirectUrl' => \url('/wallet') // required for non-recurring payments + 'redirectUrl' => Utils::serviceUrl('/wallet') // required for non-recurring payments ]; // TODO: Additional payment parameters for better fraud protection: @@ -211,7 +215,7 @@ return null; } - $customer_id = self::mollieCustomerId($wallet); + $customer_id = self::mollieCustomerId($wallet, true); // Note: Required fields: description, amount/currency, amount/value @@ -353,15 +357,16 @@ * Create one if does not exist yet. * * @param \App\Wallet $wallet The wallet + * @param bool $create Create the customer if does not exist yet * - * @return string Mollie customer identifier + * @return ?string Mollie customer identifier */ - protected static function mollieCustomerId(Wallet $wallet): string + protected static function mollieCustomerId(Wallet $wallet, bool $create = false): ?string { $customer_id = $wallet->getSetting('mollie_id'); // Register the user in Mollie - if (empty($customer_id)) { + if (empty($customer_id) && $create) { $customer = mollie()->customers()->create([ 'name' => $wallet->owner->name(), 'email' => $wallet->id . '@private.' . \config('app.domain'), diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php --- a/src/app/Providers/Payment/Stripe.php +++ b/src/app/Providers/Payment/Stripe.php @@ -29,7 +29,11 @@ */ public function customerLink(Wallet $wallet): ?string { - $customer_id = self::stripeCustomerId($wallet); + $customer_id = self::stripeCustomerId($wallet, false); + + if (!$customer_id) { + return null; + } $location = 'https://dashboard.stripe.com'; @@ -62,12 +66,12 @@ public function createMandate(Wallet $wallet, array $payment): ?array { // Register the user in Stripe, if not yet done - $customer_id = self::stripeCustomerId($wallet); + $customer_id = self::stripeCustomerId($wallet, true); $request = [ 'customer' => $customer_id, - 'cancel_url' => \url('/wallet'), // required - 'success_url' => \url('/wallet'), // required + 'cancel_url' => Utils::serviceUrl('/wallet'), // required + 'success_url' => Utils::serviceUrl('/wallet'), // required 'payment_method_types' => ['card'], // required 'locale' => 'en', 'mode' => 'setup', @@ -173,12 +177,12 @@ } // Register the user in Stripe, if not yet done - $customer_id = self::stripeCustomerId($wallet); + $customer_id = self::stripeCustomerId($wallet, true); $request = [ 'customer' => $customer_id, - 'cancel_url' => \url('/wallet'), // required - 'success_url' => \url('/wallet'), // required + 'cancel_url' => Utils::serviceUrl('/wallet'), // required + 'success_url' => Utils::serviceUrl('/wallet'), // required 'payment_method_types' => ['card'], // required 'locale' => 'en', 'line_items' => [ @@ -371,15 +375,16 @@ * Create one if does not exist yet. * * @param \App\Wallet $wallet The wallet + * @param bool $create Create the customer if does not exist yet * - * @return string Stripe customer identifier + * @return string|null Stripe customer identifier */ - protected static function stripeCustomerId(Wallet $wallet): string + protected static function stripeCustomerId(Wallet $wallet, bool $create = false): ?string { $customer_id = $wallet->getSetting('stripe_id'); // Register the user in Stripe - if (empty($customer_id)) { + if (empty($customer_id) && $create) { $customer = StripeAPI\Customer::create([ 'name' => $wallet->owner->name(), // Stripe will display the email on Checkout page, editable, diff --git a/src/app/Providers/RouteServiceProvider.php b/src/app/Providers/RouteServiceProvider.php --- a/src/app/Providers/RouteServiceProvider.php +++ b/src/app/Providers/RouteServiceProvider.php @@ -65,8 +65,9 @@ */ protected function mapApiRoutes() { - Route::prefix('api') - ->middleware('api') + // 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')); } diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -342,10 +342,11 @@ * @param string $email Email address * @param bool $return_user Return User instance instead of boolean * @param bool $is_alias Set to True if the existing email is an alias + * @param bool $existing Ignore deleted users * * @return \App\User|bool True or User model object if found, False otherwise */ - public static function emailExists(string $email, bool $return_user = false, &$is_alias = false) + public static function emailExists(string $email, bool $return_user = false, &$is_alias = false, $existing = false) { if (strpos($email, '@') === false) { return false; @@ -353,17 +354,28 @@ $email = \strtolower($email); - $user = self::withTrashed()->where('email', $email)->first(); + if ($existing) { + $user = self::where('email', $email)->first(); + } else { + $user = self::withTrashed()->where('email', $email)->first(); + } if ($user) { return $return_user ? $user : true; } - $alias = UserAlias::where('alias', $email)->first(); + $aliases = UserAlias::where('alias', $email); + + if ($existing) { + $aliases = $aliases->join('users', 'user_id', '=', 'users.id') + ->whereNull('users.deleted_at'); + } + + $alias = $aliases->first(); if ($alias) { $is_alias = true; - return $return_user ? User::withTrashed()->find($alias->user_id) : true; + return $return_user ? self::withTrashed()->find($alias->user_id) : true; } return false; @@ -415,6 +427,24 @@ return []; } + /** + * Check if user has an entitlement for the specified SKU. + * + * @param string $title The SKU title + * + * @return bool True if specified SKU entitlement exists + */ + public function hasSku($title): bool + { + $sku = Sku::where('title', $title)->first(); + + if (!$sku) { + return false; + } + + return $this->entitlements()->where('sku_id', $sku->id)->count() > 0; + } + /** * Returns whether this domain is active. * diff --git a/src/app/Utils.php b/src/app/Utils.php --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -157,16 +157,13 @@ */ public static function serviceUrl(string $route): string { - $url = \url($route); + $url = \config('app.public_url'); - $app_url = trim(\config('app.url'), '/'); - $pub_url = trim(\config('app.public_url'), '/'); - - if ($pub_url != $app_url) { - $url = str_replace($app_url, $pub_url, $url); + if (!$url) { + $url = \config('app.url'); } - return $url; + return rtrim(trim($url, '/') . '/' . ltrim($route, '/'), '/'); } /** diff --git a/src/app/Wallet.php b/src/app/Wallet.php --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -60,6 +60,22 @@ public function chargeEntitlements($apply = true) { + // 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; + } + $charges = 0; $discount = $this->getDiscountRate(); @@ -80,7 +96,7 @@ continue; } - // created more than a month ago -- was it billed? + // 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()); @@ -93,7 +109,9 @@ continue; } - $entitlement->updated_at = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diff); + $entitlement->updated_at = $entitlement->updated_at->copy() + ->addMonthsWithoutOverflow($diff); + $entitlement->save(); if ($cost == 0) { diff --git a/src/config/2fa.php b/src/config/2fa.php --- a/src/config/2fa.php +++ b/src/config/2fa.php @@ -3,12 +3,12 @@ return [ 'totp' => [ - 'digits' => (int) env('2FA_TOTP_DIGITS', 6), - 'interval' => (int) env('2FA_TOTP_INTERVAL', 30), - 'digest' => env('2FA_TOTP_DIGEST', 'sha1'), + 'digits' => (int) env('MFA_TOTP_DIGITS', 6), + 'interval' => (int) env('MFA_TOTP_INTERVAL', 30), + 'digest' => env('MFA_TOTP_DIGEST', 'sha1'), 'issuer' => env('APP_NAME', 'Laravel'), ], - 'dsn' => env('2FA_DSN'), + 'dsn' => env('MFA_DSN'), ]; diff --git a/src/config/database.php b/src/config/database.php --- a/src/config/database.php +++ b/src/config/database.php @@ -52,8 +52,8 @@ 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'unix_socket' => env('DB_SOCKET', ''), - 'charset' => 'utf8mb4', - 'collation' => 'utf8mb4_unicode_ci', + 'charset' => 'utf8', + 'collation' => 'utf8_unicode_ci', 'prefix' => '', 'prefix_indexes' => true, 'strict' => true, diff --git a/src/database/migrations/2019_09_17_102628_create_sku_entitlements.php b/src/database/migrations/2019_09_17_102628_create_sku_entitlements.php --- a/src/database/migrations/2019_09_17_102628_create_sku_entitlements.php +++ b/src/database/migrations/2019_09_17_102628_create_sku_entitlements.php @@ -19,8 +19,10 @@ function (Blueprint $table) { $table->string('id', 36)->primary(); $table->string('title', 64); - $table->json('name'); - $table->json('description'); + // were json, but mariadb + $table->text('name'); + $table->text('description'); + // end of $table->integer('cost'); $table->smallinteger('units_free')->default('0'); $table->string('period', strlen('monthly'))->default('monthly'); diff --git a/src/database/migrations/2019_12_10_095027_create_packages_table.php b/src/database/migrations/2019_12_10_095027_create_packages_table.php --- a/src/database/migrations/2019_12_10_095027_create_packages_table.php +++ b/src/database/migrations/2019_12_10_095027_create_packages_table.php @@ -19,8 +19,10 @@ function (Blueprint $table) { $table->string('id', 36); $table->string('title', 36); - $table->json('name'); - $table->json('description'); + // were json, but mariadb + $table->text('name'); + $table->text('description'); + // end of $table->integer('discount_rate')->default(0)->nullable(); $table->primary('id'); diff --git a/src/database/migrations/2019_12_10_105428_create_plans_table.php b/src/database/migrations/2019_12_10_105428_create_plans_table.php --- a/src/database/migrations/2019_12_10_105428_create_plans_table.php +++ b/src/database/migrations/2019_12_10_105428_create_plans_table.php @@ -19,8 +19,10 @@ function (Blueprint $table) { $table->string('id', 36); $table->string('title', 36); - $table->json('name'); - $table->json('description'); + // were json, but mariadb + $table->text('name'); + $table->text('description'); + // end of $table->datetime('promo_from')->nullable(); $table->datetime('promo_to')->nullable(); $table->integer('qty_min')->default(0)->nullable(); diff --git a/src/database/migrations/2020_03_30_100000_create_discounts.php b/src/database/migrations/2020_03_30_100000_create_discounts.php --- a/src/database/migrations/2020_03_30_100000_create_discounts.php +++ b/src/database/migrations/2020_03_30_100000_create_discounts.php @@ -19,7 +19,9 @@ function (Blueprint $table) { $table->string('id', 36); $table->tinyInteger('discount')->unsigned(); - $table->json('description'); + // was json, but mariadb + $table->text('description'); + // end of $table->string('code', 32)->nullable(); $table->boolean('active')->default(false); $table->timestamp('created_at')->useCurrent(); diff --git a/src/database/migrations/2019_12_10_095027_create_packages_table.php b/src/database/migrations/2020_09_02_150004_unique_discounts.php copy from src/database/migrations/2019_12_10_095027_create_packages_table.php copy to src/database/migrations/2020_09_02_150004_unique_discounts.php --- a/src/database/migrations/2019_12_10_095027_create_packages_table.php +++ b/src/database/migrations/2020_09_02_150004_unique_discounts.php @@ -1,11 +1,11 @@ string('id', 36); - $table->string('title', 36); - $table->json('name'); - $table->json('description'); - $table->integer('discount_rate')->default(0)->nullable(); - - $table->primary('id'); + $table->unique(['discount', 'description', 'code', 'active']); } ); } @@ -35,6 +29,11 @@ */ public function down() { - Schema::dropIfExists('packages'); + Schema::table( + 'discounts', + function (Blueprint $table) { + $table->dropUnique(['discount', 'description', 'code', 'active']); + } + ); } } diff --git a/src/database/seeds/production/PackageSeeder.php b/src/database/seeds/production/PackageSeeder.php --- a/src/database/seeds/production/PackageSeeder.php +++ b/src/database/seeds/production/PackageSeeder.php @@ -15,6 +15,7 @@ */ public function run() { + $skuActiveSync = Sku::firstOrCreate(['title' => 'activesync']); $skuGroupware = Sku::firstOrCreate(['title' => 'groupware']); $skuMailbox = Sku::firstOrCreate(['title' => 'mailbox']); $skuStorage = Sku::firstOrCreate(['title' => 'storage']); @@ -31,7 +32,8 @@ $skus = [ $skuMailbox, $skuGroupware, - $skuStorage + $skuStorage, + $skuActiveSync ]; $package->skus()->saveMany($skus); diff --git a/src/phpunit-fast b/src/phpunit-fast new file mode 120000 --- /dev/null +++ b/src/phpunit-fast @@ -0,0 +1 @@ +../bin/phpunit-fast \ No newline at end of file diff --git a/src/resources/countries.php b/src/resources/countries.php --- a/src/resources/countries.php +++ b/src/resources/countries.php @@ -1,6 +1,6 @@ ['AFN','Afghanistan'], - 'AX' => ['EUR','Åland Islands'], + 'AX' => ['EUR','Aland Islands'], 'AL' => ['ALL','Albania'], 'DZ' => ['DZD','Algeria'], 'AS' => ['USD','American Samoa'], @@ -14,60 +14,62 @@ 'AU' => ['AUD','Australia'], 'AT' => ['EUR','Austria'], 'AZ' => ['AZN','Azerbaijan'], + 'BS' => ['BSD','Bahamas'], 'BH' => ['BHD','Bahrain'], 'BD' => ['BDT','Bangladesh'], - 'BB' => ['USD','Barbados'], - 'BY' => ['BYN','Belarus'], + 'BB' => ['BBD','Barbados'], + 'BY' => ['BYR','Belarus'], 'BE' => ['EUR','Belgium'], 'BZ' => ['BZD','Belize'], 'BJ' => ['XOF','Benin'], - 'BM' => ['USD','Bermuda'], - 'BT' => ['INR','Bhutan'], - 'BO' => ['BOV','Bolivia (Plurinational State of)'], - 'BQ' => ['USD','Bonaire, Sint Eustatius and Saba'], + 'BM' => ['BMD','Bermuda'], + 'BT' => ['BTN','Bhutan'], + 'BO' => ['BOB','Bolivia'], + 'BQ' => ['USD','Bonaire, Saint Eustatius and Saba '], 'BA' => ['BAM','Bosnia and Herzegovina'], 'BW' => ['BWP','Botswana'], 'BV' => ['NOK','Bouvet Island'], 'BR' => ['BRL','Brazil'], 'IO' => ['USD','British Indian Ocean Territory'], + 'VG' => ['USD','British Virgin Islands'], + 'BN' => ['BND','Brunei'], 'BG' => ['BGN','Bulgaria'], 'BF' => ['XOF','Burkina Faso'], 'BI' => ['BIF','Burundi'], - 'KH' => ['USD','Cambodia'], + 'KH' => ['KHR','Cambodia'], 'CM' => ['XAF','Cameroon'], 'CA' => ['CAD','Canada'], + 'CV' => ['CVE','Cape Verde'], 'KY' => ['KYD','Cayman Islands'], 'CF' => ['XAF','Central African Republic'], 'TD' => ['XAF','Chad'], 'CL' => ['CLP','Chile'], 'CN' => ['CNY','China'], 'CX' => ['AUD','Christmas Island'], - 'CC' => ['AUD','Cocos (Keeling) Islands'], - 'CO' => ['COU','Colombia'], + 'CC' => ['AUD','Cocos Islands'], + 'CO' => ['COP','Colombia'], 'KM' => ['KMF','Comoros'], - 'CG' => ['XAF','Congo'], - 'CD' => ['CDF','Congo, Democratic Republic of the'], 'CK' => ['NZD','Cook Islands'], 'CR' => ['CRC','Costa Rica'], - 'CI' => ['XOF','Côte d\'Ivoire'], 'HR' => ['HRK','Croatia'], 'CU' => ['CUP','Cuba'], - 'CW' => ['ANG','Curaçao'], + 'CW' => ['ANG','Curacao'], 'CY' => ['EUR','Cyprus'], - 'CZ' => ['CZK','Czechia'], + 'CZ' => ['CZK','Czech Republic'], + 'CD' => ['CDF','Democratic Republic of the Congo'], 'DK' => ['DKK','Denmark'], 'DJ' => ['DJF','Djibouti'], 'DM' => ['XCD','Dominica'], 'DO' => ['DOP','Dominican Republic'], + 'TL' => ['USD','East Timor'], 'EC' => ['USD','Ecuador'], 'EG' => ['EGP','Egypt'], 'SV' => ['USD','El Salvador'], 'GQ' => ['XAF','Equatorial Guinea'], 'ER' => ['ERN','Eritrea'], 'EE' => ['EUR','Estonia'], - 'SZ' => ['SZL','Eswatini'], 'ET' => ['ETB','Ethiopia'], - 'FK' => ['FKP','Falkland Islands (Malvinas)'], + 'FK' => ['FKP','Falkland Islands'], 'FO' => ['DKK','Faroe Islands'], 'FJ' => ['FJD','Fiji'], 'FI' => ['EUR','Finland'], @@ -76,6 +78,7 @@ 'PF' => ['XPF','French Polynesia'], 'TF' => ['EUR','French Southern Territories'], 'GA' => ['XAF','Gabon'], + 'GM' => ['GMD','Gambia'], 'GE' => ['GEL','Georgia'], 'DE' => ['EUR','Germany'], 'GH' => ['GHS','Ghana'], @@ -86,10 +89,11 @@ 'GP' => ['EUR','Guadeloupe'], 'GU' => ['USD','Guam'], 'GT' => ['GTQ','Guatemala'], + 'GG' => ['GBP','Guernsey'], 'GN' => ['GNF','Guinea'], 'GW' => ['XOF','Guinea-Bissau'], 'GY' => ['GYD','Guyana'], - 'HT' => ['USD','Haiti'], + 'HT' => ['HTG','Haiti'], 'HM' => ['AUD','Heard Island and McDonald Islands'], 'HN' => ['HNL','Honduras'], 'HK' => ['HKD','Hong Kong'], @@ -97,12 +101,13 @@ 'IS' => ['ISK','Iceland'], 'IN' => ['INR','India'], 'ID' => ['IDR','Indonesia'], - 'IR' => ['IRR','Iran (Islamic Republic of)'], + 'IR' => ['IRR','Iran'], 'IQ' => ['IQD','Iraq'], 'IE' => ['EUR','Ireland'], 'IM' => ['GBP','Isle of Man'], 'IL' => ['ILS','Israel'], 'IT' => ['EUR','Italy'], + 'CI' => ['XOF','Ivory Coast'], 'JM' => ['JMD','Jamaica'], 'JP' => ['JPY','Japan'], 'JE' => ['GBP','Jersey'], @@ -110,20 +115,20 @@ 'KZ' => ['KZT','Kazakhstan'], 'KE' => ['KES','Kenya'], 'KI' => ['AUD','Kiribati'], - 'KP' => ['KPW','Korea (Democratic People\'s Republic of)'], - 'KR' => ['KRW','Korea, Republic of'], + 'XK' => ['EUR','Kosovo'], 'KW' => ['KWD','Kuwait'], 'KG' => ['KGS','Kyrgyzstan'], - 'LA' => ['LAK','Lao People\'s Democratic Republic'], + 'LA' => ['LAK','Laos'], 'LV' => ['EUR','Latvia'], 'LB' => ['LBP','Lebanon'], - 'LS' => ['ZAR','Lesotho'], + 'LS' => ['LSL','Lesotho'], 'LR' => ['LRD','Liberia'], 'LY' => ['LYD','Libya'], 'LI' => ['CHF','Liechtenstein'], 'LT' => ['EUR','Lithuania'], 'LU' => ['EUR','Luxembourg'], 'MO' => ['MOP','Macao'], + 'MK' => ['MKD','Macedonia'], 'MG' => ['MGA','Madagascar'], 'MW' => ['MWK','Malawi'], 'MY' => ['MYR','Malaysia'], @@ -132,12 +137,12 @@ 'MT' => ['EUR','Malta'], 'MH' => ['USD','Marshall Islands'], 'MQ' => ['EUR','Martinique'], - 'MR' => ['MRU','Mauritania'], + 'MR' => ['MRO','Mauritania'], 'MU' => ['MUR','Mauritius'], 'YT' => ['EUR','Mayotte'], - 'MX' => ['MXV','Mexico'], - 'FM' => ['USD','Micronesia (Federated States of)'], - 'MD' => ['MDL','Moldova, Republic of'], + 'MX' => ['MXN','Mexico'], + 'FM' => ['USD','Micronesia'], + 'MD' => ['MDL','Moldova'], 'MC' => ['EUR','Monaco'], 'MN' => ['MNT','Mongolia'], 'ME' => ['EUR','Montenegro'], @@ -145,9 +150,10 @@ 'MA' => ['MAD','Morocco'], 'MZ' => ['MZN','Mozambique'], 'MM' => ['MMK','Myanmar'], - 'NA' => ['ZAR','Namibia'], + 'NA' => ['NAD','Namibia'], 'NR' => ['AUD','Nauru'], 'NP' => ['NPR','Nepal'], + 'NL' => ['EUR','Netherlands'], 'NC' => ['XPF','New Caledonia'], 'NZ' => ['NZD','New Zealand'], 'NI' => ['NIO','Nicaragua'], @@ -155,13 +161,14 @@ 'NG' => ['NGN','Nigeria'], 'NU' => ['NZD','Niue'], 'NF' => ['AUD','Norfolk Island'], - 'MK' => ['MKD','North Macedonia'], + 'KP' => ['KPW','North Korea'], 'MP' => ['USD','Northern Mariana Islands'], 'NO' => ['NOK','Norway'], 'OM' => ['OMR','Oman'], 'PK' => ['PKR','Pakistan'], 'PW' => ['USD','Palau'], - 'PA' => ['USD','Panama'], + 'PS' => ['ILS','Palestinian Territory'], + 'PA' => ['PAB','Panama'], 'PG' => ['PGK','Papua New Guinea'], 'PY' => ['PYG','Paraguay'], 'PE' => ['PEN','Peru'], @@ -171,42 +178,49 @@ 'PT' => ['EUR','Portugal'], 'PR' => ['USD','Puerto Rico'], 'QA' => ['QAR','Qatar'], - 'RE' => ['EUR','Réunion'], + 'CG' => ['XAF','Republic of the Congo'], + 'RE' => ['EUR','Reunion'], 'RO' => ['RON','Romania'], - 'RU' => ['RUB','Russian Federation'], + 'RU' => ['RUB','Russia'], 'RW' => ['RWF','Rwanda'], - 'BL' => ['EUR','Saint Barthélemy'], + 'BL' => ['EUR','Saint Barthelemy'], + 'SH' => ['SHP','Saint Helena'], 'KN' => ['XCD','Saint Kitts and Nevis'], 'LC' => ['XCD','Saint Lucia'], - 'MF' => ['EUR','Saint Martin (French part)'], + 'MF' => ['EUR','Saint Martin'], 'PM' => ['EUR','Saint Pierre and Miquelon'], 'VC' => ['XCD','Saint Vincent and the Grenadines'], 'WS' => ['WST','Samoa'], 'SM' => ['EUR','San Marino'], + 'ST' => ['STD','Sao Tome and Principe'], 'SA' => ['SAR','Saudi Arabia'], 'SN' => ['XOF','Senegal'], 'RS' => ['RSD','Serbia'], 'SC' => ['SCR','Seychelles'], 'SL' => ['SLL','Sierra Leone'], 'SG' => ['SGD','Singapore'], - 'SX' => ['ANG','Sint Maarten (Dutch part)'], + 'SX' => ['ANG','Sint Maarten'], 'SK' => ['EUR','Slovakia'], 'SI' => ['EUR','Slovenia'], 'SB' => ['SBD','Solomon Islands'], 'SO' => ['SOS','Somalia'], 'ZA' => ['ZAR','South Africa'], + 'GS' => ['GBP','South Georgia and the South Sandwich Islands'], + 'KR' => ['KRW','South Korea'], 'SS' => ['SSP','South Sudan'], 'ES' => ['EUR','Spain'], 'LK' => ['LKR','Sri Lanka'], 'SD' => ['SDG','Sudan'], 'SR' => ['SRD','Suriname'], + 'SJ' => ['NOK','Svalbard and Jan Mayen'], + 'SZ' => ['SZL','Swaziland'], 'SE' => ['SEK','Sweden'], - 'CH' => ['CHW','Switzerland'], - 'SY' => ['SYP','Syrian Arab Republic'], + 'CH' => ['CHF','Switzerland'], + 'SY' => ['SYP','Syria'], + 'TW' => ['TWD','Taiwan'], 'TJ' => ['TJS','Tajikistan'], - 'TZ' => ['TZS','Tanzania, United Republic of'], + 'TZ' => ['TZS','Tanzania'], 'TH' => ['THB','Thailand'], - 'TL' => ['USD','Timor-Leste'], 'TG' => ['XOF','Togo'], 'TK' => ['NZD','Tokelau'], 'TO' => ['TOP','Tonga'], @@ -216,22 +230,22 @@ 'TM' => ['TMT','Turkmenistan'], 'TC' => ['USD','Turks and Caicos Islands'], 'TV' => ['AUD','Tuvalu'], + 'VI' => ['USD','U.S. Virgin Islands'], 'UG' => ['UGX','Uganda'], 'UA' => ['UAH','Ukraine'], 'AE' => ['AED','United Arab Emirates'], - 'GB' => ['GBP','United Kingdom of Great Britain and Northern Ireland'], - 'US' => ['USN','United States of America'], + 'GB' => ['GBP','United Kingdom'], + 'US' => ['USD','United States'], 'UM' => ['USD','United States Minor Outlying Islands'], - 'UY' => ['UYW','Uruguay'], + 'UY' => ['UYU','Uruguay'], 'UZ' => ['UZS','Uzbekistan'], 'VU' => ['VUV','Vanuatu'], - 'VE' => ['VES','Venezuela (Bolivarian Republic of)'], - 'VN' => ['VND','Viet Nam'], - 'VG' => ['USD','Virgin Islands (British)'], - 'VI' => ['USD','Virgin Islands (U.S.)'], + 'VA' => ['EUR','Vatican'], + 'VE' => ['VEF','Venezuela'], + 'VN' => ['VND','Vietnam'], 'WF' => ['XPF','Wallis and Futuna'], 'EH' => ['MAD','Western Sahara'], 'YE' => ['YER','Yemen'], - 'ZM' => ['ZMW','Zambia'], + 'ZM' => ['ZMK','Zambia'], 'ZW' => ['ZWL','Zimbabwe'], ]; diff --git a/src/resources/js/app.js b/src/resources/js/app.js --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -72,7 +72,6 @@ let timeout = response.expires_in || 0 // We'll refresh 60 seconds before the token expires - // or immediately when we have no expiration time (on token re-use) if (timeout > 60) { timeout -= 60 } @@ -85,7 +84,6 @@ axios.post('/api/auth/refresh').then(response => { this.loginUser(response.data, false, true) }) - }, timeout * 1000) }, // Set user state to "not logged in" @@ -113,7 +111,7 @@ startLoading() { this.isLoading = true // Lock the UI with the 'loading...' element - let loading = $('#app > .app-loader').show() + let loading = $('#app > .app-loader').removeClass('fadeOut') if (!loading.length) { $('#app').append($(loader)) } @@ -183,7 +181,7 @@ }) }, price(price, currency) { - return (price/100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' }) + return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' }) }, priceLabel(cost, units = 1, discount) { let index = '' @@ -201,7 +199,10 @@ }, clickRecord(event) { if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) { - $(event.target).closest('tr').find('a')[0].click() + let link = $(event.target).closest('tr').find('a')[0] + if (link) { + link.click() + } } }, domainStatusClass(domain) { diff --git a/src/resources/js/bootstrap.js b/src/resources/js/bootstrap.js --- a/src/resources/js/bootstrap.js +++ b/src/resources/js/bootstrap.js @@ -50,7 +50,16 @@ Vue.use(VueRouter) +let vueRouterBase = '/' +try { + let url = new URL(window.config['app.url']) + vueRouterBase = url.pathname +} catch(e) { + // ignore +} + window.router = new VueRouter({ + base: vueRouterBase, mode: 'history', routes: window.routes, scrollBehavior (to, from, savedPosition) { @@ -88,5 +97,5 @@ */ window.axios = require('axios') - +axios.defaults.baseURL = vueRouterBase axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest' diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -31,12 +31,15 @@ 'domain-verify-success' => 'Domain verified successfully.', 'domain-verify-error' => 'Domain ownership verification failed.', + 'domain-suspend-success' => 'Domain suspended successfully.', + 'domain-unsuspend-success' => 'Domain unsuspended successfully.', 'user-update-success' => 'User data updated successfully.', 'user-create-success' => 'User created successfully.', 'user-delete-success' => 'User deleted successfully.', 'user-suspend-success' => 'User suspended successfully.', 'user-unsuspend-success' => 'User unsuspended successfully.', + 'user-reset-2fa-success' => '2-Factor authentication reset successfully.', 'search-foundxdomains' => ':x domains have been found.', 'search-foundxusers' => ':x user accounts have been found.', diff --git a/src/resources/lang/en/mail.php b/src/resources/lang/en/mail.php --- a/src/resources/lang/en/mail.php +++ b/src/resources/lang/en/mail.php @@ -17,11 +17,30 @@ 'more-info-html' => "See here for more information.", 'more-info-text' => "See :href for more information.", - 'negativebalance-subject' => ":site Payment Reminder", - 'negativebalance-body' => "It has probably skipped your attention that you are behind on paying for your :site account. " - . "Consider setting up auto-payment to avoid messages like this in the future.", + 'negativebalance-subject' => ":site Payment Required", + 'negativebalance-body' => "This is a notification to let you know that your :site account balance has run into the negative and requires your attention. " + . "Consider setting up an automatic payment to avoid messages like this in the future.", 'negativebalance-body-ext' => "Settle up to keep your account running:", + 'negativebalancereminder-subject' => ":site Payment Reminder", + 'negativebalancereminder-body' => "It has probably skipped your attention that you are behind on paying for your :site account. " + . "Consider setting up an automatic payment to avoid messages like this in the future.", + 'negativebalancereminder-body-ext' => "Settle up to keep your account running:", + 'negativebalancereminder-body-warning' => "Please, be aware that your account will be suspended " + . "if your account balance is not settled by :date.", + + 'negativebalancesuspended-subject' => ":site Account Suspended", + 'negativebalancesuspended-body' => "Your :site account has been suspended for having a negative balance for too long. " + . "Consider setting up an automatic payment to avoid messages like this in the future.", + 'negativebalancesuspended-body-ext' => "Settle up now to unsuspend your account:", + 'negativebalancesuspended-body-warning' => "Please, be aware that your account and all its data will be deleted " + . "if your account balance is not settled by :date.", + + 'negativebalancebeforedelete-subject' => ":site Final Warning", + 'negativebalancebeforedelete-body' => "This is a final reminder to settle your :site account balance. " + . "Your account and all its data will be deleted if your account balance is not settled by :date.", + 'negativebalancebeforedelete-body-ext' => "Settle up now to keep your account:", + 'passwordreset-subject' => ":site Password Reset", 'passwordreset-body1' => "Someone recently asked to change your :site password.", 'passwordreset-body2' => "If this was you, use this verification code to complete the process:", diff --git a/src/resources/sass/app.scss b/src/resources/sass/app.scss --- a/src/resources/sass/app.scss +++ b/src/resources/sass/app.scss @@ -113,6 +113,7 @@ td { vertical-align: middle; height: 8em; + border: 0; } tbody:not(:empty) + & { @@ -319,6 +320,16 @@ margin-bottom: 0.5rem; } + .nav-tabs { + flex-wrap: nowrap; + overflow-x: auto; + + .nav-link { + white-space: nowrap; + padding: 0.5rem 0.75rem; + } + } + .tab-content { margin-top: 0.5rem; } @@ -343,6 +354,7 @@ #app > div.container { margin-bottom: 1rem; margin-top: 1rem; + max-width: 100%; } #header-menu-navbar { @@ -367,4 +379,55 @@ } } } + + .table.transactions { + thead { + display: none; + } + + tbody { + tr { + position: relative; + display: flex; + flex-wrap: wrap; + } + + td { + width: auto; + border: 0; + padding: 0.5rem; + + &.datetime { + width: 50%; + padding-left: 0; + } + + &.description { + order: 3; + width: 100%; + border-bottom: 1px solid $border-color; + color: $secondary; + padding: 0 1.5em 0.5rem 0; + margin-top: -0.25em; + } + + &.selection { + position: absolute; + right: 0; + border: 0; + top: 1.7em; + padding-right: 0; + } + + &.price { + width: 50%; + padding-right: 0; + } + + &.email { + display: none; + } + } + } + } } diff --git a/src/resources/sass/menu.scss b/src/resources/sass/menu.scss --- a/src/resources/sass/menu.scss +++ b/src/resources/sass/menu.scss @@ -38,6 +38,7 @@ #footer-menu { background-color: $main-color; height: 100px; + overflow: hidden; .navbar-brand { margin: 0; @@ -76,16 +77,16 @@ } } - #footer-menu { + .navbar { .navbar { - flex-direction: column; - align-items: flex-end; + justify-content: flex-end; } } - .navbar { + #footer-menu { .navbar { - justify-content: flex-end; + flex-direction: column; + align-items: flex-end; } } } diff --git a/src/resources/views/documents/receipt.blade.php b/src/resources/views/documents/receipt.blade.php --- a/src/resources/views/documents/receipt.blade.php +++ b/src/resources/views/documents/receipt.blade.php @@ -26,7 +26,7 @@ {!! $customer['customer'] !!} diff --git a/src/resources/views/emails/html/negative_balance_before_delete.blade.php b/src/resources/views/emails/html/negative_balance_before_delete.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/html/negative_balance_before_delete.blade.php @@ -0,0 +1,21 @@ + + + + + + +

{{ __('mail.header', ['name' => $username]) }}

+ +

{{ __('mail.negativebalancebeforedelete-body', ['site' => $site, 'date' => $date]) }}

+

{{ __('mail.negativebalancebeforedelete-body-ext', ['site' => $site]) }}

+

{{ $walletUrl }}

+ +@if ($supportUrl) +

{{ __('mail.support', ['site' => $site]) }}

+

{{ $supportUrl }}

+@endif + +

{{ __('mail.footer1') }}

+

{{ __('mail.footer2', ['site' => $site]) }}

+ + diff --git a/src/resources/views/emails/html/negative_balance_reminder.blade.php b/src/resources/views/emails/html/negative_balance_reminder.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/html/negative_balance_reminder.blade.php @@ -0,0 +1,22 @@ + + + + + + +

{{ __('mail.header', ['name' => $username]) }}

+ +

{{ __('mail.negativebalancereminder-body', ['site' => $site]) }}

+

{{ __('mail.negativebalancereminder-body-ext', ['site' => $site]) }}

+

{{ $walletUrl }}

+

{{ __('mail.negativebalancereminder-body-warning', ['site' => $site, 'date' => $date]) }}

+ +@if ($supportUrl) +

{{ __('mail.support', ['site' => $site]) }}

+

{{ $supportUrl }}

+@endif + +

{{ __('mail.footer1') }}

+

{{ __('mail.footer2', ['site' => $site]) }}

+ + diff --git a/src/resources/views/emails/html/negative_balance_suspended.blade.php b/src/resources/views/emails/html/negative_balance_suspended.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/html/negative_balance_suspended.blade.php @@ -0,0 +1,22 @@ + + + + + + +

{{ __('mail.header', ['name' => $username]) }}

+ +

{{ __('mail.negativebalancesuspended-body', ['site' => $site]) }}

+

{{ __('mail.negativebalancesuspended-body-ext', ['site' => $site]) }}

+

{{ $walletUrl }}

+

{{ __('mail.negativebalancesuspended-body-warning', ['site' => $site, 'date' => $date]) }}

+ +@if ($supportUrl) +

{{ __('mail.support', ['site' => $site]) }}

+

{{ $supportUrl }}

+@endif + +

{{ __('mail.footer1') }}

+

{{ __('mail.footer2', ['site' => $site]) }}

+ + diff --git a/src/resources/views/emails/plain/negative_balance_before_delete.blade.php b/src/resources/views/emails/plain/negative_balance_before_delete.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/plain/negative_balance_before_delete.blade.php @@ -0,0 +1,17 @@ +{!! __('mail.header', ['name' => $username]) !!} + +{!! __('mail.negativebalancebeforedelete-body', ['site' => $site, 'date' => $date]) !!} + +{!! __('mail.negativebalancebeforedelete-body-ext', ['site' => $site]) !!} + +{!! $walletUrl !!} + +@if ($supportUrl) +{!! __('mail.support', ['site' => $site]) !!} + +{!! $supportUrl !!} +@endif + +-- +{!! __('mail.footer1') !!} +{!! __('mail.footer2', ['site' => $site]) !!} diff --git a/src/resources/views/emails/plain/negative_balance_reminder.blade.php b/src/resources/views/emails/plain/negative_balance_reminder.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/plain/negative_balance_reminder.blade.php @@ -0,0 +1,19 @@ +{!! __('mail.header', ['name' => $username]) !!} + +{!! __('mail.negativebalancereminder-body', ['site' => $site]) !!} + +{!! __('mail.negativebalancereminder-body-ext', ['site' => $site]) !!} + +{!! $walletUrl !!} + +{!! __('mail.negativebalancereminder-body-warning', ['site' => $site, 'date' => $date]) !!} + +@if ($supportUrl) +{!! __('mail.support', ['site' => $site]) !!} + +{!! $supportUrl !!} +@endif + +-- +{!! __('mail.footer1') !!} +{!! __('mail.footer2', ['site' => $site]) !!} diff --git a/src/resources/views/emails/plain/negative_balance_suspended.blade.php b/src/resources/views/emails/plain/negative_balance_suspended.blade.php new file mode 100644 --- /dev/null +++ b/src/resources/views/emails/plain/negative_balance_suspended.blade.php @@ -0,0 +1,19 @@ +{!! __('mail.header', ['name' => $username]) !!} + +{!! __('mail.negativebalancesuspended-body', ['site' => $site]) !!} + +{!! __('mail.negativebalancesuspended-body-ext', ['site' => $site]) !!} + +{!! $walletUrl !!} + +{!! __('mail.negativebalancesuspended-body-warning', ['site' => $site, 'date' => $date]) !!} + +@if ($supportUrl) +{!! __('mail.support', ['site' => $site]) !!} + +{!! $supportUrl !!} +@endif + +-- +{!! __('mail.footer1') !!} +{!! __('mail.footer2', ['site' => $site]) !!} diff --git a/src/resources/views/layouts/app.blade.php b/src/resources/views/layouts/app.blade.php --- a/src/resources/views/layouts/app.blade.php +++ b/src/resources/views/layouts/app.blade.php @@ -3,14 +3,14 @@ - + {{ config('app.name') }} -- @yield('title') {{-- TODO: PWA disabled for now: @laravelPWA --}} - +
@@ -18,6 +18,6 @@
- + diff --git a/src/resources/vue/Admin/Dashboard.vue b/src/resources/vue/Admin/Dashboard.vue --- a/src/resources/vue/Admin/Dashboard.vue +++ b/src/resources/vue/Admin/Dashboard.vue @@ -15,17 +15,23 @@ + + - - + + +
(.+)
(.+)
- {{ __('documents.account-id') }} {{ $customer['wallet_id'] }}
+ {{--{{ __('documents.account-id') }} {{ $customer['wallet_id'] }}
--}} {{ __('documents.customer-no') }} {{ $customer['id'] }}
Primary Email IDCreatedDeleted
+
- {{ user.email }} + {{ user.email }} + {{ user.email }} - {{ user.id }} + {{ user.id }} + {{ user.id }} {{ toDate(user.created_at) }}{{ toDate(user.deleted_at) }}
@@ -65,7 +71,7 @@ axios.get('/api/v4/users', { params: { search: this.search } }) .then(response => { - if (response.data.count == 1) { + if (response.data.count == 1 && !response.data.list[0].isDeleted) { this.$router.push({ name: 'user', params: { user: response.data.list[0].id } }) return } @@ -77,6 +83,11 @@ this.users = response.data.list }) .catch(this.$root.errorHandler) + }, + toDate(datetime) { + if (datetime) { + return datetime.split(' ')[0] + } } } } diff --git a/src/resources/vue/Admin/Domain.vue b/src/resources/vue/Admin/Domain.vue --- a/src/resources/vue/Admin/Domain.vue +++ b/src/resources/vue/Admin/Domain.vue @@ -22,6 +22,10 @@ +
+ + +
@@ -64,6 +68,24 @@ .catch(this.$root.errorHandler) }, methods: { + suspendDomain() { + axios.post('/api/v4/domains/' + this.domain.id + '/suspend', {}) + .then(response => { + if (response.data.status == 'success') { + this.$toast.success(response.data.message) + this.domain = Object.assign({}, this.domain, { isSuspended: true }) + } + }) + }, + unsuspendDomain() { + axios.post('/api/v4/domains/' + this.domain.id + '/unsuspend', {}) + .then(response => { + if (response.data.status == 'success') { + this.$toast.success(response.data.message) + this.domain = Object.assign({}, this.domain, { isSuspended: false }) + } + }) + } } } diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -176,7 +176,7 @@
- +
@@ -184,7 +184,7 @@ - + @@ -199,6 +199,9 @@
¹ applied discount: {{ discount }}% - {{ discount_description }} +
+ +
@@ -241,7 +244,8 @@ @@ -342,6 +346,28 @@ + + @@ -369,10 +395,12 @@ discount_description: '', discounts: [], external_email: '', + has2FA: false, wallet: {}, walletReload: false, domains: [], skus: [], + sku2FA: null, users: [], user: { aliases: [], @@ -394,12 +422,14 @@ const financesTab = '#user-finances' const keys = ['first_name', 'last_name', 'external_email', 'billing_address', 'phone', 'organization'] - const country = this.user.settings.country - if (country) { - this.user.country = window.config.countries[country][1] + let country = this.user.settings.country + if (country && country in window.config.countries) { + country = window.config.countries[country][1] } + this.user.country = country + keys.forEach(key => { this.user[key] = this.user.settings[key] }) this.discount = this.user.wallet.discount @@ -437,6 +467,11 @@ } this.skus.push(item) + + if (sku.title == '2fa') { + this.has2FA = true + this.sku2FA = sku.id + } } }) }) @@ -445,9 +480,7 @@ // TODO: Multiple wallets axios.get('/api/v4/users?owner=' + user_id) .then(response => { - this.users = response.data.list.filter(user => { - return user.id != user_id; - }) + this.users = response.data.list; }) // Fetch domains @@ -513,6 +546,20 @@ this.walletReload = true this.$nextTick(() => { this.walletReload = false }) }, + reset2FA() { + $('#reset-2fa-dialog').modal('hide') + axios.post('/api/v4/users/' + this.user.id + '/reset2FA') + .then(response => { + if (response.data.status == 'success') { + this.$toast.success(response.data.message) + this.skus = this.skus.filter(sku => sku.id != this.sku2FA) + this.has2FA = false + } + }) + }, + reset2FADialog() { + $('#reset-2fa-dialog').modal() + }, submitDiscount() { $('#discount-dialog').modal('hide') diff --git a/src/resources/vue/App.vue b/src/resources/vue/App.vue --- a/src/resources/vue/App.vue +++ b/src/resources/vue/App.vue @@ -17,11 +17,11 @@ this.$root.startLoading() axios.defaults.headers.common.Authorization = 'Bearer ' + token - axios.get('/api/auth/info') + axios.get('/api/auth/info?refresh_token=1') .then(response => { this.isLoading = false this.$root.stopLoading() - this.$root.loginUser({ access_token: token }, false) + this.$root.loginUser(response.data, false) this.$store.state.authInfo = response.data }) .catch(error => { diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue --- a/src/resources/vue/Dashboard.vue +++ b/src/resources/vue/Dashboard.vue @@ -6,13 +6,13 @@ Your profile - + Domains - + User accounts - + Wallet {{ $root.price(balance) }} diff --git a/src/resources/vue/Domain/Info.vue b/src/resources/vue/Domain/Info.vue --- a/src/resources/vue/Domain/Info.vue +++ b/src/resources/vue/Domain/Info.vue @@ -53,12 +53,17 @@ }, created() { if (this.domain_id = this.$route.params.domain) { + this.$root.startLoading() + axios.get('/api/v4/domains/' + this.domain_id) .then(response => { + this.$root.stopLoading() this.domain = response.data + if (!this.domain.isConfirmed) { $('#domain-verify button').focus() } + this.status = response.data.statusInfo }) .catch(this.$root.errorHandler) diff --git a/src/resources/vue/Domain/List.vue b/src/resources/vue/Domain/List.vue --- a/src/resources/vue/Domain/List.vue +++ b/src/resources/vue/Domain/List.vue @@ -20,6 +20,11 @@ + + + + +
Subscription
{{ sku.name }} {{ sku.price }}
- {{ item.email }} + {{ item.email }} + {{ item.email }}
There are no domains in this account.
@@ -35,8 +40,11 @@ } }, created() { + this.$root.startLoading() + axios.get('/api/v4/domains') .then(response => { + this.$root.stopLoading() this.domains = response.data }) .catch(this.$root.errorHandler) diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -193,10 +193,14 @@ this.discount_description = wallet.discount_description } + this.$root.startLoading() + if (this.user_id === 'new') { // do nothing (for now) axios.get('/api/v4/packages') .then(response => { + this.$root.stopLoading() + this.packages = response.data.filter(pkg => !pkg.isDomain) this.package_id = this.packages[0].id }) @@ -205,6 +209,8 @@ else { axios.get('/api/v4/users/' + this.user_id) .then(response => { + this.$root.stopLoading() + this.user = response.data this.user.first_name = response.data.settings.first_name this.user.last_name = response.data.settings.last_name @@ -267,14 +273,8 @@ axios[method](location, this.user) .then(response => { - if (response.data.status == 'success') { - this.$toast.success(response.data.message) - } - - // on new user redirect to users list - if (this.user_id === 'new') { - this.$router.push({ name: 'users' }) - } + this.$toast.success(response.data.message) + this.$router.push({ name: 'users' }) }) }, onInputSku(e) { diff --git a/src/resources/vue/User/List.vue b/src/resources/vue/User/List.vue --- a/src/resources/vue/User/List.vue +++ b/src/resources/vue/User/List.vue @@ -33,6 +33,11 @@ + + + There are no users in this account. + +
@@ -73,8 +78,11 @@ } }, created() { + this.$root.startLoading() + axios.get('/api/v4/users') .then(response => { + this.$root.stopLoading() this.users = response.data }) .catch(this.$root.errorHandler) diff --git a/src/resources/vue/User/Profile.vue b/src/resources/vue/User/Profile.vue --- a/src/resources/vue/User/Profile.vue +++ b/src/resources/vue/User/Profile.vue @@ -5,6 +5,12 @@
Your profile
+
+ +
+ {{ user_id }} +
+
@@ -80,6 +86,7 @@ data() { return { profile: {}, + user_id: null, wallet_id: null, countries: window.config.countries } @@ -87,26 +94,22 @@ created() { this.wallet_id = this.$store.state.authInfo.wallet.id this.profile = this.$store.state.authInfo.settings + this.user_id = this.$store.state.authInfo.id }, mounted() { $('#first_name').focus() }, methods: { submit() { - if (this.profile.country) { - this.profile.currency = this.countries[this.profile.country][0] - } - this.$root.clearFormValidation($('#user-profile form')) - axios.put('/api/v4/users/' + this.$store.state.authInfo.id, this.profile) + axios.put('/api/v4/users/' + this.user_id, this.profile) .then(response => { delete this.profile.password delete this.profile.password_confirm - if (response.data.status == 'success') { - this.$toast.success(response.data.message) - } + this.$toast.success(response.data.message) + this.$router.push({ name: 'dashboard' }) }) } } diff --git a/src/resources/vue/Widgets/ListInput.vue b/src/resources/vue/Widgets/ListInput.vue --- a/src/resources/vue/Widgets/ListInput.vue +++ b/src/resources/vue/Widgets/ListInput.vue @@ -10,7 +10,7 @@
- +
diff --git a/src/resources/vue/Widgets/Menu.vue b/src/resources/vue/Widgets/Menu.vue --- a/src/resources/vue/Widgets/Menu.vue +++ b/src/resources/vue/Widgets/Menu.vue @@ -2,7 +2,7 @@