Repository: tchapi/davis Branch: main Commit: 82d577822192 Files: 174 Total size: 746.3 KB Directory structure: gitextract_80g_njsc/ ├── .dockerignore ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ └── feature_request.yml │ └── workflows/ │ ├── ci.yml │ └── main.yml ├── .gitignore ├── .hadolint.yaml ├── .php-cs-fixer.php ├── LICENSE ├── README.md ├── bin/ │ ├── console │ └── phpunit ├── composer.json ├── config/ │ ├── bundles.php │ ├── packages/ │ │ ├── cache.yaml │ │ ├── dev/ │ │ │ ├── debug.yaml │ │ │ ├── monolog.yaml │ │ │ └── web_profiler.yaml │ │ ├── doctrine.yaml │ │ ├── doctrine_migrations.yaml │ │ ├── framework.yaml │ │ ├── mailer.yaml │ │ ├── prod/ │ │ │ ├── deprecations.yaml │ │ │ ├── doctrine.yaml │ │ │ ├── monolog.yaml │ │ │ └── routing.yaml │ │ ├── routing.yaml │ │ ├── security.yaml │ │ ├── test/ │ │ │ ├── framework.yaml │ │ │ ├── monolog.yaml │ │ │ ├── twig.yaml │ │ │ ├── validator.yaml │ │ │ └── web_profiler.yaml │ │ ├── translation.yaml │ │ ├── twig.yaml │ │ └── validator.yaml │ ├── reference.php │ ├── routes/ │ │ ├── attributes.yaml │ │ └── dev/ │ │ ├── framework.yaml │ │ └── web_profiler.yaml │ ├── routes.yaml │ └── services.yaml ├── docker/ │ ├── Dockerfile │ ├── Dockerfile-standalone │ ├── configurations/ │ │ ├── Caddyfile │ │ ├── nginx.conf │ │ ├── opcache.ini │ │ └── supervisord.conf │ ├── docker-compose-postgresql.yml │ ├── docker-compose-sqlite.yml │ ├── docker-compose-standalone.yml │ └── docker-compose.yml ├── docs/ │ └── api/ │ ├── README.md │ └── v1/ │ ├── calendars/ │ │ ├── all.md │ │ ├── create.md │ │ ├── delete.md │ │ ├── details.md │ │ ├── edit.md │ │ ├── share_add.md │ │ ├── share_remove.md │ │ └── shares.md │ ├── health.md │ └── users/ │ ├── all.md │ └── details.md ├── migrations/ │ ├── Version20191030113307.php │ ├── Version20191113170650.php │ ├── Version20191125093508.php │ ├── Version20191202091507.php │ ├── Version20191203111729.php │ ├── Version20210928132307.php │ ├── Version20221106220411.php │ ├── Version20221106220412.php │ ├── Version20221211154443.php │ ├── Version20230209142217.php │ ├── Version20231001214111.php │ ├── Version20231001214112.php │ ├── Version20231001214113.php │ ├── Version20231229203515.php │ ├── Version20250409193948.php │ ├── Version20250421163214.php │ └── Version20260131161930.php ├── phpunit.xml.dist ├── public/ │ ├── .htaccess │ ├── css/ │ │ └── style.css │ ├── index.php │ ├── js/ │ │ ├── app.js │ │ └── color.mode.toggler.js │ ├── robots.txt │ └── site.webmanifest ├── src/ │ ├── Command/ │ │ ├── ApiGenerateCommand.php │ │ └── SyncBirthdayCalendars.php │ ├── Constants.php │ ├── Controller/ │ │ ├── Admin/ │ │ │ ├── AddressBookController.php │ │ │ ├── CalendarController.php │ │ │ ├── DashboardController.php │ │ │ └── UserController.php │ │ ├── Api/ │ │ │ └── ApiController.php │ │ ├── DAVController.php │ │ └── SecurityController.php │ ├── DataFixtures/ │ │ └── AppFixtures.php │ ├── Entity/ │ │ ├── AddressBook.php │ │ ├── AddressBookChange.php │ │ ├── Calendar.php │ │ ├── CalendarChange.php │ │ ├── CalendarInstance.php │ │ ├── CalendarObject.php │ │ ├── CalendarSubscription.php │ │ ├── Card.php │ │ ├── Lock.php │ │ ├── Principal.php │ │ ├── PropertyStorage.php │ │ ├── SchedulingObject.php │ │ └── User.php │ ├── Form/ │ │ ├── AddressBookType.php │ │ ├── CalendarInstanceType.php │ │ └── UserType.php │ ├── Kernel.php │ ├── Logging/ │ │ └── Monolog/ │ │ └── PasswordFilterProcessor.php │ ├── Plugins/ │ │ ├── BirthdayCalendarPlugin.php │ │ ├── DavisIMipPlugin.php │ │ └── PublicAwareDAVACLPlugin.php │ ├── Repository/ │ │ ├── CalendarInstanceRepository.php │ │ └── PrincipalRepository.php │ ├── Security/ │ │ ├── AdminUser.php │ │ ├── AdminUserProvider.php │ │ ├── ApiKeyAuthenticator.php │ │ └── LoginFormAuthenticator.php │ ├── Services/ │ │ ├── BasicAuth.php │ │ ├── BirthdayService.php │ │ ├── IMAPAuth.php │ │ ├── LDAPAuth.php │ │ └── Utils.php │ └── Version.php ├── templates/ │ ├── _partials/ │ │ ├── add_delegate_modal.html.twig │ │ ├── back_button.html.twig │ │ ├── delegate_row.html.twig │ │ ├── delete_modal.html.twig │ │ ├── flashes.html.twig │ │ ├── navigation.html.twig │ │ └── share_modal.html.twig │ ├── addressbooks/ │ │ ├── edit.html.twig │ │ └── index.html.twig │ ├── base.html.twig │ ├── calendars/ │ │ ├── edit.html.twig │ │ └── index.html.twig │ ├── dashboard.html.twig │ ├── index.html.twig │ ├── mails/ │ │ ├── scheduling.html.twig │ │ └── scheduling.txt.twig │ ├── security/ │ │ └── login.html.twig │ └── users/ │ ├── delegates.html.twig │ ├── edit.html.twig │ └── index.html.twig ├── tests/ │ ├── .gitignore │ ├── Functional/ │ │ ├── Commands/ │ │ │ └── SyncBirthdayCalendarTest.php │ │ ├── Controllers/ │ │ │ ├── AddressBookControllerTest.php │ │ │ ├── ApiControllerTest.php │ │ │ ├── CalendarControllerTest.php │ │ │ ├── DashboardTest.php │ │ │ └── UserControllerTest.php │ │ ├── DavTest.php │ │ └── Service/ │ │ └── BirthdayServiceTest.php │ └── bootstrap.php └── translations/ ├── .gitignore ├── messages+intl-icu.de.xlf ├── messages+intl-icu.en.xlf ├── messages+intl-icu.fr.xliff ├── security.de.xlf ├── security.en.xlf ├── security.fr.xlf ├── validators.de.xlf ├── validators.en.xlf └── validators.fr.xlf ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ _screenshots .DS_Store README.md LICENSE .git .gitignore .github .env.local .env.test.local phpunit.xml.dist .php-cs* .phpunit.* .dockerignore var/cache/* var/log/* ================================================ FILE: .github/FUNDING.yml ================================================ custom: ['https://www.paypal.me/tchap'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug Report description: Something is not working as expected labels: ["bug"] body: - type: markdown attributes: value: | Before opening an issue, please: - Check `./var/log/prod.log` for errors (only written when errors occur, thanks to the `fingers_crossed` filter) - Check your web server / reverse proxy logs - Read the [Troubleshooting section of the README](https://github.com/tchapi/davis/blob/main/README.md#troubleshooting) - type: input id: davis_version attributes: label: Davis version description: Tag or commit SHA. Found on the `/` status page or via `git describe --tags`. placeholder: "e.g. v5.1.0" validations: required: true - type: dropdown id: install_method attributes: label: Installation method options: - Docker – standalone (with Caddy) - Docker – barebone (no reverse proxy) - Bare metal / manual - NixOS module - Other (describe below) validations: required: true - type: input id: php_version attributes: label: PHP version (bare metal only) description: Output of `php --version`. Skip if using Docker. placeholder: "e.g. 8.2.18" - type: dropdown id: database attributes: label: Database options: - MySQL - MariaDB - PostgreSQL - SQLite - Other validations: required: true - type: dropdown id: reverse_proxy attributes: label: Reverse proxy options: - None (standalone Docker with Caddy) - Nginx - Apache - Caddy - Traefik - Other validations: required: true - type: dropdown id: auth_method attributes: label: Authentication method options: - Internal (ADMIN_LOGIN / ADMIN_PASSWORD) - IMAP - LDAP validations: required: true - type: checkboxes id: protocols attributes: label: Affected protocol(s) options: - label: CalDAV - label: CardDAV - label: WebDAV - label: Admin dashboard - label: API - type: textarea id: client attributes: label: Client(s) exhibiting the issue description: Name, version, OS/platform. Add multiple if relevant. placeholder: | - DAVx⁵ 4.3.14 on Android 14 - Apple Calendar on macOS 14.4 - Thunderbird 115.10 on Ubuntu 22.04 validations: required: true - type: textarea id: env_vars attributes: label: Relevant environment variables description: | Sanitize secrets (APP_SECRET, DATABASE_URL password, API_KEY, etc.). Include at minimum: APP_ENV, CALDAV_ENABLED, CARDDAV_ENABLED, WEBDAV_ENABLED, AUTH_METHOD, and any env vars you think are relevant. render: shell placeholder: | APP_ENV=prod AUTH_METHOD=... CALDAV_ENABLED=true CARDDAV_ENABLED=true WEBDAV_ENABLED=false DATABASE_URL=mysql://user:***@host:3306/davis validations: required: true - type: textarea id: steps attributes: label: Steps to reproduce placeholder: | 1. Configure client with URL https://dav.example.com/dav 2. ... 3. Observe error validations: required: true - type: textarea id: expected attributes: label: Expected behaviour validations: required: true - type: textarea id: actual attributes: label: Actual behaviour description: Include any error messages shown in the UI or client. validations: required: true - type: textarea id: logs attributes: label: Logs description: | Paste the relevant section of `./var/log/prod.log` (relative to your Davis installation root, **not** `/var/log`). Also include web server / reverse proxy error logs if applicable. Set `APP_ENV=dev` temporarily to get verbose output if `prod.log` is empty (you need to install dev dependencies with composer). render: text - type: textarea id: additional attributes: label: Additional context description: Anything else that might be relevant (docker-compose snippet, nginx config excerpt, network topology, etc.). ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: Feature Request description: Suggest an improvement or new functionality labels: ["feature request"] body: - type: markdown attributes: value: | Please check [existing issues](https://github.com/tchapi/davis/issues) and the [Davis roadmap](https://github.com/users/tchapi/projects/1) first to avoid duplicates. - type: textarea id: problem attributes: label: Problem / motivation description: What are you trying to do, and why is it currently not possible or inconvenient? placeholder: "e.g. I cannot do X, which forces me to..." validations: required: true - type: textarea id: solution attributes: label: Proposed solution description: Describe the feature you have in mind. Be as specific as possible. validations: required: true - type: textarea id: alternatives attributes: label: Alternatives considered description: Other approaches you have thought of or tried, and why they fall short. - type: dropdown id: area attributes: label: Area options: - CalDAV - CardDAV - WebDAV - Admin dashboard - API - Authentication (IMAP / LDAP / internal) - Docker / deployment - Documentation - Other validations: required: true - type: textarea id: additional attributes: label: Additional context description: Links, screenshots, references to relevant RFCs or client behaviour, etc. ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: - main # Only run on pushes to main pull_request: # Run on all PRs (no path restrictions) env: COMPOSER_ALLOW_SUPERUSER: '1' SYMFONY_DEPRECATIONS_HELPER: max[self]=0 ADMIN_LOGIN: admin ADMIN_PASSWORD: test jobs: dockerfile-checks: name: Dockerfile Checks runs-on: ubuntu-latest strategy: matrix: dockerfile: - docker/Dockerfile - docker/Dockerfile-standalone steps: - name: Checkout uses: actions/checkout@v4 - name: Lint with Hadolint uses: hadolint/hadolint-action@v3.1.0 with: dockerfile: ${{ matrix.dockerfile }} failure-threshold: warning - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Validate Dockerfile syntax run: | docker buildx build \ --file ${{ matrix.dockerfile }} \ --platform linux/amd64 \ --check \ . analyze: name: Analyze runs-on: ubuntu-latest container: image: php:8.4-alpine options: >- --tmpfs /tmp:exec --tmpfs /var/tmp:exec steps: - name: Checkout uses: actions/checkout@v4 - name: Install GD / ZIP PHP extension run: | apk add $PHPIZE_DEPS libpng-dev libzip-dev docker-php-ext-configure gd docker-php-ext-configure zip docker-php-ext-install gd zip - name: Install Composer run: wget -qO - https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet - name: Validate Composer run: composer validate - name: Update to highest dependencies with Composer run: composer install --no-interaction --no-progress --ansi - name: Analyze run: vendor/bin/php-cs-fixer fix --ansi phpunit: name: PHPUnit (PHP ${{ matrix.php }}) runs-on: ubuntu-latest container: image: php:${{ matrix.php }}-alpine options: >- --tmpfs /tmp:exec --tmpfs /var/tmp:exec services: mysql: image: mariadb:10.11 env: # Corresponds to what is in .env.test MYSQL_DATABASE: davis_test MYSQL_USER: davis MYSQL_PASSWORD: davis MYSQL_ROOT_PASSWORD: root options: >- --health-cmd "mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 3306:3306 strategy: matrix: php: - '8.2' - '8.3' - '8.4' - '8.5' fail-fast: false steps: - name: Checkout uses: actions/checkout@v4 - name: Install MySQL / GD / ZIP PHP extensions run: | apk add $PHPIZE_DEPS icu-libs icu-dev libpng-dev libzip-dev docker-php-ext-configure intl docker-php-ext-configure gd docker-php-ext-configure zip docker-php-ext-install pdo pdo_mysql intl gd zip - name: Install Composer run: wget -qO - https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet - name: Install dependencies with Composer run: composer install --no-progress --no-interaction --ansi - name: Run tests with PHPUnit env: DATABASE_URL: mysql://davis:davis@mysql:3306/davis_test run: vendor/bin/phpunit --process-isolation --colors=always migrations: name: Migrations (${{ matrix.database }}) runs-on: ubuntu-latest container: image: php:8.4-alpine options: >- --tmpfs /tmp:exec --tmpfs /var/tmp:exec services: mysql: image: mariadb:10.11 env: MYSQL_DATABASE: davis_test MYSQL_USER: davis MYSQL_PASSWORD: davis MYSQL_ROOT_PASSWORD: root options: >- --health-cmd "mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 5 postgres: image: postgres:15-alpine env: POSTGRES_DB: davis_test POSTGRES_USER: davis POSTGRES_PASSWORD: davis options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 strategy: matrix: database: - mysql - postgresql - sqlite fail-fast: false steps: - name: Checkout uses: actions/checkout@v4 - name: Install database extensions run: | apk add $PHPIZE_DEPS icu-libs icu-dev libpng-dev libzip-dev postgresql-dev sqlite-dev docker-php-ext-configure intl docker-php-ext-configure gd docker-php-ext-configure zip docker-php-ext-install pdo pdo_mysql pdo_pgsql pdo_sqlite intl gd zip - name: Install Composer run: wget -qO - https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet - name: Install dependencies with Composer run: composer install --no-progress --no-interaction --ansi - name: Run migrations (MySQL) if: matrix.database == 'mysql' env: DATABASE_URL: mysql://davis:davis@mysql:3306/davis_test run: | php bin/console doctrine:database:create --if-not-exists --env=test php bin/console doctrine:migrations:migrate --no-interaction --env=test php bin/console doctrine:schema:validate --env=test - name: Run migrations (PostgreSQL) if: matrix.database == 'postgresql' env: DATABASE_URL: postgresql://davis:davis@postgres:5432/davis_test?serverVersion=15&charset=utf8 run: | php bin/console doctrine:database:create --if-not-exists --env=test php bin/console doctrine:migrations:migrate --no-interaction --env=test php bin/console doctrine:schema:validate --skip-sync --env=test - name: Run migrations (SQLite) if: matrix.database == 'sqlite' env: DATABASE_URL: sqlite:///%kernel.project_dir%/var/data_test.db run: | php bin/console doctrine:migrations:migrate --no-interaction --env=test php bin/console doctrine:schema:validate --skip-sync --env=test smoke-test: name: Application Smoke Test runs-on: ubuntu-latest container: image: php:8.4-alpine options: >- --tmpfs /tmp:exec --tmpfs /var/tmp:exec services: mysql: image: mariadb:10.11 env: MYSQL_DATABASE: davis_test MYSQL_USER: davis MYSQL_PASSWORD: davis MYSQL_ROOT_PASSWORD: root options: >- --health-cmd "mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 5 steps: - name: Checkout uses: actions/checkout@v4 - name: Install extensions and curl run: | apk add $PHPIZE_DEPS icu-libs icu-dev libpng-dev libzip-dev curl docker-php-ext-configure intl docker-php-ext-configure gd docker-php-ext-configure zip docker-php-ext-install pdo pdo_mysql intl gd zip - name: Install Composer run: wget -qO - https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet - name: Install dependencies with Composer run: composer install --no-progress --no-interaction --ansi --optimize-autoloader - name: Prepare application env: DATABASE_URL: mysql://davis:davis@mysql:3306/davis_test APP_ENV: test run: | php bin/console doctrine:database:create --if-not-exists --env=test php bin/console doctrine:migrations:migrate --no-interaction --env=test php bin/console cache:clear --env=test - name: Start Symfony server in background env: DATABASE_URL: mysql://davis:davis@mysql:3306/davis_test APP_ENV: test run: | php -S 127.0.0.1:8000 -t public/ & echo $! > server.pid sleep 3 - name: Test application responds run: | # Test that the app responds with a successful HTTP status RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8000/) echo "HTTP Response code: $RESPONSE" if [ "$RESPONSE" -ge 200 ] && [ "$RESPONSE" -lt 400 ]; then echo "✅ Application is responding correctly" else echo "❌ Application returned unexpected status code: $RESPONSE" exit 1 fi - name: Test dashboard continue-on-error: true run: | curl -f http://127.0.0.1:8000/dashboard || echo "No health endpoint available" - name: Stop server if: always() run: | if [ -f server.pid ]; then kill $(cat server.pid) || true fi ================================================ FILE: .github/workflows/main.yml ================================================ name: Publish Docker image on: workflow_dispatch: release: types: [published] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: # Use docker.io for Docker Hub if empty REGISTRY: ghcr.io # github.repository as / ACCOUNT: tchapi jobs: build: name: Build Docker images runs-on: ubuntu-latest strategy: fail-fast: false matrix: image: - davis - davis-standalone platform: - linux/amd64 - linux/arm64 include: - image: davis dockerfile: docker/Dockerfile - image: davis-standalone dockerfile: docker/Dockerfile-standalone steps: - name: Prepare run: | platform=${{ matrix.platform }} echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - name: Checkout uses: actions/checkout@v4 - name: Extract metadata id: meta uses: docker/metadata-action@v5 with: context: git images: ${{ env.REGISTRY }}/${{ env.ACCOUNT }}/${{ matrix.image }} tags: type=raw,value= - name: Set up QEMU if: matrix.platform == 'linux/arm64' uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 with: version: latest - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and Push by digest id: build uses: docker/build-push-action@v6 with: context: . file: ${{ matrix.dockerfile }} platforms: ${{ matrix.platform }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} annotations: ${{ steps.meta.outputs.annotations }} outputs: type=image,name=${{ env.REGISTRY }}/${{ env.ACCOUNT }}/${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true cache-from: type=gha,scope=${{ matrix.image }}-${{ env.PLATFORM_PAIR }} cache-to: type=gha,mode=max,scope=${{ matrix.image }}-${{ env.PLATFORM_PAIR }} - name: Export digest run: | mkdir -p /tmp/digests/ digest="${{ steps.build.outputs.digest }}" touch "/tmp/digests/${digest#sha256:}" - name: Upload digest uses: actions/upload-artifact@v4 with: name: digests-${{ matrix.image }}_${{ env.PLATFORM_PAIR }} path: /tmp/digests/* if-no-files-found: error retention-days: 1 - name: Build summary run: | echo "### ✅ Build Complete" >> $GITHUB_STEP_SUMMARY echo "- **Image**: \`${{ matrix.image }}\`" >> $GITHUB_STEP_SUMMARY echo "- **Platform**: \`${{ matrix.platform }}\`" >> $GITHUB_STEP_SUMMARY echo "- **Digest**: \`${{ steps.build.outputs.digest }}\`" >> $GITHUB_STEP_SUMMARY merge: name: Create merged manifest runs-on: ubuntu-latest strategy: fail-fast: false matrix: image: - davis - davis-standalone needs: build steps: - name: Checkout uses: actions/checkout@v4 - name: Download digests uses: actions/download-artifact@v4 with: path: /tmp/digests/${{ matrix.image }}/ pattern: digests-${{ matrix.image }}_* merge-multiple: true - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 with: version: latest - name: Docker meta id: meta uses: docker/metadata-action@v5 with: context: git images: ${{ env.REGISTRY }}/${{ env.ACCOUNT }}/${{ matrix.image }} tags: | type=semver,pattern={{version}} type=edge,branch=${{ github.ref_name }} - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Create manifest list and push working-directory: /tmp/digests/${{ matrix.image }}/ run: | docker buildx imagetools create \ $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ --annotation index:org.opencontainers.image.created="${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}" \ --annotation index:org.opencontainers.image.description="${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.description'] }}" \ --annotation index:org.opencontainers.image.version="${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}" \ --annotation index:org.opencontainers.image.licenses="${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.licenses'] }}" \ --annotation index:org.opencontainers.image.title="${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.title'] }}" \ --annotation index:org.opencontainers.image.source="${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.source'] }}" \ --annotation index:org.opencontainers.image.url="${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.url'] }}" \ --annotation index:org.opencontainers.image.revision="${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}" \ $(printf '${{ env.REGISTRY }}/${{ env.ACCOUNT }}/${{ matrix.image }}@sha256:%s ' *) - name: Inspect image run: | docker buildx imagetools inspect \ ${{ env.REGISTRY }}/${{ env.ACCOUNT }}/${{ matrix.image }}:${{ steps.meta.outputs.version }} ================================================ FILE: .gitignore ================================================ ###> symfony/framework-bundle ### /.env.local /.env.local.php /.env.*.local /config/secrets/prod/prod.decrypt.private.php /public/bundles/ /var/ /vendor/ ###< symfony/framework-bundle ### ###> symfony/phpunit-bridge ### .phpunit .phpunit.result.cache /phpunit.xml ###< symfony/phpunit-bridge ### ###> friendsofphp/php-cs-fixer ### /.php_cs.cache ###< friendsofphp/php-cs-fixer ### .DS_Store TODO.todo webdav_* ================================================ FILE: .hadolint.yaml ================================================ ignored: - DL3018 # We don't pin apk versions (Alpine is rolling) failure-threshold: error # Only fail on errors, not warnings ================================================ FILE: .php-cs-fixer.php ================================================ in(__DIR__) ->exclude('var') ; return (new PhpCsFixer\Config()) ->setRules([ '@Symfony' => true, 'ordered_imports' => true, // Order "use" alphabetically 'array_syntax' => ['syntax' => 'short'], // Replace array() by [] 'no_useless_return' => true, // Keep return null; 'phpdoc_order' => true, // Clean up the /** php doc */ 'linebreak_after_opening_tag' => true, 'multiline_whitespace_before_semicolons' => false, 'phpdoc_add_missing_param_annotation' => true, 'single_trait_insert_per_statement' => false ]) ->setUnsupportedPhpVersionAllowed(true) ->setUsingCache(false) ->setFinder($finder) ; ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2019 tchap Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ Davis --- [![Build Status][ci_badge]][ci_link] [![Publish Docker image](https://github.com/tchapi/davis/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/tchapi/davis/actions/workflows/main.yml) [![Latest release][release_badge]][release_link] [![License](https://img.shields.io/github/license/tchapi/davis)](https://github.com/tchapi/davis/blob/main/LICENSE) ![Platform](https://img.shields.io/badge/platform-amd64%20%7C%20arm64-blue?logo=docker) ![PHP Version](https://img.shields.io/badge/php-8.2%20%7C%208.3%20%7C%208.4-777BB4?logo=php&logoColor=white) [![Sponsor me][sponsor_badge]][sponsor_link] A modern, simple, feature-packed, fully translatable DAV server, admin interface and frontend based on `sabre/dav`, built with [Symfony 7](https://symfony.com/) and [Bootstrap 5](https://getbootstrap.com/), initially inspired by [Baïkal](https://github.com/sabre-io/Baikal) (_see dependencies table below for more detail_) ### Web admin dashboard Provides user edition, calendar creation and sharing, and address book creation. The interface is simple and straightforward, responsive, and provides a light and a dark mode. Supports **Basic authentication**, as well as **IMAP** and **LDAP** (_via external providers_). ### DAV Server The underlying server implementation supports (*non-exhaustive list*) CalDAV, CardDAV, WebDAV, calendar sharing, scheduling, mail notifications, and server-side subscriptions (*depending on the capabilities of the client*). ### Additional features ✨ - Subscriptions (to be added via the client, such as the macOS calendar, for instance) - Public calendars, available to anyone with the link - Automatic birthday calendar, updated on the fly when birthdates change in your contacts ### Deployment Easily containerisable (_`Dockerfile` and sample `docker-compose` configuration file provided_). NixOS [package](https://search.nixos.org/packages?channel=unstable&show=davis&from=0&size=50&sort=relevance&type=packages&query=davis) and module available. Comes with already built Docker images in two flavours: [standalone](https://github.com/tchapi/davis/pkgs/container/davis-standalone) (with included Caddy reverse proxy) or [barebone](https://github.com/tchapi/davis/pkgs/container/davis). - - - ✨ Created and maintained (with the help of the community) by [@tchapi](https://github.com/tchapi). ✨ ![Dashboard page](_screenshots/dashboard.png) ![User creation page](_screenshots/user.png) ![Sharing page](_screenshots/sharing.png) | Dark / Light mode | Useful information at hand | |--------------------|----------------------------| | ![Color mode](_screenshots/mode.png)| ![Setup information](_screenshots/setup_info.png)| # 🔩 Requirements - PHP > 8.2 (with `pdo_mysql` [or `pdo_pgsql`, `pdo_sqlite`], `gd` and `intl` extensions), compatible up to PHP 8.5 (_See dependencies table below_) - A compatible database layer, such as MySQL or MariaDB (recommended), PostgreSQL (not extensively tested yet) or SQLite (not extensively tested yet) - Composer > 2 (_The last release compatible with Composer 1 is [v1.6.2](https://github.com/tchapi/davis/releases/tag/v1.6.2)_) - The [`imap`](https://www.php.net/manual/en/imap.installation.php) and [`ldap`](https://www.php.net/manual/en/ldap.installation.php) PHP extensions if you want to use either authentication methods (_these are not enabled / compiled by default except in the Docker image_) Dependencies ------------ | Release | Status | PHP version | |--------------------|----------------------------|--------------------| | `main` (edge) | development branch | PHP 8.2+ | | `v5.x` | stable | PHP 8.2+ | | `v4.x` | security fixes only | PHP 8.0 → 8.3 | | `v3.x` | :warning: unmaintained | PHP 7.3 → 8.2 | # 🧰 Installation 0. Clone this repository 1. Retrieve the dependencies: a. If you plan to run Davis locally, for development purposes ``` composer install ``` b. If you plan to run Davis on production ``` composer install --no-dev ``` And set `APP_ENV=prod` in your `.env.local` file (see below) 3. At least put the correct credentials to your database (driver and url) in your `.env.local` file so you can easily create the necessary tables. 4. Run the migrations to create all the necessary tables: ``` bin/console doctrine:migrations:migrate ``` **Davis** can also be used with a pre-existing MySQL database (_for instance, one previously managed by Baïkal_). See the paragraph "Migrating from Baikal" for more info. > [!NOTE] > > The tables are not _exactly_ equivalent to those of Baïkal, and allow for a bit more room in columns for instance (among other things) ## Configuration Create your own `.env.local` file to change the necessary variables, if you plan on using `symfony/dotenv`. > [!NOTE] > > If your installation is behind a web server like Apache or Nginx, you can setup the env vars directly in your Apache or Nginx configuration (see below). Skip this part in this case. > [!CAUTION] > > In a production environnement, the `APP_ENV` variable MUST be set to `prod` to prevent leaking sensitive data. **a. The database driver and url** (_you should already have it configured since you created the database previously_) ```shell DATABASE_DRIVER=mysql # or postgresql, or sqlite DATABASE_URL=mysql://db_user:db_pass@host:3306/db_name?serverVersion=10.9.3-MariaDB&charset=utf8mb4 ``` **b. The admin password for the backend** ```shell ADMIN_LOGIN=admin ADMIN_PASSWORD=test ``` > [!NOTE] > > You can bypass auth entirely if you use a third party authorization provider such as Authelia. In that case, set the `ADMIN_AUTH_BYPASS` env var to `true` (case-sensitive, this is actually the string `true`, not a boolean) to allow full access to the dashboard. This does not change the behaviour of the DAV server. **c. The auth Realm and method for HTTP auth** ```shell AUTH_REALM=SabreDAV AUTH_METHOD=Basic # can be "Basic", "IMAP" or "LDAP" ``` > See [the following paragraph](#specific-environment-variables-for-imap-and-ldap-authentication-methods) for more information if you choose either IMAP or LDAP. **d. The global flags to enable CalDAV, CardDAV and WebDAV**. You can also disable the option to have calendars public ```shell CALDAV_ENABLED=true CARDDAV_ENABLED=true WEBDAV_ENABLED=false PUBLIC_CALENDARS_ENABLED=true ``` > [!NOTE] > > By default, `PUBLIC_CALENDARS_ENABLED` is true. That doesn't mean that all calendars are public by default — it just means that you have an option, upon calendar creation, to set the calendar public (but it's not public by default). **e. Mailer configuration** It includes: - the mailer uri (`MAILER_DSN`) - The email address that your invites are going to be sent from ```shell MAILER_DSN=smtp://user:pass@smtp.example.com:port INVITE_FROM_ADDRESS=no-reply@example.org ``` > [!WARNING] > If the username, password or host contain any character considered special in a URI (such as `: / ? # [ ] @ ! $ & ' ( ) * + , ; =`), you MUST encode them. > See [here](https://symfony.com/doc/current/mailer.html#transport-setup) for more details. **f. The reminder offset for all birthdays** You must specify a relative duration, as specified in [the RFC 5545 spec](https://www.rfc-editor.org/rfc/rfc5545.html#section-3.3.6) ```shell BIRTHDAY_REMINDER_OFFSET=PT9H ``` If you don't want a reminder for birthday events, set it to the `false` value (lowercase): ```shell BIRTHDAY_REMINDER_OFFSET=false ``` > [!NOTE] > > By default, if the env var is not set or empty, we use `PT9H` (9am on the date of the birthday). **g. The paths for the WebDAV installation** > [!TIP] > > I recommend that you use absolute directories so you know exactly where your files reside. ```shell WEBDAV_TMP_DIR=/webdav/tmp WEBDAV_PUBLIC_DIR=/webdav/public WEBDAV_HOMES_DIR= ``` > [!NOTE] > > In a docker setup, I recommend setting `WEBDAV_TMP_DIR` to `/tmp`. > [!NOTE] > > By default, home directories are disabled totally (the env var is set to an empty string). If needed, it is recommended to use a folder that is **NOT** a child of the public dir, such as `/webdav/homes` for instance, so that users cannot access other users' homes. **h. The log file path** You can use an absolute file path here, and you can use Symfony's `%kernel.logs_dir%` and `%kernel.environment%` placeholders if needed (as in the default value). Setting it to `/dev/null` will disable logging altogether. ```shell LOG_FILE_PATH="%kernel.logs_dir%/%kernel.environment%.log" ``` **i. The timezone you want for the app** This must comply with the [official list](https://www.php.net/manual/en/timezones.php) ```shell APP_TIMEZONE=Australia/Lord_Howe ``` > Set a void value like so: > ```shell > APP_TIMEZONE= > ``` > in your environment file if you wish to use the **actual default timezone of the server**, and not enforcing it. **j. Trusting forwarded headers** If you're behind one or several proxies, the TLS termination might be upstream and the application might not be aware of the HTTPS context. In order for urls to be generated with the correct scheme, you should indicate that you trust the chain of proxies until the TLS termination one. You can use the Symfony mechanism for that (see [documentation](https://symfony.com/doc/7.2/deployment/proxies.html) for possible values): ```shell SYMFONY_TRUSTED_PROXIES=127.0.0.1,REMOTE_ADDR ``` #### Overriding the dotenv (`.env`) path You can override the expected location of the environment files (`.env`, `.env.local`, etc) by setting the `ENV_DIR` variable. The value should be to a _folder_ containing the env files. This value must be specified in the actual environment and *not* in an `.env` file as it is read and evaluated **before** the env files are read. For instance, you can use it to call `bin/console` with a specific dotenv directory: ```shell > ENV_DIR=/var/lib/davis bin/console ``` Or use it directly in the Apache configuration ```apache # .. rest of config (see ¶ below) SetEnv ENV_DIR /var/lib/davis # ... other env vars if needed ``` ### Specific environment variables for IMAP and LDAP authentication methods In case you use the `IMAP` auth type, you must specify the auth url (_the "mailbox" url_) in `IMAP_AUTH_URL` as `host:port`, the encryption method (SSL, TLS or None) and whether the certificate should be validated. You should also explicitely define whether you want new authenticated users to be created upon login: ```shell IMAP_AUTH_URL=imap.mydomain.com:993 IMAP_ENCRYPTION_METHOD=ssl # ssl, tls or false IMAP_CERTIFICATE_VALIDATION=true IMAP_AUTH_USER_AUTOCREATE=true # false by default ``` Same goes for LDAP, where you must specify the LDAP server url, the DN pattern, the Mail attribute, as well as whether you want new authenticated users to be created upon login (_like for IMAP_): ```shell LDAP_AUTH_URL=ldap://127.0.0.1:3890 # default LDAP port LDAP_DN_PATTERN=uid=%u,ou=users,dc=domain,dc=com LDAP_MAIL_ATTRIBUTE=mail LDAP_AUTH_USER_AUTOCREATE=true # false by default LDAP_CERTIFICATE_CHECKING_STRATEGY=try # try by default. Other values are: never, hard, demand or allow ``` > Ex: for [Zimbra LDAP](https://zimbra.github.io/adminguide/latest/#zimbra_ldap_service), you might want to use the `zimbraMailDeliveryAddress` attribute to retrieve the principal user email: > ```shell > LDAP_MAIL_ATTRIBUTE=zimbraMailDeliveryAddress > ``` ## Migrating from Baïkal? If you're migrating from Baïkal, then you will likely want to do the following : 1. Get a backup of your data (without the `CREATE` statements, but with complete `INSERT` statements): ```shell mysqldump -u root -p --no-create-info --complete-insert baikal > baikal_to_davis.sql # baikal is the actual name of your database ``` 2. Create a new database for Davis (let's name it `davis`) and create the base schema: ```shell bin/console doctrine:migrations:migrate 'DoctrineMigrations\Version20191030113307' --no-interaction ``` 3. Reimport the data back: ``` mysql -uroot -p davis < baikal_to_davis.sql ``` 4. Run the necessary remaining migrations: ``` bin/console doctrine:migrations:migrate ``` > [!NOTE] > Some details / steps to resolve are also available in https://github.com/tchapi/davis/issues/226. # 🌐 Access / Webserver A simple status page is available on the root `/` of the server. The administration interface is available at `/dashboard`. You need to login to use it (See `ADMIN_LOGIN` and `ADMIN_PASSWORD` env vars). The main endpoint for CalDAV, WebDAV or CardDAV is at `/dav`. > [!TIP] > > For shared hosting, the `symfony/apache-pack` is included and provides a standard `.htaccess` file in the public directory so redirections should work out of the box. ## API Endpoint For user and calendar management there is an API endpoint. See [the API documentation](docs/api/README.md) for more information. > [!TIP] > > The API endpoint requires an environment variable `API_KEY` set to a secret key that you will use in the `X-Davis-API-Token` header of your requests to authenticate. You can generate it with `bin/console api:generate` ## Webserver Configuration Examples ### Example Caddy 2 configuration ``` dav.domain.tld { # General settings encode zstd gzip header { -Server -X-Powered-By # enable HSTS Strict-Transport-Security max-age=31536000; # disable clients from sniffing the media type X-Content-Type-Options nosniff # keep referrer data off of HTTP connections Referrer-Policy no-referrer-when-downgrade } root * /var/www/davis/public php_fastcgi 127.0.0.1:8000 file_server } ``` ### Example Apache 2.4 configuration ```apache ServerName dav.domain.tld DocumentRoot /var/www/davis/public DirectoryIndex /index.php AllowOverride None Order Allow,Deny Allow from All FallbackResource /index.php # Apache > 2.4.25, else remove this part FallbackResource disabled # Env vars (if you did not use .env.local) SetEnv APP_ENV prod SetEnv APP_SECRET SetEnv DATABASE_DRIVER "mysql" SetEnv DATABASE_URL "mysql://db_user:db_pass@host:3306/db_name?serverVersion=10.9.3-MariaDB&charset=utf8mb4" # ... etc ``` ### Example Nginx configuration ```nginx server { server_name dav.domain.tld; root /var/www/davis/public; location / { try_files $uri /index.php$is_args$args; } location /bundles { try_files $uri =404; } location ~ ^/index\.php(/|$) { fastcgi_pass unix:/var/run/php/php7.2-fpm.sock; # Change for your PHP version fastcgi_split_path_info ^(.+\.php)(/.*)$; include fastcgi_params; # Env vars (if you did not use .env.local) fastcgi_param APP_ENV prod; fastcgi_param APP_SECRET ; fastcgi_param DATABASE_DRIVER "mysql"; fastcgi_param DATABASE_URL "mysql://db_user:db_pass@host:3306/db_name?serverVersion=10.9.3-MariaDB&charset=utf8mb4"; # ... etc ... fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; fastcgi_param DOCUMENT_ROOT $realpath_root; internal; } location ~ \.php$ { return 404; } } ``` More examples and information [here](https://symfony.com/doc/current/setup/web_server_configuration.html). ## Well-known redirections for CalDAV and CardDAV Web-based protocols like CalDAV and CardDAV can be found using a discovery service. Some clients require that you implement a path prefix to point to the correct location for your service. See [here](https://en.wikipedia.org/wiki/List_of_/.well-known/_services_offered_by_webservers) for more info. If you use Apache as your webserver, you can enable the redirections with: ```apache RewriteEngine On RewriteRule ^\.well-known/carddav /dav/ [R=301,L] RewriteRule ^\.well-known/caldav /dav/ [R=301,L] ``` Make sure that `mod_rewrite` is enabled on your installation beforehand. If you use Nginx, you can add this to your configuration: ```nginx location / { rewrite ^/.well-known/carddav /dav/ redirect; rewrite ^/.well-known/caldav /dav/ redirect; } ``` # 🐳 Dockerized installation A `Dockerfile` is available for you to compile the image. To build the checked out version, just run: docker build --pull --file docker/Dockerfile --tag davis:latest --build-arg fpm_user=82:82 . > [!TIP] > > The `fpm_user` build arg allows to set: > - the uid FPM will run with > - the owner of the app folder > > This is helpful if you have a proxy that does not use the same default PHP Alpine uid/gid for www-data (82:82). For instance, in the docker compose file, nginx uses 101:101 > This will build a `davis:latest` image that you can directly use. Do not forget to pass sensible environment variables to the container since the _dist_ `.env` file will take precedence if no `.env.local` or environment variable is found. You can use `--platform` to specify the platform to build for. Currently, `arm64` (ARMv8) and `amd64` (x86) are supported. > [!IMPORTANT] > > ⚠ Do not forget to run all the database migrations the first time you run the container : > > docker exec -it davis sh -c "APP_ENV=prod bin/console doctrine:migrations:migrate --no-interaction" ## Docker images For each release, a Docker image is built and published in the [Github package repository](https://github.com/tchapi/davis/pkgs/container/davis). ### Release images Each release builds and tags two images: one for the standard build (no reverse-proxy) and one for the standalone build (including Caddy as a reverse-proxy). Example: ``` docker pull ghcr.io/tchapi/davis:v4.4.0 ``` ``` docker pull ghcr.io/tchapi/davis-standalone:v4.4.0 ``` ### Edge image The edge image is generally built from the tip of the main branch, but might sometimes be used for specific branch testing: ``` docker pull ghcr.io/tchapi/davis:edge ``` > [!WARNING] > > The `edge` image must not be considered stable. **Use only release images for production setups**. ## Full stack A few `docker-compose.yml` files are also included (in the `docker` folder) as minimal example setups, with various databases for instance. You can start the containers with : cd docker && docker compose up -d > [!NOTE] > > The default recipe above uses MariaDB. > [!IMPORTANT] > > ⚠ Do not forget to run all the database migrations the first time you run the container : > > docker exec -it davis sh -c "APP_ENV=prod bin/console doctrine:migrations:migrate --no-interaction" > [!WARNING] > > For SQLite, you must also make sure that the folder the database will reside in AND the database file in itself have the right permissions! You can do for instance: > `chown -R www-data: /data` if `/data` is the folder your SQLite database will be in, just after you have run the migrations ### Updating from a previous version If you update the code, you need to make sure the database structure is in sync. **Before v3.0.0**, you need to force the update: docker exec -it davis sh -c "APP_ENV=prod bin/console doctrine:schema:update --force --no-interaction" **For v3.0.0 and after**, you can just migrate again (_provided you correctly followed the migration notes in the v3.0.0 release_): docker exec -it davis sh -c "APP_ENV=prod bin/console doctrine:migrations:migrate --no-interaction" Then, head up to `http://:9000` to see the status display : ![Status page](_screenshots/status.png) > Note that there is no user and no principals created by default. # NixOS Installation To install Davis on NixOS, you can use the builtin NixOS module [`services.davis`](https://search.nixos.org/options?channel=unstable&query=services.davis). Currently the NixOS module and package are in the nixos-unstable channel, but they are slated to enter the stable channel in the 24.05 release. * [All `services.davis` options](https://search.nixos.org/options?channel=unstable&query=services.davis) * [Basic Guide](https://nixos.org/manual/nixos/unstable/#module-services-davis) If you encounter a bug or problem with the NixOS Davis module please open an issue [at the nixpkgs repo](https://github.com/NixOS/nixpkgs/issues/new/choose) so the module maintainers can assist. # Development You can spin off a local PHP webserver with: php -S localhost:8000 -t public If you change or add translations, you need to update the `messages` XLIFF file with: bin/console translation:extract en --force --domain=messages+intl-icu ## Testing You can use: ./vendor/bin/phpunit ## ✨ Code linting We use [PHP-CS-Fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer) with: PHP_CS_FIXER_IGNORE_ENV=True ./vendor/bin/php-cs-fixer fix ## ❓ How-to's Below are some issues that can bring more info / insight into custom setups that Davis users have experienced in the past. Hopefully it can help: - **Davis on Proxmox / TrueNAS Scale**: https://github.com/tchapi/davis/issues/164 ## 🐛 Troubleshooting Depending on how you run Davis, logs are either: - [dev] printed out directly in the console - [dev] available in the Symfony Debug Bar in the [Profiler](https://symfony.com/doc/current/profiler.html) - [dev] logged in `./var/log/dev.log` - [prod] logged in `./var/log/prod.log`, but only if there has been an error (_it's the fingers_crossed filter, explained [here](https://symfony.com/doc/current/logging.html#handlers-that-modify-log-entries)_) > [!NOTE] > > It's `./var/log` (relative to the Davis installation), not `/var/log`. > > To tail the aplication log on Docker, do: > ``` > docker exec -it davis tail /var/www/davis/var/log/prod.log > ``` ### I have a "Bad timezone configuration env var" error on the dashboard If you see this: ![Bad timezone configuration env var error](_screenshots/bad_timezone_configuration_env_var.png) It means that the value you set for the `APP_TIMEZONE` env var is not a correct timezone, as per [the official list](https://www.php.net/manual/en/timezones.php). Your timezone has thus not been set and is the server's default (Here, UTC). Adjust the setting accordingly. ### I have a 500 and no tables have been created You probably forgot to run the migration once to create the necessary DB schema In Docker: ```shell docker exec -it davis sh -c "APP_ENV=prod bin/console doctrine:migrations:migrate --no-interaction" ``` In a shell, if you run Davis locally: bin/console doctrine:migrations:migrate ### I have a 500 and a log about `Uncaught Error: Class "Symfony\Bundle\WebProfilerBundle\WebProfilerBundle" not found` You are running the app in dev mode, but you haven't installed the dev dependencies. Either: a. Set `APP_ENV=prod` in your local env file (See configuration above) b. Or `composer install` (without the `--no-dev` flag) ### The LDAP connection is not working > [!NOTE] > > Make sure all environment parameters are in plain text (no quotes). Check if your instance can reach your LDAP server: - For Docker instances: make sure it is on the same network - Check connection via `ldapsearch`: ```shell # For docker: connect into container's shell docker exec -it davis sh # install ldap utils (for alpine linux) apk add openldap-clients # User checking their own entry ldapsearch -H ldap://lldap-server:3890 -D "uid=someuser,ou=users,dc=domain,dc=com" -W -b "dc=domain,dc=com" "(uid=someuser)" ``` - Check that the `LDAP_DN_PATTERN` filter is compliant with your LDAP service - Example: `uid=%u,ou=people,dc=domain,dc=com`: [LLDAP](https://github.com/lldap/lldap) uses `people` instead of `users`. ### The birthday calendar is not synced / not up to date An update event might have been missed. In this case, it's easy to resync all contacts by issuing the command: ``` bin/console dav:sync-birthday-calendar ``` # 📚 Libraries used - Symfony 7 (Licence : MIT) - Sabre-io/dav (Licence : BSD-3-Clause) - Bootstrap 5 (Licence : MIT) _This project does not use any pipeline for the assets since the frontend side is relatively simple, and based on Bootstrap._ # ⚖️ Licence This project is release under the MIT licence. See the LICENCE file [ci_badge]: https://github.com/tchapi/davis/workflows/CI/badge.svg [ci_link]: https://github.com/tchapi/davis/actions?query=workflow%3ACI [sponsor_badge]: https://img.shields.io/badge/sponsor%20me-🙏-blue?logo=paypal [sponsor_link]: https://paypal.me/tchap [release_badge]: https://img.shields.io/github/v/release/tchapi/davis [release_link]: https://github.com/tchapi/davis/releases ================================================ FILE: bin/console ================================================ #!/usr/bin/env php bootEnv($overridenEnvDir.'/.env'); } return function (array $context) { $kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']); return new Application($kernel); }; ================================================ FILE: bin/phpunit ================================================ #!/usr/bin/env php ['all' => true], Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true], Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], ]; ================================================ FILE: config/packages/cache.yaml ================================================ framework: cache: # Unique name of your app: used to compute stable namespaces for cache keys. #prefix_seed: your_vendor_name/app_name # The "app" cache stores to the filesystem by default. # The data in this cache should persist between deploys. # Other options include: # Redis #app: cache.adapter.redis #default_redis_provider: redis://localhost # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) #app: cache.adapter.apcu # Namespaced pools use the above "app" backend by default #pools: #my.dedicated.cache: null ================================================ FILE: config/packages/dev/debug.yaml ================================================ debug: # Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser. # See the "server:dump" command to start a new server. dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%" ================================================ FILE: config/packages/dev/monolog.yaml ================================================ monolog: handlers: main: type: stream path: "%kernel.logs_dir%/%kernel.environment%.log" level: debug channels: ["!event"] # uncomment to get logging in your browser # you may have to allow bigger header sizes in your Web server configuration #firephp: # type: firephp # level: info #chromephp: # type: chromephp # level: info console: type: console process_psr_3_messages: false channels: ["!event", "!doctrine", "!console"] ================================================ FILE: config/packages/dev/web_profiler.yaml ================================================ web_profiler: toolbar: true intercept_redirects: false framework: profiler: { only_exceptions: false } ================================================ FILE: config/packages/doctrine.yaml ================================================ doctrine: dbal: # The server_version must be configured directly in the # DATABASE_URL to allow different drivers without adding # too many env vars driver: 'pdo_%env(string:default:default_database_driver:DATABASE_DRIVER)%' url: '%env(resolve:DATABASE_URL)%' orm: auto_generate_proxy_classes: true report_fields_where_declared: true enable_lazy_ghost_objects: true naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware controller_resolver: auto_mapping: false mappings: App: is_bundle: false type: attribute dir: '%kernel.project_dir%/src/Entity' prefix: 'App\Entity' alias: App ================================================ FILE: config/packages/doctrine_migrations.yaml ================================================ doctrine_migrations: migrations_paths: # namespace is arbitrary but should be different from App\Migrations # as migrations classes should NOT be autoloaded 'DoctrineMigrations': '%kernel.project_dir%/migrations' ================================================ FILE: config/packages/framework.yaml ================================================ # see https://symfony.com/doc/current/reference/configuration/framework.html framework: secret: '%env(APP_SECRET)%' handle_all_throwables: true #csrf_protection: true http_method_override: false # Enables session support. Note that the session will ONLY be started if you read or write from it. # Remove or comment this section to explicitly disable session support. session: handler_id: null cookie_secure: auto cookie_samesite: lax name: 'DAVIS_SESSION' storage_factory_id: session.storage.factory.native profiler: collect_serializer_data: true property_info: with_constructor_extractor: false php_errors: log: true ================================================ FILE: config/packages/mailer.yaml ================================================ framework: mailer: dsn: '%env(MAILER_DSN)%' ================================================ FILE: config/packages/prod/deprecations.yaml ================================================ # As of Symfony 5.1, deprecations are logged in the dedicated "deprecation" channel when it exists #monolog: # channels: [deprecation] # handlers: # deprecation: # type: stream # channels: [deprecation] # path: "%kernel.logs_dir%/%kernel.environment%.deprecations.log" ================================================ FILE: config/packages/prod/doctrine.yaml ================================================ doctrine: orm: auto_generate_proxy_classes: false metadata_cache_driver: type: pool pool: doctrine.system_cache_pool query_cache_driver: type: pool pool: doctrine.system_cache_pool result_cache_driver: type: pool pool: doctrine.result_cache_pool framework: cache: pools: doctrine.result_cache_pool: adapter: cache.app doctrine.system_cache_pool: adapter: cache.system ================================================ FILE: config/packages/prod/monolog.yaml ================================================ monolog: handlers: main: type: fingers_crossed action_level: error handler: nested excluded_http_codes: [404, 405] buffer_size: 50 # How many messages should be saved? Prevent memory leaks nested: type: stream path: "%env(resolve:LOG_FILE_PATH)%" level: debug console: type: console process_psr_3_messages: false channels: ["!event", "!doctrine"] ================================================ FILE: config/packages/prod/routing.yaml ================================================ framework: router: strict_requirements: null ================================================ FILE: config/packages/routing.yaml ================================================ framework: router: utf8: true # Configure how to generate URLs in non-HTTP contexts, such as CLI commands. # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands #default_uri: http://localhost ================================================ FILE: config/packages/security.yaml ================================================ security: password_hashers: Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' providers: admin_user_provider: id: App\Security\AdminUserProvider firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false api_v1: pattern: ^/api/v1 stateless: true custom_authenticators: - App\Security\ApiKeyAuthenticator main: lazy: true custom_authenticators: - App\Security\LoginFormAuthenticator provider: admin_user_provider logout: path: app_logout target: dashboard access_control: - { path: ^/$, roles: PUBLIC_ACCESS } - { path: ^/dav, roles: PUBLIC_ACCESS } - { path: ^/dashboard, roles: ROLE_ADMIN, allow_if: "'%env(default:default_admin_auth_bypass:ADMIN_AUTH_BYPASS)%' === 'true'" } - { path: ^/users, roles: ROLE_ADMIN, allow_if: "'%env(default:default_admin_auth_bypass:ADMIN_AUTH_BYPASS)%' === 'true'" } - { path: ^/calendars, roles: ROLE_ADMIN, allow_if: "'%env(default:default_admin_auth_bypass:ADMIN_AUTH_BYPASS)%' === 'true'" } - { path: ^/adressbooks, roles: ROLE_ADMIN, allow_if: "'%env(default:default_admin_auth_bypass:ADMIN_AUTH_BYPASS)%' === 'true'" } - { path: ^/api/v1/health$, roles: PUBLIC_ACCESS } - { path: ^/api, roles: IS_AUTHENTICATED } ================================================ FILE: config/packages/test/framework.yaml ================================================ framework: test: true session: storage_factory_id: session.storage.factory.mock_file ================================================ FILE: config/packages/test/monolog.yaml ================================================ monolog: handlers: main: type: fingers_crossed action_level: error handler: nested excluded_http_codes: [404, 405] channels: ["!event"] nested: type: stream path: "%kernel.logs_dir%/%kernel.environment%.log" level: debug ================================================ FILE: config/packages/test/twig.yaml ================================================ twig: strict_variables: true ================================================ FILE: config/packages/test/validator.yaml ================================================ framework: validation: not_compromised_password: false ================================================ FILE: config/packages/test/web_profiler.yaml ================================================ web_profiler: toolbar: false intercept_redirects: false framework: profiler: { collect: false } ================================================ FILE: config/packages/translation.yaml ================================================ framework: default_locale: en translator: default_path: '%kernel.project_dir%/translations' fallbacks: - en ================================================ FILE: config/packages/twig.yaml ================================================ twig: default_path: '%kernel.project_dir%/templates' debug: '%kernel.debug%' strict_variables: '%kernel.debug%' form_themes: ['bootstrap_5_horizontal_layout.html.twig'] globals: invite_from_address: '%env(INVITE_FROM_ADDRESS)%' calDAVEnabled: '%env(bool:CALDAV_ENABLED)%' cardDAVEnabled: '%env(bool:CARDDAV_ENABLED)%' webDAVEnabled: '%env(bool:WEBDAV_ENABLED)%' authRealm: '%env(AUTH_REALM)%' authMethod: '%env(AUTH_METHOD)%' ================================================ FILE: config/packages/validator.yaml ================================================ framework: validation: enable_attributes: true email_validation_mode: html5 # Enables validator auto-mapping support. # For instance, basic validation constraints will be inferred from Doctrine's metadata. #auto_mapping: # App\Entity\: [] ================================================ FILE: config/reference.php ================================================ [ * 'App\\' => [ * 'resource' => '../src/', * ], * ], * ]); * ``` * * @psalm-type ImportsConfig = list * @psalm-type ParametersConfig = array|Param|null>|Param|null> * @psalm-type ArgumentsType = list|array * @psalm-type CallType = array|array{0:string, 1?:ArgumentsType, 2?:bool}|array{method:string, arguments?:ArgumentsType, returns_clone?:bool} * @psalm-type TagsType = list>> // arrays inside the list must have only one element, with the tag name as the key * @psalm-type CallbackType = string|array{0:string|ReferenceConfigurator,1:string}|\Closure|ReferenceConfigurator|ExpressionConfigurator * @psalm-type DeprecationType = array{package: string, version: string, message?: string} * @psalm-type DefaultsType = array{ * public?: bool, * tags?: TagsType, * resource_tags?: TagsType, * autowire?: bool, * autoconfigure?: bool, * bind?: array, * } * @psalm-type InstanceofType = array{ * shared?: bool, * lazy?: bool|string, * public?: bool, * properties?: array, * configurator?: CallbackType, * calls?: list, * tags?: TagsType, * resource_tags?: TagsType, * autowire?: bool, * bind?: array, * constructor?: string, * } * @psalm-type DefinitionType = array{ * class?: string, * file?: string, * parent?: string, * shared?: bool, * synthetic?: bool, * lazy?: bool|string, * public?: bool, * abstract?: bool, * deprecated?: DeprecationType, * factory?: CallbackType, * configurator?: CallbackType, * arguments?: ArgumentsType, * properties?: array, * calls?: list, * tags?: TagsType, * resource_tags?: TagsType, * decorates?: string, * decoration_inner_name?: string, * decoration_priority?: int, * decoration_on_invalid?: 'exception'|'ignore'|null, * autowire?: bool, * autoconfigure?: bool, * bind?: array, * constructor?: string, * from_callable?: CallbackType, * } * @psalm-type AliasType = string|array{ * alias: string, * public?: bool, * deprecated?: DeprecationType, * } * @psalm-type PrototypeType = array{ * resource: string, * namespace?: string, * exclude?: string|list, * parent?: string, * shared?: bool, * lazy?: bool|string, * public?: bool, * abstract?: bool, * deprecated?: DeprecationType, * factory?: CallbackType, * arguments?: ArgumentsType, * properties?: array, * configurator?: CallbackType, * calls?: list, * tags?: TagsType, * resource_tags?: TagsType, * autowire?: bool, * autoconfigure?: bool, * bind?: array, * constructor?: string, * } * @psalm-type StackType = array{ * stack: list>, * public?: bool, * deprecated?: DeprecationType, * } * @psalm-type ServicesConfig = array{ * _defaults?: DefaultsType, * _instanceof?: InstanceofType, * ... * } * @psalm-type ExtensionType = array * @psalm-type FrameworkConfig = array{ * secret?: scalar|Param|null, * http_method_override?: bool|Param, // Set true to enable support for the '_method' request parameter to determine the intended HTTP method on POST requests. // Default: false * allowed_http_method_override?: list|null, * trust_x_sendfile_type_header?: scalar|Param|null, // Set true to enable support for xsendfile in binary file responses. // Default: "%env(bool:default::SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER)%" * ide?: scalar|Param|null, // Default: "%env(default::SYMFONY_IDE)%" * test?: bool|Param, * default_locale?: scalar|Param|null, // Default: "en" * set_locale_from_accept_language?: bool|Param, // Whether to use the Accept-Language HTTP header to set the Request locale (only when the "_locale" request attribute is not passed). // Default: false * set_content_language_from_locale?: bool|Param, // Whether to set the Content-Language HTTP header on the Response using the Request locale. // Default: false * enabled_locales?: list, * trusted_hosts?: list, * trusted_proxies?: mixed, // Default: ["%env(default::SYMFONY_TRUSTED_PROXIES)%"] * trusted_headers?: list, * error_controller?: scalar|Param|null, // Default: "error_controller" * handle_all_throwables?: bool|Param, // HttpKernel will handle all kinds of \Throwable. // Default: true * csrf_protection?: bool|array{ * enabled?: scalar|Param|null, // Default: null * stateless_token_ids?: list, * check_header?: scalar|Param|null, // Whether to check the CSRF token in a header in addition to a cookie when using stateless protection. // Default: false * cookie_name?: scalar|Param|null, // The name of the cookie to use when using stateless protection. // Default: "csrf-token" * }, * form?: bool|array{ // Form configuration * enabled?: bool|Param, // Default: true * csrf_protection?: bool|array{ * enabled?: scalar|Param|null, // Default: null * token_id?: scalar|Param|null, // Default: null * field_name?: scalar|Param|null, // Default: "_token" * field_attr?: array, * }, * }, * http_cache?: bool|array{ // HTTP cache configuration * enabled?: bool|Param, // Default: false * debug?: bool|Param, // Default: "%kernel.debug%" * trace_level?: "none"|"short"|"full"|Param, * trace_header?: scalar|Param|null, * default_ttl?: int|Param, * private_headers?: list, * skip_response_headers?: list, * allow_reload?: bool|Param, * allow_revalidate?: bool|Param, * stale_while_revalidate?: int|Param, * stale_if_error?: int|Param, * terminate_on_cache_hit?: bool|Param, * }, * esi?: bool|array{ // ESI configuration * enabled?: bool|Param, // Default: false * }, * ssi?: bool|array{ // SSI configuration * enabled?: bool|Param, // Default: false * }, * fragments?: bool|array{ // Fragments configuration * enabled?: bool|Param, // Default: false * hinclude_default_template?: scalar|Param|null, // Default: null * path?: scalar|Param|null, // Default: "/_fragment" * }, * profiler?: bool|array{ // Profiler configuration * enabled?: bool|Param, // Default: false * collect?: bool|Param, // Default: true * collect_parameter?: scalar|Param|null, // The name of the parameter to use to enable or disable collection on a per request basis. // Default: null * only_exceptions?: bool|Param, // Default: false * only_main_requests?: bool|Param, // Default: false * dsn?: scalar|Param|null, // Default: "file:%kernel.cache_dir%/profiler" * collect_serializer_data?: bool|Param, // Enables the serializer data collector and profiler panel. // Default: false * }, * workflows?: bool|array{ * enabled?: bool|Param, // Default: false * workflows?: array, * definition_validators?: list, * support_strategy?: scalar|Param|null, * initial_marking?: list, * events_to_dispatch?: list|null, * places?: list, * }>, * transitions?: list, * to?: list, * weight?: int|Param, // Default: 1 * metadata?: array, * }>, * metadata?: array, * }>, * }, * router?: bool|array{ // Router configuration * enabled?: bool|Param, // Default: false * resource?: scalar|Param|null, * type?: scalar|Param|null, * cache_dir?: scalar|Param|null, // Deprecated: Setting the "framework.router.cache_dir.cache_dir" configuration option is deprecated. It will be removed in version 8.0. // Default: "%kernel.build_dir%" * default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null * http_port?: scalar|Param|null, // Default: 80 * https_port?: scalar|Param|null, // Default: 443 * strict_requirements?: scalar|Param|null, // set to true to throw an exception when a parameter does not match the requirements set to false to disable exceptions when a parameter does not match the requirements (and return null instead) set to null to disable parameter checks against requirements 'true' is the preferred configuration in development mode, while 'false' or 'null' might be preferred in production // Default: true * utf8?: bool|Param, // Default: true * }, * session?: bool|array{ // Session configuration * enabled?: bool|Param, // Default: false * storage_factory_id?: scalar|Param|null, // Default: "session.storage.factory.native" * handler_id?: scalar|Param|null, // Defaults to using the native session handler, or to the native *file* session handler if "save_path" is not null. * name?: scalar|Param|null, * cookie_lifetime?: scalar|Param|null, * cookie_path?: scalar|Param|null, * cookie_domain?: scalar|Param|null, * cookie_secure?: true|false|"auto"|Param, // Default: "auto" * cookie_httponly?: bool|Param, // Default: true * cookie_samesite?: null|"lax"|"strict"|"none"|Param, // Default: "lax" * use_cookies?: bool|Param, * gc_divisor?: scalar|Param|null, * gc_probability?: scalar|Param|null, * gc_maxlifetime?: scalar|Param|null, * save_path?: scalar|Param|null, // Defaults to "%kernel.cache_dir%/sessions" if the "handler_id" option is not null. * metadata_update_threshold?: int|Param, // Seconds to wait between 2 session metadata updates. // Default: 0 * sid_length?: int|Param, // Deprecated: Setting the "framework.session.sid_length.sid_length" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option. * sid_bits_per_character?: int|Param, // Deprecated: Setting the "framework.session.sid_bits_per_character.sid_bits_per_character" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option. * }, * request?: bool|array{ // Request configuration * enabled?: bool|Param, // Default: false * formats?: array>, * }, * assets?: bool|array{ // Assets configuration * enabled?: bool|Param, // Default: true * strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false * version_strategy?: scalar|Param|null, // Default: null * version?: scalar|Param|null, // Default: null * version_format?: scalar|Param|null, // Default: "%%s?%%s" * json_manifest_path?: scalar|Param|null, // Default: null * base_path?: scalar|Param|null, // Default: "" * base_urls?: list, * packages?: array, * }>, * }, * asset_mapper?: bool|array{ // Asset Mapper configuration * enabled?: bool|Param, // Default: false * paths?: array, * excluded_patterns?: list, * exclude_dotfiles?: bool|Param, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true * server?: bool|Param, // If true, a "dev server" will return the assets from the public directory (true in "debug" mode only by default). // Default: true * public_prefix?: scalar|Param|null, // The public path where the assets will be written to (and served from when "server" is true). // Default: "/assets/" * missing_import_mode?: "strict"|"warn"|"ignore"|Param, // Behavior if an asset cannot be found when imported from JavaScript or CSS files - e.g. "import './non-existent.js'". "strict" means an exception is thrown, "warn" means a warning is logged, "ignore" means the import is left as-is. // Default: "warn" * extensions?: array, * importmap_path?: scalar|Param|null, // The path of the importmap.php file. // Default: "%kernel.project_dir%/importmap.php" * importmap_polyfill?: scalar|Param|null, // The importmap name that will be used to load the polyfill. Set to false to disable. // Default: "es-module-shims" * importmap_script_attributes?: array, * vendor_dir?: scalar|Param|null, // The directory to store JavaScript vendors. // Default: "%kernel.project_dir%/assets/vendor" * precompress?: bool|array{ // Precompress assets with Brotli, Zstandard and gzip. * enabled?: bool|Param, // Default: false * formats?: list, * extensions?: list, * }, * }, * translator?: bool|array{ // Translator configuration * enabled?: bool|Param, // Default: true * fallbacks?: list, * logging?: bool|Param, // Default: false * formatter?: scalar|Param|null, // Default: "translator.formatter.default" * cache_dir?: scalar|Param|null, // Default: "%kernel.cache_dir%/translations" * default_path?: scalar|Param|null, // The default path used to load translations. // Default: "%kernel.project_dir%/translations" * paths?: list, * pseudo_localization?: bool|array{ * enabled?: bool|Param, // Default: false * accents?: bool|Param, // Default: true * expansion_factor?: float|Param, // Default: 1.0 * brackets?: bool|Param, // Default: true * parse_html?: bool|Param, // Default: false * localizable_html_attributes?: list, * }, * providers?: array, * locales?: list, * }>, * globals?: array, * domain?: string|Param, * }>, * }, * validation?: bool|array{ // Validation configuration * enabled?: bool|Param, // Default: true * cache?: scalar|Param|null, // Deprecated: Setting the "framework.validation.cache.cache" configuration option is deprecated. It will be removed in version 8.0. * enable_attributes?: bool|Param, // Default: true * static_method?: list, * translation_domain?: scalar|Param|null, // Default: "validators" * email_validation_mode?: "html5"|"html5-allow-no-tld"|"strict"|"loose"|Param, // Default: "html5" * mapping?: array{ * paths?: list, * }, * not_compromised_password?: bool|array{ * enabled?: bool|Param, // When disabled, compromised passwords will be accepted as valid. // Default: true * endpoint?: scalar|Param|null, // API endpoint for the NotCompromisedPassword Validator. // Default: null * }, * disable_translation?: bool|Param, // Default: false * auto_mapping?: array, * }>, * }, * annotations?: bool|array{ * enabled?: bool|Param, // Default: false * }, * serializer?: bool|array{ // Serializer configuration * enabled?: bool|Param, // Default: true * enable_attributes?: bool|Param, // Default: true * name_converter?: scalar|Param|null, * circular_reference_handler?: scalar|Param|null, * max_depth_handler?: scalar|Param|null, * mapping?: array{ * paths?: list, * }, * default_context?: array, * named_serializers?: array, * include_built_in_normalizers?: bool|Param, // Whether to include the built-in normalizers // Default: true * include_built_in_encoders?: bool|Param, // Whether to include the built-in encoders // Default: true * }>, * }, * property_access?: bool|array{ // Property access configuration * enabled?: bool|Param, // Default: true * magic_call?: bool|Param, // Default: false * magic_get?: bool|Param, // Default: true * magic_set?: bool|Param, // Default: true * throw_exception_on_invalid_index?: bool|Param, // Default: false * throw_exception_on_invalid_property_path?: bool|Param, // Default: true * }, * type_info?: bool|array{ // Type info configuration * enabled?: bool|Param, // Default: true * aliases?: array, * }, * property_info?: bool|array{ // Property info configuration * enabled?: bool|Param, // Default: true * with_constructor_extractor?: bool|Param, // Registers the constructor extractor. * }, * cache?: array{ // Cache configuration * prefix_seed?: scalar|Param|null, // Used to namespace cache keys when using several apps with the same shared backend. // Default: "_%kernel.project_dir%.%kernel.container_class%" * app?: scalar|Param|null, // App related cache pools configuration. // Default: "cache.adapter.filesystem" * system?: scalar|Param|null, // System related cache pools configuration. // Default: "cache.adapter.system" * directory?: scalar|Param|null, // Default: "%kernel.share_dir%/pools/app" * default_psr6_provider?: scalar|Param|null, * default_redis_provider?: scalar|Param|null, // Default: "redis://localhost" * default_valkey_provider?: scalar|Param|null, // Default: "valkey://localhost" * default_memcached_provider?: scalar|Param|null, // Default: "memcached://localhost" * default_doctrine_dbal_provider?: scalar|Param|null, // Default: "database_connection" * default_pdo_provider?: scalar|Param|null, // Default: null * pools?: array, * tags?: scalar|Param|null, // Default: null * public?: bool|Param, // Default: false * default_lifetime?: scalar|Param|null, // Default lifetime of the pool. * provider?: scalar|Param|null, // Overwrite the setting from the default provider for this adapter. * early_expiration_message_bus?: scalar|Param|null, * clearer?: scalar|Param|null, * }>, * }, * php_errors?: array{ // PHP errors handling configuration * log?: mixed, // Use the application logger instead of the PHP logger for logging PHP errors. // Default: true * throw?: bool|Param, // Throw PHP errors as \ErrorException instances. // Default: true * }, * exceptions?: array, * web_link?: bool|array{ // Web links configuration * enabled?: bool|Param, // Default: true * }, * lock?: bool|string|array{ // Lock configuration * enabled?: bool|Param, // Default: false * resources?: array>, * }, * semaphore?: bool|string|array{ // Semaphore configuration * enabled?: bool|Param, // Default: false * resources?: array, * }, * messenger?: bool|array{ // Messenger configuration * enabled?: bool|Param, // Default: false * routing?: array, * }>, * serializer?: array{ * default_serializer?: scalar|Param|null, // Service id to use as the default serializer for the transports. // Default: "messenger.transport.native_php_serializer" * symfony_serializer?: array{ * format?: scalar|Param|null, // Serialization format for the messenger.transport.symfony_serializer service (which is not the serializer used by default). // Default: "json" * context?: array, * }, * }, * transports?: array, * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null * retry_strategy?: string|array{ * service?: scalar|Param|null, // Service id to override the retry strategy entirely. // Default: null * max_retries?: int|Param, // Default: 3 * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: this delay = (delay * (multiple ^ retries)). // Default: 2 * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 * jitter?: float|Param, // Randomness to apply to the delay (between 0 and 1). // Default: 0.1 * }, * rate_limiter?: scalar|Param|null, // Rate limiter name to use when processing messages. // Default: null * }>, * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null * stop_worker_on_signals?: list, * default_bus?: scalar|Param|null, // Default: null * buses?: array, * }>, * }>, * }, * scheduler?: bool|array{ // Scheduler configuration * enabled?: bool|Param, // Default: false * }, * disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true * http_client?: bool|array{ // HTTP Client configuration * enabled?: bool|Param, // Default: true * max_host_connections?: int|Param, // The maximum number of connections to a single host. * default_options?: array{ * headers?: array, * vars?: array, * max_redirects?: int|Param, // The maximum number of redirects to follow. * http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. * resolve?: array, * proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection. * no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached. * timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter. * max_duration?: float|Param, // The maximum execution time for the request+response as a whole. * bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to. * verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context. * verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name. * cafile?: scalar|Param|null, // A certificate authority file. * capath?: scalar|Param|null, // A directory that contains multiple certificate authority files. * local_cert?: scalar|Param|null, // A PEM formatted certificate file. * local_pk?: scalar|Param|null, // A private key file. * passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file. * ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...) * peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es). * sha1?: mixed, * pin-sha256?: mixed, * md5?: mixed, * }, * crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. * extra?: array, * rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null * caching?: bool|array{ // Caching configuration. * enabled?: bool|Param, // Default: false * cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" * shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true * max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null * }, * retry_failed?: bool|array{ * enabled?: bool|Param, // Default: false * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null * http_codes?: array, * }>, * max_retries?: int|Param, // Default: 3 * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2 * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 * jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1 * }, * }, * mock_response_factory?: scalar|Param|null, // The id of the service that should generate mock responses. It should be either an invokable or an iterable. * scoped_clients?: array, * headers?: array, * max_redirects?: int|Param, // The maximum number of redirects to follow. * http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. * resolve?: array, * proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection. * no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached. * timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter. * max_duration?: float|Param, // The maximum execution time for the request+response as a whole. * bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to. * verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context. * verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name. * cafile?: scalar|Param|null, // A certificate authority file. * capath?: scalar|Param|null, // A directory that contains multiple certificate authority files. * local_cert?: scalar|Param|null, // A PEM formatted certificate file. * local_pk?: scalar|Param|null, // A private key file. * passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file. * ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...). * peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es). * sha1?: mixed, * pin-sha256?: mixed, * md5?: mixed, * }, * crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. * extra?: array, * rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null * caching?: bool|array{ // Caching configuration. * enabled?: bool|Param, // Default: false * cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" * shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true * max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null * }, * retry_failed?: bool|array{ * enabled?: bool|Param, // Default: false * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null * http_codes?: array, * }>, * max_retries?: int|Param, // Default: 3 * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2 * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 * jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1 * }, * }>, * }, * mailer?: bool|array{ // Mailer configuration * enabled?: bool|Param, // Default: true * message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null * dsn?: scalar|Param|null, // Default: null * transports?: array, * envelope?: array{ // Mailer Envelope configuration * sender?: scalar|Param|null, * recipients?: list, * allowed_recipients?: list, * }, * headers?: array, * dkim_signer?: bool|array{ // DKIM signer configuration * enabled?: bool|Param, // Default: false * key?: scalar|Param|null, // Key content, or path to key (in PEM format with the `file://` prefix) // Default: "" * domain?: scalar|Param|null, // Default: "" * select?: scalar|Param|null, // Default: "" * passphrase?: scalar|Param|null, // The private key passphrase // Default: "" * options?: array, * }, * smime_signer?: bool|array{ // S/MIME signer configuration * enabled?: bool|Param, // Default: false * key?: scalar|Param|null, // Path to key (in PEM format) // Default: "" * certificate?: scalar|Param|null, // Path to certificate (in PEM format without the `file://` prefix) // Default: "" * passphrase?: scalar|Param|null, // The private key passphrase // Default: null * extra_certificates?: scalar|Param|null, // Default: null * sign_options?: int|Param, // Default: null * }, * smime_encrypter?: bool|array{ // S/MIME encrypter configuration * enabled?: bool|Param, // Default: false * repository?: scalar|Param|null, // S/MIME certificate repository service. This service shall implement the `Symfony\Component\Mailer\EventListener\SmimeCertificateRepositoryInterface`. // Default: "" * cipher?: int|Param, // A set of algorithms used to encrypt the message // Default: null * }, * }, * secrets?: bool|array{ * enabled?: bool|Param, // Default: true * vault_directory?: scalar|Param|null, // Default: "%kernel.project_dir%/config/secrets/%kernel.runtime_environment%" * local_dotenv_file?: scalar|Param|null, // Default: "%kernel.project_dir%/.env.%kernel.environment%.local" * decryption_env_var?: scalar|Param|null, // Default: "base64:default::SYMFONY_DECRYPTION_SECRET" * }, * notifier?: bool|array{ // Notifier configuration * enabled?: bool|Param, // Default: false * message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null * chatter_transports?: array, * texter_transports?: array, * notification_on_failed_messages?: bool|Param, // Default: false * channel_policy?: array>, * admin_recipients?: list, * }, * rate_limiter?: bool|array{ // Rate limiter configuration * enabled?: bool|Param, // Default: false * limiters?: array, * limit?: int|Param, // The maximum allowed hits in a fixed interval or burst. * interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). * rate?: array{ // Configures the fill rate if "policy" is set to "token_bucket". * interval?: scalar|Param|null, // Configures the rate interval. The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). * amount?: int|Param, // Amount of tokens to add each interval. // Default: 1 * }, * }>, * }, * uid?: bool|array{ // Uid configuration * enabled?: bool|Param, // Default: false * default_uuid_version?: 7|6|4|1|Param, // Default: 7 * name_based_uuid_version?: 5|3|Param, // Default: 5 * name_based_uuid_namespace?: scalar|Param|null, * time_based_uuid_version?: 7|6|1|Param, // Default: 7 * time_based_uuid_node?: scalar|Param|null, * }, * html_sanitizer?: bool|array{ // HtmlSanitizer configuration * enabled?: bool|Param, // Default: false * sanitizers?: array, * block_elements?: list, * drop_elements?: list, * allow_attributes?: array, * drop_attributes?: array, * force_attributes?: array>, * force_https_urls?: bool|Param, // Transforms URLs using the HTTP scheme to use the HTTPS scheme instead. // Default: false * allowed_link_schemes?: list, * allowed_link_hosts?: list|null, * allow_relative_links?: bool|Param, // Allows relative URLs to be used in links href attributes. // Default: false * allowed_media_schemes?: list, * allowed_media_hosts?: list|null, * allow_relative_medias?: bool|Param, // Allows relative URLs to be used in media source attributes (img, audio, video, ...). // Default: false * with_attribute_sanitizers?: list, * without_attribute_sanitizers?: list, * max_input_length?: int|Param, // The maximum length allowed for the sanitized input. // Default: 0 * }>, * }, * webhook?: bool|array{ // Webhook configuration * enabled?: bool|Param, // Default: false * message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus" * routing?: array, * }, * remote-event?: bool|array{ // RemoteEvent configuration * enabled?: bool|Param, // Default: false * }, * json_streamer?: bool|array{ // JSON streamer configuration * enabled?: bool|Param, // Default: false * }, * } * @psalm-type DoctrineConfig = array{ * dbal?: array{ * default_connection?: scalar|Param|null, * types?: array, * driver_schemes?: array, * connections?: array, * mapping_types?: array, * default_table_options?: array, * schema_manager_factory?: scalar|Param|null, // Default: "doctrine.dbal.legacy_schema_manager_factory" * result_cache?: scalar|Param|null, * slaves?: array, * replicas?: array, * }>, * }, * orm?: array{ * default_entity_manager?: scalar|Param|null, * auto_generate_proxy_classes?: scalar|Param|null, // Auto generate mode possible values are: "NEVER", "ALWAYS", "FILE_NOT_EXISTS", "EVAL", "FILE_NOT_EXISTS_OR_CHANGED", this option is ignored when the "enable_native_lazy_objects" option is true // Default: false * enable_lazy_ghost_objects?: bool|Param, // Enables the new implementation of proxies based on lazy ghosts instead of using the legacy implementation // Default: false * enable_native_lazy_objects?: bool|Param, // Enables the new native implementation of PHP lazy objects instead of generated proxies // Default: false * proxy_dir?: scalar|Param|null, // Configures the path where generated proxy classes are saved when using non-native lazy objects, this option is ignored when the "enable_native_lazy_objects" option is true // Default: "%kernel.build_dir%/doctrine/orm/Proxies" * proxy_namespace?: scalar|Param|null, // Defines the root namespace for generated proxy classes when using non-native lazy objects, this option is ignored when the "enable_native_lazy_objects" option is true // Default: "Proxies" * controller_resolver?: bool|array{ * enabled?: bool|Param, // Default: true * auto_mapping?: bool|Param|null, // Set to false to disable using route placeholders as lookup criteria when the primary key doesn't match the argument name // Default: null * evict_cache?: bool|Param, // Set to true to fetch the entity from the database instead of using the cache, if any // Default: false * }, * entity_managers?: array, * }>, * }>, * }, * connection?: scalar|Param|null, * class_metadata_factory_name?: scalar|Param|null, // Default: "Doctrine\\ORM\\Mapping\\ClassMetadataFactory" * default_repository_class?: scalar|Param|null, // Default: "Doctrine\\ORM\\EntityRepository" * auto_mapping?: scalar|Param|null, // Default: false * naming_strategy?: scalar|Param|null, // Default: "doctrine.orm.naming_strategy.default" * quote_strategy?: scalar|Param|null, // Default: "doctrine.orm.quote_strategy.default" * typed_field_mapper?: scalar|Param|null, // Default: "doctrine.orm.typed_field_mapper.default" * entity_listener_resolver?: scalar|Param|null, // Default: null * fetch_mode_subselect_batch_size?: scalar|Param|null, * repository_factory?: scalar|Param|null, // Default: "doctrine.orm.container_repository_factory" * schema_ignore_classes?: list, * report_fields_where_declared?: bool|Param, // Set to "true" to opt-in to the new mapping driver mode that was added in Doctrine ORM 2.16 and will be mandatory in ORM 3.0. See https://github.com/doctrine/orm/pull/10455. // Default: false * validate_xml_mapping?: bool|Param, // Set to "true" to opt-in to the new mapping driver mode that was added in Doctrine ORM 2.14. See https://github.com/doctrine/orm/pull/6728. // Default: false * second_level_cache?: array{ * region_cache_driver?: string|array{ * type?: scalar|Param|null, // Default: null * id?: scalar|Param|null, * pool?: scalar|Param|null, * }, * region_lock_lifetime?: scalar|Param|null, // Default: 60 * log_enabled?: bool|Param, // Default: true * region_lifetime?: scalar|Param|null, // Default: 3600 * enabled?: bool|Param, // Default: true * factory?: scalar|Param|null, * regions?: array, * loggers?: array, * }, * hydrators?: array, * mappings?: array, * dql?: array{ * string_functions?: array, * numeric_functions?: array, * datetime_functions?: array, * }, * filters?: array, * }>, * identity_generation_preferences?: array, * }>, * resolve_target_entities?: array, * }, * } * @psalm-type DoctrineMigrationsConfig = array{ * enable_service_migrations?: bool|Param, // Whether to enable fetching migrations from the service container. // Default: false * migrations_paths?: array, * services?: array, * factories?: array, * storage?: array{ // Storage to use for migration status metadata. * table_storage?: array{ // The default metadata storage, implemented as a table in the database. * table_name?: scalar|Param|null, // Default: null * version_column_name?: scalar|Param|null, // Default: null * version_column_length?: scalar|Param|null, // Default: null * executed_at_column_name?: scalar|Param|null, // Default: null * execution_time_column_name?: scalar|Param|null, // Default: null * }, * }, * migrations?: list, * connection?: scalar|Param|null, // Connection name to use for the migrations database. // Default: null * em?: scalar|Param|null, // Entity manager name to use for the migrations database (available when doctrine/orm is installed). // Default: null * all_or_nothing?: scalar|Param|null, // Run all migrations in a transaction. // Default: false * check_database_platform?: scalar|Param|null, // Adds an extra check in the generated migrations to allow execution only on the same platform as they were initially generated on. // Default: true * custom_template?: scalar|Param|null, // Custom template path for generated migration classes. // Default: null * organize_migrations?: scalar|Param|null, // Organize migrations mode. Possible values are: "BY_YEAR", "BY_YEAR_AND_MONTH", false // Default: false * enable_profiler?: bool|Param, // Whether or not to enable the profiler collector to calculate and visualize migration status. This adds some queries overhead. // Default: false * transactional?: bool|Param, // Whether or not to wrap migrations in a single transaction. // Default: true * } * @psalm-type SecurityConfig = array{ * access_denied_url?: scalar|Param|null, // Default: null * session_fixation_strategy?: "none"|"migrate"|"invalidate"|Param, // Default: "migrate" * hide_user_not_found?: bool|Param, // Deprecated: The "hide_user_not_found" option is deprecated and will be removed in 8.0. Use the "expose_security_errors" option instead. * expose_security_errors?: \Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::None|\Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::AccountStatus|\Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::All|Param, // Default: "none" * erase_credentials?: bool|Param, // Default: true * access_decision_manager?: array{ * strategy?: "affirmative"|"consensus"|"unanimous"|"priority"|Param, * service?: scalar|Param|null, * strategy_service?: scalar|Param|null, * allow_if_all_abstain?: bool|Param, // Default: false * allow_if_equal_granted_denied?: bool|Param, // Default: true * }, * password_hashers?: array, * hash_algorithm?: scalar|Param|null, // Name of hashing algorithm for PBKDF2 (i.e. sha256, sha512, etc..) See hash_algos() for a list of supported algorithms. // Default: "sha512" * key_length?: scalar|Param|null, // Default: 40 * ignore_case?: bool|Param, // Default: false * encode_as_base64?: bool|Param, // Default: true * iterations?: scalar|Param|null, // Default: 5000 * cost?: int|Param, // Default: null * memory_cost?: scalar|Param|null, // Default: null * time_cost?: scalar|Param|null, // Default: null * id?: scalar|Param|null, * }>, * providers?: array, * }, * entity?: array{ * class?: scalar|Param|null, // The full entity class name of your user class. * property?: scalar|Param|null, // Default: null * manager_name?: scalar|Param|null, // Default: null * }, * memory?: array{ * users?: array, * }>, * }, * ldap?: array{ * service?: scalar|Param|null, * base_dn?: scalar|Param|null, * search_dn?: scalar|Param|null, // Default: null * search_password?: scalar|Param|null, // Default: null * extra_fields?: list, * default_roles?: list, * role_fetcher?: scalar|Param|null, // Default: null * uid_key?: scalar|Param|null, // Default: "sAMAccountName" * filter?: scalar|Param|null, // Default: "({uid_key}={user_identifier})" * password_attribute?: scalar|Param|null, // Default: null * }, * }>, * firewalls?: array, * security?: bool|Param, // Default: true * user_checker?: scalar|Param|null, // The UserChecker to use when authenticating users in this firewall. // Default: "security.user_checker" * request_matcher?: scalar|Param|null, * access_denied_url?: scalar|Param|null, * access_denied_handler?: scalar|Param|null, * entry_point?: scalar|Param|null, // An enabled authenticator name or a service id that implements "Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface". * provider?: scalar|Param|null, * stateless?: bool|Param, // Default: false * lazy?: bool|Param, // Default: false * context?: scalar|Param|null, * logout?: array{ * enable_csrf?: bool|Param|null, // Default: null * csrf_token_id?: scalar|Param|null, // Default: "logout" * csrf_parameter?: scalar|Param|null, // Default: "_csrf_token" * csrf_token_manager?: scalar|Param|null, * path?: scalar|Param|null, // Default: "/logout" * target?: scalar|Param|null, // Default: "/" * invalidate_session?: bool|Param, // Default: true * clear_site_data?: list<"*"|"cache"|"cookies"|"storage"|"executionContexts"|Param>, * delete_cookies?: array, * }, * switch_user?: array{ * provider?: scalar|Param|null, * parameter?: scalar|Param|null, // Default: "_switch_user" * role?: scalar|Param|null, // Default: "ROLE_ALLOWED_TO_SWITCH" * target_route?: scalar|Param|null, // Default: null * }, * required_badges?: list, * custom_authenticators?: list, * login_throttling?: array{ * limiter?: scalar|Param|null, // A service id implementing "Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface". * max_attempts?: int|Param, // Default: 5 * interval?: scalar|Param|null, // Default: "1 minute" * lock_factory?: scalar|Param|null, // The service ID of the lock factory used by the login rate limiter (or null to disable locking). // Default: null * cache_pool?: string|Param, // The cache pool to use for storing the limiter state // Default: "cache.rate_limiter" * storage_service?: string|Param, // The service ID of a custom storage implementation, this precedes any configured "cache_pool" // Default: null * }, * x509?: array{ * provider?: scalar|Param|null, * user?: scalar|Param|null, // Default: "SSL_CLIENT_S_DN_Email" * credentials?: scalar|Param|null, // Default: "SSL_CLIENT_S_DN" * user_identifier?: scalar|Param|null, // Default: "emailAddress" * }, * remote_user?: array{ * provider?: scalar|Param|null, * user?: scalar|Param|null, // Default: "REMOTE_USER" * }, * login_link?: array{ * check_route?: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify". * check_post_only?: scalar|Param|null, // If true, only HTTP POST requests to "check_route" will be handled by the authenticator. // Default: false * signature_properties?: list, * lifetime?: int|Param, // The lifetime of the login link in seconds. // Default: 600 * max_uses?: int|Param, // Max number of times a login link can be used - null means unlimited within lifetime. // Default: null * used_link_cache?: scalar|Param|null, // Cache service id used to expired links of max_uses is set. * success_handler?: scalar|Param|null, // A service id that implements Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface. * failure_handler?: scalar|Param|null, // A service id that implements Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface. * provider?: scalar|Param|null, // The user provider to load users from. * secret?: scalar|Param|null, // Default: "%kernel.secret%" * always_use_default_target_path?: bool|Param, // Default: false * default_target_path?: scalar|Param|null, // Default: "/" * login_path?: scalar|Param|null, // Default: "/login" * target_path_parameter?: scalar|Param|null, // Default: "_target_path" * use_referer?: bool|Param, // Default: false * failure_path?: scalar|Param|null, // Default: null * failure_forward?: bool|Param, // Default: false * failure_path_parameter?: scalar|Param|null, // Default: "_failure_path" * }, * form_login?: array{ * provider?: scalar|Param|null, * remember_me?: bool|Param, // Default: true * success_handler?: scalar|Param|null, * failure_handler?: scalar|Param|null, * check_path?: scalar|Param|null, // Default: "/login_check" * use_forward?: bool|Param, // Default: false * login_path?: scalar|Param|null, // Default: "/login" * username_parameter?: scalar|Param|null, // Default: "_username" * password_parameter?: scalar|Param|null, // Default: "_password" * csrf_parameter?: scalar|Param|null, // Default: "_csrf_token" * csrf_token_id?: scalar|Param|null, // Default: "authenticate" * enable_csrf?: bool|Param, // Default: false * post_only?: bool|Param, // Default: true * form_only?: bool|Param, // Default: false * always_use_default_target_path?: bool|Param, // Default: false * default_target_path?: scalar|Param|null, // Default: "/" * target_path_parameter?: scalar|Param|null, // Default: "_target_path" * use_referer?: bool|Param, // Default: false * failure_path?: scalar|Param|null, // Default: null * failure_forward?: bool|Param, // Default: false * failure_path_parameter?: scalar|Param|null, // Default: "_failure_path" * }, * form_login_ldap?: array{ * provider?: scalar|Param|null, * remember_me?: bool|Param, // Default: true * success_handler?: scalar|Param|null, * failure_handler?: scalar|Param|null, * check_path?: scalar|Param|null, // Default: "/login_check" * use_forward?: bool|Param, // Default: false * login_path?: scalar|Param|null, // Default: "/login" * username_parameter?: scalar|Param|null, // Default: "_username" * password_parameter?: scalar|Param|null, // Default: "_password" * csrf_parameter?: scalar|Param|null, // Default: "_csrf_token" * csrf_token_id?: scalar|Param|null, // Default: "authenticate" * enable_csrf?: bool|Param, // Default: false * post_only?: bool|Param, // Default: true * form_only?: bool|Param, // Default: false * always_use_default_target_path?: bool|Param, // Default: false * default_target_path?: scalar|Param|null, // Default: "/" * target_path_parameter?: scalar|Param|null, // Default: "_target_path" * use_referer?: bool|Param, // Default: false * failure_path?: scalar|Param|null, // Default: null * failure_forward?: bool|Param, // Default: false * failure_path_parameter?: scalar|Param|null, // Default: "_failure_path" * service?: scalar|Param|null, // Default: "ldap" * dn_string?: scalar|Param|null, // Default: "{user_identifier}" * query_string?: scalar|Param|null, * search_dn?: scalar|Param|null, // Default: "" * search_password?: scalar|Param|null, // Default: "" * }, * json_login?: array{ * provider?: scalar|Param|null, * remember_me?: bool|Param, // Default: true * success_handler?: scalar|Param|null, * failure_handler?: scalar|Param|null, * check_path?: scalar|Param|null, // Default: "/login_check" * use_forward?: bool|Param, // Default: false * login_path?: scalar|Param|null, // Default: "/login" * username_path?: scalar|Param|null, // Default: "username" * password_path?: scalar|Param|null, // Default: "password" * }, * json_login_ldap?: array{ * provider?: scalar|Param|null, * remember_me?: bool|Param, // Default: true * success_handler?: scalar|Param|null, * failure_handler?: scalar|Param|null, * check_path?: scalar|Param|null, // Default: "/login_check" * use_forward?: bool|Param, // Default: false * login_path?: scalar|Param|null, // Default: "/login" * username_path?: scalar|Param|null, // Default: "username" * password_path?: scalar|Param|null, // Default: "password" * service?: scalar|Param|null, // Default: "ldap" * dn_string?: scalar|Param|null, // Default: "{user_identifier}" * query_string?: scalar|Param|null, * search_dn?: scalar|Param|null, // Default: "" * search_password?: scalar|Param|null, // Default: "" * }, * access_token?: array{ * provider?: scalar|Param|null, * remember_me?: bool|Param, // Default: true * success_handler?: scalar|Param|null, * failure_handler?: scalar|Param|null, * realm?: scalar|Param|null, // Default: null * token_extractors?: list, * token_handler?: string|array{ * id?: scalar|Param|null, * oidc_user_info?: string|array{ * base_uri?: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured). * discovery?: array{ // Enable the OIDC discovery. * cache?: array{ * id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. * }, * }, * claim?: scalar|Param|null, // Claim which contains the user identifier (e.g. sub, email, etc.). // Default: "sub" * client?: scalar|Param|null, // HttpClient service id to use to call the OIDC server. * }, * oidc?: array{ * discovery?: array{ // Enable the OIDC discovery. * base_uri?: list, * cache?: array{ * id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. * }, * }, * claim?: scalar|Param|null, // Claim which contains the user identifier (e.g.: sub, email..). // Default: "sub" * audience?: scalar|Param|null, // Audience set in the token, for validation purpose. * issuers?: list, * algorithm?: array, * algorithms?: list, * key?: scalar|Param|null, // Deprecated: The "key" option is deprecated and will be removed in 8.0. Use the "keyset" option instead. // JSON-encoded JWK used to sign the token (must contain a "kty" key). * keyset?: scalar|Param|null, // JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys). * encryption?: bool|array{ * enabled?: bool|Param, // Default: false * enforce?: bool|Param, // When enabled, the token shall be encrypted. // Default: false * algorithms?: list, * keyset?: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys). * }, * }, * cas?: array{ * validation_url?: scalar|Param|null, // CAS server validation URL * prefix?: scalar|Param|null, // CAS prefix // Default: "cas" * http_client?: scalar|Param|null, // HTTP Client service // Default: null * }, * oauth2?: scalar|Param|null, * }, * }, * http_basic?: array{ * provider?: scalar|Param|null, * realm?: scalar|Param|null, // Default: "Secured Area" * }, * http_basic_ldap?: array{ * provider?: scalar|Param|null, * realm?: scalar|Param|null, // Default: "Secured Area" * service?: scalar|Param|null, // Default: "ldap" * dn_string?: scalar|Param|null, // Default: "{user_identifier}" * query_string?: scalar|Param|null, * search_dn?: scalar|Param|null, // Default: "" * search_password?: scalar|Param|null, // Default: "" * }, * remember_me?: array{ * secret?: scalar|Param|null, // Default: "%kernel.secret%" * service?: scalar|Param|null, * user_providers?: list, * catch_exceptions?: bool|Param, // Default: true * signature_properties?: list, * token_provider?: string|array{ * service?: scalar|Param|null, // The service ID of a custom remember-me token provider. * doctrine?: bool|array{ * enabled?: bool|Param, // Default: false * connection?: scalar|Param|null, // Default: null * }, * }, * token_verifier?: scalar|Param|null, // The service ID of a custom rememberme token verifier. * name?: scalar|Param|null, // Default: "REMEMBERME" * lifetime?: int|Param, // Default: 31536000 * path?: scalar|Param|null, // Default: "/" * domain?: scalar|Param|null, // Default: null * secure?: true|false|"auto"|Param, // Default: null * httponly?: bool|Param, // Default: true * samesite?: null|"lax"|"strict"|"none"|Param, // Default: "lax" * always_remember_me?: bool|Param, // Default: false * remember_me_parameter?: scalar|Param|null, // Default: "_remember_me" * }, * }>, * access_control?: list, * attributes?: array, * route?: scalar|Param|null, // Default: null * methods?: list, * allow_if?: scalar|Param|null, // Default: null * roles?: list, * }>, * role_hierarchy?: array>, * } * @psalm-type TwigConfig = array{ * form_themes?: list, * globals?: array, * autoescape_service?: scalar|Param|null, // Default: null * autoescape_service_method?: scalar|Param|null, // Default: null * base_template_class?: scalar|Param|null, // Deprecated: The child node "base_template_class" at path "twig.base_template_class" is deprecated. * cache?: scalar|Param|null, // Default: true * charset?: scalar|Param|null, // Default: "%kernel.charset%" * debug?: bool|Param, // Default: "%kernel.debug%" * strict_variables?: bool|Param, // Default: "%kernel.debug%" * auto_reload?: scalar|Param|null, * optimizations?: int|Param, * default_path?: scalar|Param|null, // The default path used to load templates. // Default: "%kernel.project_dir%/templates" * file_name_pattern?: list, * paths?: array, * date?: array{ // The default format options used by the date filter. * format?: scalar|Param|null, // Default: "F j, Y H:i" * interval_format?: scalar|Param|null, // Default: "%d days" * timezone?: scalar|Param|null, // The timezone used when formatting dates, when set to null, the timezone returned by date_default_timezone_get() is used. // Default: null * }, * number_format?: array{ // The default format options for the number_format filter. * decimals?: int|Param, // Default: 0 * decimal_point?: scalar|Param|null, // Default: "." * thousands_separator?: scalar|Param|null, // Default: "," * }, * mailer?: array{ * html_to_text_converter?: scalar|Param|null, // A service implementing the "Symfony\Component\Mime\HtmlToTextConverter\HtmlToTextConverterInterface". // Default: null * }, * } * @psalm-type WebProfilerConfig = array{ * toolbar?: bool|array{ // Profiler toolbar configuration * enabled?: bool|Param, // Default: false * ajax_replace?: bool|Param, // Replace toolbar on AJAX requests // Default: false * }, * intercept_redirects?: bool|Param, // Default: false * excluded_ajax_paths?: scalar|Param|null, // Default: "^/((index|app(_[\\w]+)?)\\.php/)?_wdt" * } * @psalm-type MonologConfig = array{ * use_microseconds?: scalar|Param|null, // Default: true * channels?: list, * handlers?: array, * excluded_http_codes?: list, * }>, * accepted_levels?: list, * min_level?: scalar|Param|null, // Default: "DEBUG" * max_level?: scalar|Param|null, // Default: "EMERGENCY" * buffer_size?: scalar|Param|null, // Default: 0 * flush_on_overflow?: bool|Param, // Default: false * handler?: scalar|Param|null, * url?: scalar|Param|null, * exchange?: scalar|Param|null, * exchange_name?: scalar|Param|null, // Default: "log" * room?: scalar|Param|null, * message_format?: scalar|Param|null, // Default: "text" * api_version?: scalar|Param|null, // Default: null * channel?: scalar|Param|null, // Default: null * bot_name?: scalar|Param|null, // Default: "Monolog" * use_attachment?: scalar|Param|null, // Default: true * use_short_attachment?: scalar|Param|null, // Default: false * include_extra?: scalar|Param|null, // Default: false * icon_emoji?: scalar|Param|null, // Default: null * webhook_url?: scalar|Param|null, * exclude_fields?: list, * team?: scalar|Param|null, * notify?: scalar|Param|null, // Default: false * nickname?: scalar|Param|null, // Default: "Monolog" * token?: scalar|Param|null, * region?: scalar|Param|null, * source?: scalar|Param|null, * use_ssl?: bool|Param, // Default: true * user?: mixed, * title?: scalar|Param|null, // Default: null * host?: scalar|Param|null, // Default: null * port?: scalar|Param|null, // Default: 514 * config?: list, * members?: list, * connection_string?: scalar|Param|null, * timeout?: scalar|Param|null, * time?: scalar|Param|null, // Default: 60 * deduplication_level?: scalar|Param|null, // Default: 400 * store?: scalar|Param|null, // Default: null * connection_timeout?: scalar|Param|null, * persistent?: bool|Param, * dsn?: scalar|Param|null, * hub_id?: scalar|Param|null, // Default: null * client_id?: scalar|Param|null, // Default: null * auto_log_stacks?: scalar|Param|null, // Default: false * release?: scalar|Param|null, // Default: null * environment?: scalar|Param|null, // Default: null * message_type?: scalar|Param|null, // Default: 0 * parse_mode?: scalar|Param|null, // Default: null * disable_webpage_preview?: bool|Param|null, // Default: null * disable_notification?: bool|Param|null, // Default: null * split_long_messages?: bool|Param, // Default: false * delay_between_messages?: bool|Param, // Default: false * topic?: int|Param, // Default: null * factor?: int|Param, // Default: 1 * tags?: list, * console_formater_options?: mixed, // Deprecated: "monolog.handlers..console_formater_options.console_formater_options" is deprecated, use "monolog.handlers..console_formater_options.console_formatter_options" instead. * console_formatter_options?: mixed, // Default: [] * formatter?: scalar|Param|null, * nested?: bool|Param, // Default: false * publisher?: string|array{ * id?: scalar|Param|null, * hostname?: scalar|Param|null, * port?: scalar|Param|null, // Default: 12201 * chunk_size?: scalar|Param|null, // Default: 1420 * encoder?: "json"|"compressed_json"|Param, * }, * mongo?: string|array{ * id?: scalar|Param|null, * host?: scalar|Param|null, * port?: scalar|Param|null, // Default: 27017 * user?: scalar|Param|null, * pass?: scalar|Param|null, * database?: scalar|Param|null, // Default: "monolog" * collection?: scalar|Param|null, // Default: "logs" * }, * mongodb?: string|array{ * id?: scalar|Param|null, // ID of a MongoDB\Client service * uri?: scalar|Param|null, * username?: scalar|Param|null, * password?: scalar|Param|null, * database?: scalar|Param|null, // Default: "monolog" * collection?: scalar|Param|null, // Default: "logs" * }, * elasticsearch?: string|array{ * id?: scalar|Param|null, * hosts?: list, * host?: scalar|Param|null, * port?: scalar|Param|null, // Default: 9200 * transport?: scalar|Param|null, // Default: "Http" * user?: scalar|Param|null, // Default: null * password?: scalar|Param|null, // Default: null * }, * index?: scalar|Param|null, // Default: "monolog" * document_type?: scalar|Param|null, // Default: "logs" * ignore_error?: scalar|Param|null, // Default: false * redis?: string|array{ * id?: scalar|Param|null, * host?: scalar|Param|null, * password?: scalar|Param|null, // Default: null * port?: scalar|Param|null, // Default: 6379 * database?: scalar|Param|null, // Default: 0 * key_name?: scalar|Param|null, // Default: "monolog_redis" * }, * predis?: string|array{ * id?: scalar|Param|null, * host?: scalar|Param|null, * }, * from_email?: scalar|Param|null, * to_email?: list, * subject?: scalar|Param|null, * content_type?: scalar|Param|null, // Default: null * headers?: list, * mailer?: scalar|Param|null, // Default: null * email_prototype?: string|array{ * id?: scalar|Param|null, * method?: scalar|Param|null, // Default: null * }, * lazy?: bool|Param, // Default: true * verbosity_levels?: array{ * VERBOSITY_QUIET?: scalar|Param|null, // Default: "ERROR" * VERBOSITY_NORMAL?: scalar|Param|null, // Default: "WARNING" * VERBOSITY_VERBOSE?: scalar|Param|null, // Default: "NOTICE" * VERBOSITY_VERY_VERBOSE?: scalar|Param|null, // Default: "INFO" * VERBOSITY_DEBUG?: scalar|Param|null, // Default: "DEBUG" * }, * channels?: string|array{ * type?: scalar|Param|null, * elements?: list, * }, * }>, * } * @psalm-type DebugConfig = array{ * max_items?: int|Param, // Max number of displayed items past the first level, -1 means no limit. // Default: 2500 * min_depth?: int|Param, // Minimum tree depth to clone all the items, 1 is default. // Default: 1 * max_string_length?: int|Param, // Max length of displayed strings, -1 means no limit. // Default: -1 * dump_destination?: scalar|Param|null, // A stream URL where dumps should be written to. // Default: null * theme?: "dark"|"light"|Param, // Changes the color of the dump() output when rendered directly on the templating. "dark" (default) or "light". // Default: "dark" * } * @psalm-type MakerConfig = array{ * root_namespace?: scalar|Param|null, // Default: "App" * generate_final_classes?: bool|Param, // Default: true * generate_final_entities?: bool|Param, // Default: false * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, * services?: ServicesConfig, * framework?: FrameworkConfig, * doctrine?: DoctrineConfig, * doctrine_migrations?: DoctrineMigrationsConfig, * security?: SecurityConfig, * twig?: TwigConfig, * web_profiler?: WebProfilerConfig, * monolog?: MonologConfig, * debug?: DebugConfig, * "when@dev"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, * services?: ServicesConfig, * framework?: FrameworkConfig, * doctrine?: DoctrineConfig, * doctrine_migrations?: DoctrineMigrationsConfig, * security?: SecurityConfig, * twig?: TwigConfig, * web_profiler?: WebProfilerConfig, * monolog?: MonologConfig, * debug?: DebugConfig, * maker?: MakerConfig, * }, * "when@test"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, * services?: ServicesConfig, * framework?: FrameworkConfig, * doctrine?: DoctrineConfig, * doctrine_migrations?: DoctrineMigrationsConfig, * security?: SecurityConfig, * twig?: TwigConfig, * web_profiler?: WebProfilerConfig, * monolog?: MonologConfig, * debug?: DebugConfig, * }, * ..., * }> * } */ final class App { /** * @param ConfigType $config * * @psalm-return ConfigType */ public static function config(array $config): array { /** @var ConfigType $config */ $config = AppReference::config($config); return $config; } } namespace Symfony\Component\Routing\Loader\Configurator; /** * This class provides array-shapes for configuring the routes of an application. * * Example: * * ```php * // config/routes.php * namespace Symfony\Component\Routing\Loader\Configurator; * * return Routes::config([ * 'controllers' => [ * 'resource' => 'routing.controllers', * ], * ]); * ``` * * @psalm-type RouteConfig = array{ * path: string|array, * controller?: string, * methods?: string|list, * requirements?: array, * defaults?: array, * options?: array, * host?: string|array, * schemes?: string|list, * condition?: string, * locale?: string, * format?: string, * utf8?: bool, * stateless?: bool, * } * @psalm-type ImportConfig = array{ * resource: string, * type?: string, * exclude?: string|list, * prefix?: string|array, * name_prefix?: string, * trailing_slash_on_root?: bool, * controller?: string, * methods?: string|list, * requirements?: array, * defaults?: array, * options?: array, * host?: string|array, * schemes?: string|list, * condition?: string, * locale?: string, * format?: string, * utf8?: bool, * stateless?: bool, * } * @psalm-type AliasConfig = array{ * alias: string, * deprecated?: array{package:string, version:string, message?:string}, * } * @psalm-type RoutesConfig = array{ * "when@dev"?: array, * "when@test"?: array, * ... * } */ final class Routes { /** * @param RoutesConfig $config * * @psalm-return RoutesConfig */ public static function config(array $config): array { return $config; } } ================================================ FILE: config/routes/attributes.yaml ================================================ controllers: resource: ../../src/Controller/ type: attribute kernel: resource: App\Kernel type: attribute ================================================ FILE: config/routes/dev/framework.yaml ================================================ _errors: resource: '@FrameworkBundle/Resources/config/routing/errors.php' prefix: /_error ================================================ FILE: config/routes/dev/web_profiler.yaml ================================================ web_profiler_wdt: resource: '@WebProfilerBundle/Resources/config/routing/wdt.php' prefix: /_wdt web_profiler_profiler: resource: '@WebProfilerBundle/Resources/config/routing/profiler.php' prefix: /_profiler ================================================ FILE: config/routes.yaml ================================================ #index: # path: / # controller: App\Controller\DefaultController::index ================================================ FILE: config/services.yaml ================================================ # This file is the entry point to configure your own services. # Files in the packages/ subdirectory configure your dependencies. # Put parameters here that don't need to change on each machine where the app is deployed # https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration parameters: default_database_driver: "mysql" default_admin_auth_bypass: "false" timezone: '%env(APP_TIMEZONE)%' public_calendars_enabled: '%env(default:default_public_calendars_enabled:bool:PUBLIC_CALENDARS_ENABLED)%' default_public_calendars_enabled: "true" birthday_reminder_offset: '%env(default:default_birthday_reminder_offset:BIRTHDAY_REMINDER_OFFSET)%' default_birthday_reminder_offset: "PT9H" caldav_enabled: "%env(bool:CALDAV_ENABLED)%" carddav_enabled: "%env(bool:CARDDAV_ENABLED)%" services: # default configuration for services in *this* file _defaults: autowire: true # Automatically injects dependencies in your services. autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name App\: resource: '../src/*' exclude: '../src/{DependencyInjection,Entity,Migrations,Tests,Kernel.php}' App\Services\Utils: arguments: $authRealm: "%env(AUTH_REALM)%" App\Services\IMAPAuth: arguments: $IMAPAuthUrl: "%env(IMAP_AUTH_URL)%" $IMAPEncryptionMethod: "%env(IMAP_ENCRYPTION_METHOD)%" $IMAPCertificateValidation: "%env(bool:IMAP_CERTIFICATE_VALIDATION)%" $autoCreate: "%env(bool:IMAP_AUTH_USER_AUTOCREATE)%" App\Services\LDAPAuth: arguments: $LDAPAuthUrl: "%env(LDAP_AUTH_URL)%" $LDAPDnPattern: "%env(LDAP_DN_PATTERN)%" $LDAPMailAttribute: "%env(LDAP_MAIL_ATTRIBUTE)%" $LDAPCertificateCheckingStrategy: "%env(LDAP_CERTIFICATE_CHECKING_STRATEGY)%" $autoCreate: "%env(bool:LDAP_AUTH_USER_AUTOCREATE)%" # controllers are imported separately to make sure services can be injected # as action arguments even if you don't extend any base controller class App\Controller\: resource: '../src/Controller' tags: ['controller.service_arguments'] App\Controller\DAVController: arguments: $publicDir: "%kernel.project_dir%/public" $calDAVEnabled: "%env(bool:CALDAV_ENABLED)%" $cardDAVEnabled: "%env(bool:CARDDAV_ENABLED)%" $webDAVEnabled: "%env(bool:WEBDAV_ENABLED)%" $publicCalendarsEnabled: "%public_calendars_enabled%" $inviteAddress: "%env(INVITE_FROM_ADDRESS)%" $authMethod: "%env(AUTH_METHOD)%" $authRealm: "%env(AUTH_REALM)%" $webdavPublicDir: "%env(resolve:WEBDAV_PUBLIC_DIR)%" $webdavHomesDir: "%env(resolve:WEBDAV_HOMES_DIR)%" $webdavTmpDir: "%env(resolve:WEBDAV_TMP_DIR)%" App\Security\LoginFormAuthenticator: arguments: $adminLogin: "%env(ADMIN_LOGIN)%" $adminPassword: "%env(ADMIN_PASSWORD)%" App\Logging\Monolog\PasswordFilterProcessor: tags: - { name: monolog.processor } App\Services\BirthdayService: arguments: $birthdayReminderOffset: "%birthday_reminder_offset%" App\Security\ApiKeyAuthenticator: arguments: $apiKey: "%env(API_KEY)%" when@dev: services: Symfony\Component\HttpKernel\Profiler\Profiler: '@profiler' when@test: services: Symfony\Component\HttpKernel\Profiler\Profiler: '@profiler' ================================================ FILE: docker/Dockerfile ================================================ # syntax=docker/dockerfile:1 # Initial PHP image is available here: # https://github.com/docker-library/php/blob/master/8.2/alpine3.18/fpm/Dockerfile#L33 ARG fpm_user=82:82 # Base image, used to build extensions and the final image ——————————————————————— FROM php:8.3-fpm-alpine AS base-image # Run update, and gets basic packages and packages for runtime RUN apk --no-progress --update add --no-cache \ curl unzip fcgi \ # These are for php-intl icu-libs \ # This one for LDAP libldap \ # This one for IMAP libzip \ # These are for GD (map image in mail) freetype \ libjpeg-turbo \ libpng \ # This is for PostgreSQL libpq # Build all extensions in a separate image ——————————————————————————————————————— FROM base-image AS extension-builder # Install ALL build dependencies at once # hadolint ignore=SC2086 RUN apk --update --virtual build-deps add --no-cache \ # Intl support icu-dev \ # PDO: PostgreSQL libpq-dev \ # GD (map image in mail) freetype-dev libjpeg-turbo-dev libpng-dev \ # LDAP auth support openldap-dev \ # Zip lib for PHP-IMAP libzip-dev \ $PHPIZE_DEPS # Build ALL extensions in one RUN command to reduce layers # and allow parallel compilation for extensions RUN docker-php-ext-configure intl \ && docker-php-ext-configure pdo_mysql --with-pdo-mysql=mysqlnd \ && docker-php-ext-configure pgsql -with-pgsql=/usr/local/pgsql \ && docker-php-ext-configure gd --with-freetype \ && docker-php-ext-configure ldap \ && docker-php-ext-configure zip \ && docker-php-ext-install -j"$(nproc)" \ intl \ pdo_mysql \ pgsql \ pdo_pgsql \ gd \ ldap \ zip \ opcache \ && docker-php-ext-enable gd opcache \ && apk del build-deps \ && rm -rf /tmp/* /var/cache/apk/* COPY ./docker/configurations/opcache.ini /usr/local/etc/php/conf.d/opcache.ini # Final image ———————————————————————————————————————————————————————————————————— FROM base-image ARG fpm_user=82:82 ENV FPM_USER=${fpm_user} ENV PHP_OPCACHE_MEMORY_CONSUMPTION="256" \ PHP_OPCACHE_MAX_WASTED_PERCENTAGE="10" LABEL org.opencontainers.image.authors="tchap@tchap.me" LABEL org.opencontainers.image.url="https://github.com/tchapi/davis/pkgs/container/davis" LABEL org.opencontainers.image.description="A simple, fully translatable admin interface for sabre/dav based on Symfony 7 and Bootstrap 5" # Rapatriate built extensions COPY --from=extension-builder /usr/local/etc/php/conf.d /usr/local/etc/php/conf.d/ COPY --from=extension-builder /usr/local/lib/php/extensions /usr/local/lib/php/extensions/ # This is for Hadolint SHELL ["/bin/ash", "-eo", "pipefail", "-c"] # PHP-FPM healthcheck RUN set -xe && echo "pm.status_path = /status" >> /usr/local/etc/php-fpm.d/zz-docker.conf RUN curl https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/v0.5.0/php-fpm-healthcheck \ -o /usr/local/bin/php-fpm-healthcheck -s \ && chmod +x /usr/local/bin/php-fpm-healthcheck # Davis installation COPY --chown=${FPM_USER} . /var/www/davis WORKDIR /var/www/davis # Install Composer 2, then dependencies, compress the rather big INTL package, and then cleanup (only useful when using --squash) RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer RUN APP_ENV=prod COMPOSER_ALLOW_SUPERUSER=1 composer install --no-ansi --no-dev --no-interaction --no-progress --prefer-dist --optimize-autoloader \ && php ./vendor/symfony/intl/Resources/bin/compress \ && rm -rf /var/www/davis/docker USER $FPM_USER HEALTHCHECK --interval=30s --timeout=1s CMD php-fpm-healthcheck || exit 1 ================================================ FILE: docker/Dockerfile-standalone ================================================ # Initial PHP image is available here: # https://github.com/docker-library/php/blob/master/8.2/alpine3.18/fpm/Dockerfile#L33 # Base image, used to build extensions and the final image ——————————————————————— FROM php:8.3-fpm-alpine AS base-image # Run update, and gets basic packages and packages for runtime RUN apk --no-progress --update add --no-cache \ curl unzip \ # These are for php-intl icu-libs \ # This one for LDAP libldap \ # This one for IMAP libzip \ # These are for GD (map image in mail) freetype \ libjpeg-turbo \ libpng \ # This is for PostgreSQL libpq \ # For the webserver and process manager caddy supervisor # Build all extensions in a separate image ——————————————————————————————————————— FROM base-image AS extension-builder # Install ALL build dependencies at once # hadolint ignore=SC2086 RUN apk --update --virtual build-deps add --no-cache \ # Intl support icu-dev \ # PDO: PostgreSQL libpq-dev \ # GD (map image in mail) freetype-dev libjpeg-turbo-dev libpng-dev \ # LDAP auth support openldap-dev \ # Zip lib for PHP-IMAP libzip-dev \ $PHPIZE_DEPS # Build ALL extensions in one RUN command to reduce layers # and allow parallel compilation for extensions RUN docker-php-ext-configure intl \ && docker-php-ext-configure pdo_mysql --with-pdo-mysql=mysqlnd \ && docker-php-ext-configure pgsql -with-pgsql=/usr/local/pgsql \ && docker-php-ext-configure gd --with-freetype \ && docker-php-ext-configure ldap \ && docker-php-ext-configure zip \ && docker-php-ext-install -j"$(nproc)" \ intl \ pdo_mysql \ pgsql \ pdo_pgsql \ gd \ ldap \ zip \ opcache \ && docker-php-ext-enable gd opcache \ && apk del build-deps \ && rm -rf /tmp/* /var/cache/apk/* COPY ./docker/configurations/opcache.ini /usr/local/etc/php/conf.d/opcache.ini # Final image ———————————————————————————————————————————————————————————————————— FROM base-image ENV PHP_OPCACHE_MEMORY_CONSUMPTION="256" \ PHP_OPCACHE_MAX_WASTED_PERCENTAGE="10" LABEL org.opencontainers.image.authors="tchap@tchap.me" LABEL org.opencontainers.image.url="https://github.com/tchapi/davis/pkgs/container/davis-standalone" LABEL org.opencontainers.image.description="A simple, fully translatable admin interface for sabre/dav based on Symfony 7 and Bootstrap 5 (Standalone version with reverse-proxy)" # Rapatriate built extensions COPY --from=extension-builder /usr/local/etc/php/conf.d /usr/local/etc/php/conf.d/ COPY --from=extension-builder /usr/local/lib/php/extensions /usr/local/lib/php/extensions/ # Davis source # The app folder needs to be owned by www-data so PHP-fpm can execute files COPY --chown=www-data:www-data . /var/www/davis WORKDIR /var/www/davis # This is for Hadolint SHELL ["/bin/ash", "-eo", "pipefail", "-c"] # Install Composer 2, then dependencies, compress the rather big INTL package RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer RUN APP_ENV=prod COMPOSER_ALLOW_SUPERUSER=1 composer install --no-ansi --no-dev --no-interaction --no-progress --prefer-dist --optimize-autoloader \ && php ./vendor/symfony/intl/Resources/bin/compress # Caddy: web server RUN mkdir -p /var/log/caddy COPY ./docker/configurations/Caddyfile /etc/caddy/Caddyfile # Supervisor: Process manager RUN mkdir -p /var/log/supervisor && mkdir -p /var/log/php-fpm COPY ./docker/configurations/supervisord.conf /etc/supervisord.conf # We want to use sockets inside the container between Caddy and PHP-fpm # NOTE: Creating a custom zzzz-custom.conf overrides the www.conf setting (files are processed alphabetically) RUN mkdir /var/run/php-fpm && chown -R www-data:www-data /var/run/php-fpm \ && { \ echo '[www]'; \ echo 'listen = /var/run/php-fpm/php-fpm.sock'; \ } | tee /usr/local/etc/php-fpm.d/zzzz-custom-docker.conf RUN mkdir -p ./var/log ./var/cache && chown -R www-data:www-data ./var # Cleanup (only useful when using --squash) RUN docker-php-source delete && \ rm -rf /var/www/davis/docker CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"] HEALTHCHECK --interval=120s --timeout=10s --start-period=60s --retries=3 \ CMD curl --fail http://localhost:9000 || exit 1 # It's the Caddy port, not the PHP-fpm one (as we use sockets) EXPOSE 9000 ================================================ FILE: docker/configurations/Caddyfile ================================================ { auto_https off } :9000 { # Redirect .well-known redir /.well-known/caldav /dav/ redir /.well-known/carddav /dav/ root * /var/www/davis/public php_fastcgi unix//var/run/php-fpm/php-fpm.sock { # Preserve the original X-Forwarded-Proto from upstream, as it might be HTTPS header_up X-Forwarded-Proto {http.request.header.X-Forwarded-Proto} header_up X-Forwarded-Host {http.request.header.X-Forwarded-Host} header_up X-Forwarded-For {http.request.header.X-Forwarded-For} } file_server { # Safety net, just in case hide .git .gitignore } # enable compression encode zstd gzip # Remove leaky headers header { -Server -X-Powered-By # keep referrer data off of HTTP connections Referrer-Policy no-referrer-when-downgrade # disable clients from sniffing the media type X-Content-Type-Options nosniff } } ================================================ FILE: docker/configurations/nginx.conf ================================================ # This is a very simple / naive configuration for nginx + Davis # # USE HTTPS IN PRODUCTION # upstream docker-davis { server davis:9000; } server { listen 80; access_log off; root /var/www/davis/public/; index index.php; rewrite ^/.well-known/caldav /dav/ redirect; rewrite ^/.well-known/carddav /dav/ redirect; charset utf-8; location ~ /(\.ht) { deny all; return 404; } location / { try_files $uri $uri/ /index.php$is_args$args; } location ~ ^(.+\.php)(.*)$ { try_files $fastcgi_script_name =404; include fastcgi_params; fastcgi_pass docker-davis; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param PATH_INFO $fastcgi_path_info; fastcgi_split_path_info ^(.+\.php)(.*)$; } } ================================================ FILE: docker/configurations/opcache.ini ================================================ opcache.enable=1 opcache.jit=1255 opcache.jit_buffer_size=128M opcache.revalidate_freq=60 opcache.save_comments=1 opcache.validate_timestamps=1 opcache.max_accelerated_files=32531 opcache.memory_consumption=${PHP_OPCACHE_MEMORY_CONSUMPTION} opcache.max_wasted_percentage=${PHP_OPCACHE_MAX_WASTED_PERCENTAGE} opcache.interned_strings_buffer=64 ================================================ FILE: docker/configurations/supervisord.conf ================================================ [supervisord] nodaemon=true user=root pidfile=/run/supervisord.pid logfile=/dev/null logfile_maxbytes=0 [unix_http_server] file=/run/supervisord.sock ; the path to the socket file [supervisorctl] serverurl=unix:///run/supervisord.sock ; use a unix:// URL for a unix socket [rpcinterface:supervisor] supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface [program:caddy] command=/usr/sbin/caddy run -c /etc/caddy/Caddyfile autostart=true autorestart=true redirect_stderr=true stdout_logfile=/var/log/caddy/access.log stdout_logfile_maxbytes = 0 [program:php-fpm] command=/usr/local/sbin/php-fpm --nodaemonize autostart=true autorestart=true redirect_stderr=true stdout_logfile=/var/log/php-fpm/access.log stdout_logfile_maxbytes = 0 ================================================ FILE: docker/docker-compose-postgresql.yml ================================================ version: "3.7" name: "davis-docker" services: nginx: image: nginx:1.25-alpine container_name: nginx command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" depends_on: - davis volumes: - davis_www:/var/www/davis - type: bind source: ./configurations/nginx.conf target: /etc/nginx/conf.d/default.conf ports: - 9000:80 postgresql: image: postgres:16-alpine container_name: postgresql environment: - POSTGRES_PASSWORD=${DB_PASSWORD} - POSTGRES_DB=${DB_DATABASE} - POSTGRES_USER=${DB_USER} volumes: - database_pg:/var/lib/postgresql/data davis: build: context: ../ dockerfile: ./docker/Dockerfile image: davis:latest # If you want to use a prebuilt image from Github # image: ghcr.io/tchapi/davis:edge container_name: davis env_file: .env environment: - DATABASE_DRIVER=postgresql - DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@postgresql:5432/${DB_DATABASE}?serverVersion=15&charset=UTF-8 - MAILER_DSN=smtp://${MAIL_USERNAME}:${MAIL_PASSWORD}@${MAIL_HOST}:${MAIL_PORT} depends_on: - postgresql volumes: - davis_www:/var/www/davis volumes: davis_www: name: davis_www database_pg: name: database_pg ================================================ FILE: docker/docker-compose-sqlite.yml ================================================ version: "3.7" name: "davis-docker" services: nginx: image: nginx:1.25-alpine container_name: nginx command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" depends_on: - davis volumes: - davis_www:/var/www/davis - type: bind source: ./configurations/nginx.conf target: /etc/nginx/conf.d/default.conf ports: - 9000:80 davis: build: context: ../ dockerfile: ./docker/Dockerfile image: davis:latest # If you want to use a prebuilt image from Github # image: ghcr.io/tchapi/davis:edge container_name: davis env_file: .env environment: - DATABASE_DRIVER=sqlite - DATABASE_URL=sqlite:////data/davis-database.db # ⚠️ 4 slashes for an absolute path ⚠️ + no quotes (so Symfony can resolve it) - MAILER_DSN=smtp://${MAIL_USERNAME}:${MAIL_PASSWORD}@${MAIL_HOST}:${MAIL_PORT} volumes: - davis_www:/var/www/davis - davis_data:/data volumes: davis_www: name: davis_www davis_data: name: davis_data ================================================ FILE: docker/docker-compose-standalone.yml ================================================ version: "3.7" name: "davis-docker" services: mysql: image: mariadb:10.6.10 container_name: mysql environment: - MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD} - MYSQL_DATABASE=${DB_DATABASE} - MYSQL_USER=${DB_USER} - MYSQL_PASSWORD=${DB_PASSWORD} volumes: - database:/var/lib/mysql davis: build: context: ../ dockerfile: ./docker/Dockerfile-standalone image: davis:latest # If you want to use a prebuilt image from Github # image: ghcr.io/tchapi/davis-standalone:edge container_name: davis-standalone env_file: .env environment: - DATABASE_DRIVER=mysql - DATABASE_URL=mysql://${DB_USER}:${DB_PASSWORD}@mysql:3306/${DB_DATABASE}?serverVersion=mariadb-10.6.10&charset=utf8mb4 - MAILER_DSN=smtp://${MAIL_USERNAME}:${MAIL_PASSWORD}@${MAIL_HOST}:${MAIL_PORT} depends_on: - mysql ports: - 9000:9000 volumes: database: name: database ================================================ FILE: docker/docker-compose.yml ================================================ version: "3.7" name: "davis-docker" services: nginx: image: nginx:1.25-alpine container_name: nginx command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" depends_on: - davis volumes: - davis_www:/var/www/davis - type: bind source: ./configurations/nginx.conf target: /etc/nginx/conf.d/default.conf ports: - 9000:80 mysql: image: mariadb:10.11 container_name: mysql environment: - MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD} - MYSQL_DATABASE=${DB_DATABASE} - MYSQL_USER=${DB_USER} - MYSQL_PASSWORD=${DB_PASSWORD} volumes: - database:/var/lib/mysql davis: build: context: ../ dockerfile: ./docker/Dockerfile args: fpm_user: 101:101 image: davis:latest # If you want to use a prebuilt image from Github # image: ghcr.io/tchapi/davis:edge container_name: davis env_file: .env environment: - DATABASE_DRIVER=mysql - DATABASE_URL=mysql://${DB_USER}:${DB_PASSWORD}@mysql:3306/${DB_DATABASE}?serverVersion=mariadb-10.6.10&charset=utf8mb4 - MAILER_DSN=smtp://${MAIL_USERNAME}:${MAIL_PASSWORD}@${MAIL_HOST}:${MAIL_PORT} depends_on: - mysql volumes: - davis_www:/var/www/davis volumes: davis_www: name: davis_www database: name: database ================================================ FILE: docs/api/README.md ================================================ # Davis API ## API Version 1 ### Open Endpoints Open endpoints require no Authentication. * [Health](v1/health.md) : `GET /api/v1/health` ### Endpoints that require Authentication Closed endpoints require a valid `X-Davis-API-Token` to be included in the header of the request. Token needs to be configured in .env file (as a environment variable `API_KEY`) and can be generated using `php bin/console api:generate` command. When `API_KEY` is not set, the API endpoints are disabled and will return a 500 error if accessed. #### User related Each endpoint displays information related to the User: * [Get Users](v1/users/all.md) : `GET /api/v1/users` * [Get User Details](v1/users/details.md) : `GET /api/v1/users/:user_id` #### Calendars related Endpoints for viewing and modifying user calendars. * [Show All User Calendars](v1/calendars/all.md) : `GET /api/v1/calendars/:user_id` * [Show User Calendar Details](v1/calendars/details.md) : `GET /api/v1/calendars/:user_id/:calendar_id` * [Create User Calendar](v1/calendars/create.md) : `POST /api/v1/calendars/:user_id/create` * [Edit User Calendar](v1/calendars/edit.md) : `PUT /api/v1/calendars/:user_id/:calendar_id` * [Delete User Calendar](v1/calendars/delete.md) : `DELETE /api/v1/calendars/:user_id/:calendar_id` * [Show User Calendar Shares](v1/calendars/shares.md) : `GET /api/v1/calendars/:user_id/shares/:calendar_id` * [Share User Calendar](v1/calendars/share_add.md) : `POST /api/v1/calendars/:user_id/share/:calendar_id/add` * [Remove Share User Calendar](v1/calendars/share_remove.md) : `POST /api/v1/calendars/:user_id/share/:calendar_id/remove` ================================================ FILE: docs/api/v1/calendars/all.md ================================================ # User Calendars Gets a list of all available calendars for a specific user. **URL** : `/api/v1/calendars/:user_id` **Method** : `GET` **Auth required** : YES **Params constraints** ``` :user_id -> "[user id as an int]", ``` **URL example** ```json /api/v1/calendars/jdoe ``` ## Success Response **Code** : `200 OK` **Notes**: The `events`, `notes`, and `tasks` fields return a count (number) if the component is enabled for the calendar, or `null` if the component is disabled. **Content examples** ```json { "status": "success", "data": { "user_calendars": [ { "id": 1, "uri": "default", "displayname": "Default Calendar", "events": 0, "notes": null, "tasks": null } ], "shared_calendars": [ { "id": 10, "uri": "c2152eb0-ada1-451f-bf33-b4a9571ec92e", "displayname": "Default Calendar", "events": 0, "notes": null, "tasks": null } ], "subscriptions": [] }, "timestamp": "2026-01-23T15:01:33+01:00" } ``` Shown when user does not have calendars: ```json { "status": "success", "data": { "user_calendars": [], "shared_calendars": [], "subscriptions": [] }, "timestamp": "2026-01-23T15:01:33+01:00" } ``` ## Error Response **Condition** : If 'X-Davis-API-Token' is not present or mismatched in headers. **Code** : `401 UNAUTHORIZED` **Content** : ```json { "message": "No API token provided", "timestamp": "2026-01-23T15:01:33+01:00" } ``` or ```json { "message": "Invalid API token", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If user is not found. **Code** : `404 NOT FOUND` **Content** : ```json { "status": "error", "message": "User Not Found", "timestamp": "2026-01-23T15:01:33+01:00" } ``` ================================================ FILE: docs/api/v1/calendars/create.md ================================================ # Create User Calendar Creates a new calendar for a specific user. **URL** : `/api/v1/calendars/:user_id/create` **Method** : `POST` **Auth required** : YES **Params constraints** ``` :user_id -> "[user id as an int]", ``` **Request Body constraints** ```json { "name": "[string: calendar name, alphanumeric, spaces, underscores and hyphens, max 64 chars]", "uri": "[string: calendar URI, lowercase alphanumeric, underscores and hyphens, max 128 chars]", "description": "[string: calendar description, alphanumeric, spaces, underscores and hyphens, max 256 chars, optional]", "events_support": "[string: 'true' or 'false', default 'true', optional]", "notes_support": "[string: 'true' or 'false', default 'false', optional]", "tasks_support": "[string: 'true' or 'false', default 'false', optional]" } ``` **URL example** ``` /api/v1/calendars/jdoe/create ``` **Body example** ```json { "name": "Work Calendar", "uri": "work-calendar", "description": "Calendar for work events", "events_support": "true", "notes_support": "false", "tasks_support": "true" } ``` ## Success Response **Code** : `200 OK` **Content examples** ```json { "status": "success", "data": { "calendar_id": 5, "calendar_uri": "work-calendar" }, "timestamp": "2026-01-23T15:01:33+01:00" } ``` ## Error Response **Condition** : If 'X-Davis-API-Token' is not present or mismatched in headers. **Code** : `401 UNAUTHORIZED` **Content** : ```json { "message": "No API token provided", "timestamp": "2026-01-23T15:01:33+01:00" } ``` or ```json { "message": "Invalid API token", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If user is not found. **Code** : `404 NOT FOUND` **Content** : ```json { "status": "error", "message": "User Not Found", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If request body contains invalid JSON. **Code** : `400 BAD REQUEST` **Content** : ```json { "status": "error", "message": "Invalid JSON", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If 'name' parameter is invalid (not matching the regex or exceeds length). **Code** : `400 BAD REQUEST` **Content** : ```json { "status": "error", "message": "Invalid Calendar Name", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If 'uri' parameter is invalid (not matching the regex or exceeds length). **Code** : `400 BAD REQUEST` **Content** : ```json { "status": "error", "message": "Invalid Calendar URI", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If calendar with specified URI already exists for the user. **Code** : `400 BAD REQUEST` **Content** : ```json { "status": "error", "message": "Calendar URI Already Exists", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If 'description' parameter is invalid (not matching the regex or exceeds length). **Code** : `400 BAD REQUEST` **Content** : ```json { "status": "error", "message": "Invalid Calendar Description", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If no calendar components are enabled (all of `events_support`, `notes_support`, and `tasks_support` are false). **Code** : `400 BAD REQUEST` **Content** : ```json { "status": "error", "message": "At least one calendar component must be enabled (events, notes, or tasks)", "timestamp": "2026-01-23T15:01:33+01:00" } ``` ================================================ FILE: docs/api/v1/calendars/delete.md ================================================ # Delete User Calendar Deletes a specific calendar for a specific user. **URL** : `/api/v1/calendars/:user_id/:calendar_id` **Method** : `DELETE` **Auth required** : YES **Params constraints** ``` :user_id -> "[user id as an int]", :calendar_id -> "[numeric id of a calendar owned by the user]", ``` **URL example** ``` /api/v1/calendars/jdoe/1 ``` ## Success Response **Code** : `200 OK` **Content examples** ```json { "status": "success", "timestamp": "2026-01-23T15:01:33+01:00" } ``` ## Error Response **Condition** : If 'X-Davis-API-Token' is not present or mismatched in headers. **Code** : `401 UNAUTHORIZED` **Content** : ```json { "message": "No API token provided", "timestamp": "2026-01-23T15:01:33+01:00" } ``` or ```json { "message": "Invalid API token", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If user is not found. **Code** : `404 NOT FOUND` **Content** : ```json { "status": "error", "message": "User Not Found", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If ':calendar_id' is not owned by the specified ':username' or calendar instance is not found. **Code** : `400 BAD REQUEST` **Content** : ```json { "status": "error", "message": "Invalid Instance Not Found", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If an error occurs while deleting the calendar. **Code** : `500 INTERNAL SERVER ERROR` **Content** : ```json { "status": "error", "message": "Failed to Delete Calendar", "timestamp": "2026-01-23T15:01:33+01:00" } ``` ================================================ FILE: docs/api/v1/calendars/details.md ================================================ # User Calendar Details Gets a list of all available calendars for a specific user. **URL** : `/api/v1/calendars/:user_id/:calendar_id` **Method** : `GET` **Auth required** : YES **Params constraints** ``` :user_id -> "[user id as an int]", :calendar_id -> "[numeric id of a calendar owned by the user]", ``` **URL example** ```json /api/v1/calendars/jdoe/1 ``` ## Success Response **Code** : `200 OK` **Content examples** ```json { "status": "success", "data": { "id": 1, "uri": "default", "displayname": "Default Calendar", "description": "Default Calendar for Joe Doe", "events": { "enabled": true, "count": 0 }, "notes": { "enabled": false, "count": 0 }, "tasks": { "enabled": false, "count": 0 } }, "timestamp": "2026-01-23T15:01:33+01:00" } ``` Shown when user has no calendars with the given id: ```json { "status": "success", "data": {}, "timestamp": "2026-01-23T15:01:33+01:00" } ``` ## Error Response **Condition** : If 'X-Davis-API-Token' is not present or mismatched in headers. **Code** : `401 UNAUTHORIZED` **Content** : ```json { "message": "No API token provided", "timestamp": "2026-01-23T15:01:33+01:00" } ``` or ```json { "message": "Invalid API token", "timestamp": "2026-01-23T15:01:33+01:00" } ``` ================================================ FILE: docs/api/v1/calendars/edit.md ================================================ # Edit User Calendar Edits an existing calendar for a specific user. **URL** : `/api/v1/calendars/:user_id/:calendar_id` **Method** : `PUT` or `PATCH` **Auth required** : YES **Params constraints** ``` :user_id -> "[user id as an int]", :calendar_id -> "[numeric id of a calendar owned by the user]", ``` **Request Body constraints** ```json { "name": "[string: calendar name, alphanumeric, spaces, underscores and hyphens, max 64 chars]", "description": "[string: calendar description, alphanumeric, spaces, underscores and hyphens, max 256 chars, optional]", "events_support": "[string: 'true' or 'false', default 'true', optional]", "notes_support": "[string: 'true' or 'false', default 'false', optional]", "tasks_support": "[string: 'true' or 'false', default 'false', optional]" } ``` **URL example** ``` /api/v1/calendars/jdoe/1 ``` **Body example** ```json { "name": "Updated Work Calendar", "description": "Updated calendar for work events", "events_support": "true", "notes_support": "true", "tasks_support": "false" } ``` ## Success Response **Code** : `200 OK` **Content examples** ```json { "status": "success", "timestamp": "2026-01-23T15:01:33+01:00" } ``` ## Error Response **Condition** : If 'X-Davis-API-Token' is not present or mismatched in headers. **Code** : `401 UNAUTHORIZED` **Content** : ```json { "message": "No API token provided", "timestamp": "2026-01-23T15:01:33+01:00" } ``` or ```json { "message": "Invalid API token", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If user is not found. **Code** : `404 NOT FOUND` **Content** : ```json { "status": "error", "message": "User Not Found", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If request body contains invalid JSON. **Code** : `400 BAD REQUEST` **Content** : ```json { "status": "error", "message": "Invalid JSON", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If ':calendar_id' is not owned by the specified ':username'. **Code** : `400 BAD REQUEST` **Content** : ```json { "status": "error", "message": "Invalid Calendar ID", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If calendar instance is not found. **Code** : `404 NOT FOUND` **Content** : ```json { "status": "error", "message": "Calendar Instance Not Found", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If 'name' parameter is invalid (not matching the regex or exceeds length). **Code** : `400 BAD REQUEST` **Content** : ```json { "status": "error", "message": "Invalid Calendar Name", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If 'description' parameter is invalid (not matching the regex or exceeds length). **Code** : `400 BAD REQUEST` **Content** : ```json { "status": "error", "message": "Invalid Calendar Description", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If no calendar components are enabled (all of `events_support`, `notes_support`, and `tasks_support` are false). **Code** : `400 BAD REQUEST` **Content** : ```json { "status": "error", "message": "At least one calendar component must be enabled (events, notes, or tasks)", "timestamp": "2026-01-23T15:01:33+01:00" } ``` ================================================ FILE: docs/api/v1/calendars/share_add.md ================================================ # Share User Calendar Shares (or updates write access) a calendar owned by the specified user to another user. **URL** : `/api/v1/calendars/:user_id/share/:calendar_id/add` **Method** : `POST` **Auth required** : YES **Params constraints** ``` :user_id -> "[user id as an int]", :calendar_id -> "[numeric id of a calendar owned by the user]", ``` ** Request Body constraints** ```json { "username": "[username of the user to add/update access]", "write_access": "[boolean: true to grant write access, false for read-only]" } ``` **URL example** ```json /api/v1/calendars/mdoe/share/1/add ``` **Body example** ```json { "username": "jdoe", "write_access": true } ``` ## Success Response **Code** : `200 OK` **Content examples** ```json { "status": "success", "timestamp": "2026-01-23T15:01:33+01:00" } ``` ## Error Response **Condition** : If 'X-Davis-API-Token' is not present or mismatched in headers. **Code** : `401 UNAUTHORIZED` **Content** : ```json { "message": "No API token provided", "timestamp": "2026-01-23T15:01:33+01:00" } ``` or ```json { "message": "Invalid API token", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If user is not found. **Code** : `404 NOT FOUND` **Content** : ```json { "status": "error", "message": "User Not Found", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If request body contains invalid JSON. **Code** : `400 BAD REQUEST` **Content** : ```json { "status": "error", "message": "Invalid JSON", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If ':calendar_id' is not owned by the specified ':username'. **Code** : `400 BAD REQUEST` **Content** : ```json { "status": "error", "message": "Invalid Calendar ID and User ID", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If 'username' is not valid or 'write_access' is not 'true' or 'false'. **Code** : `400 BAD REQUEST` **Content** : ```json { "status": "error", "message": "Invalid Sharee ID/Write Access Value", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If calendar instance or user to share with is not found. **Code** : `404 NOT FOUND` **Content** : ```json { "status": "error", "message": "Calendar Instance/User Not Found", "timestamp": "2026-01-23T15:01:33+01:00" } ``` ================================================ FILE: docs/api/v1/calendars/share_remove.md ================================================ # Remove Share User Calendar Removes access to a specific shared calendar for a specific user. **URL** : `/api/v1/calendars/:user_id/share/:calendar_id/remove` **Method** : `POST` **Auth required** : YES **Params constraints** ``` :user_id -> "[user id as an int]", :calendar_id -> "[numeric id of a calendar owned by the user]", ``` ** Request Body Constraints** ```json { "username": "[username of the user to remove access]" } ``` **URL example** ```json /api/v1/calendars/mdoe/share/1/remove ``` **Body example** ```json { "username": "jdoe" } ``` ## Success Response **Code** : `200 OK` **Content examples** ```json { "status": "success", "timestamp": "2026-01-23T15:01:33+01:00" } ``` ## Error Response **Condition** : If 'X-Davis-API-Token' is not present or mismatched in headers. **Code** : `401 UNAUTHORIZED` **Content** : ```json { "message": "No API token provided", "timestamp": "2026-01-23T15:01:33+01:00" } ``` or ```json { "message": "Invalid API token", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If user is not found. **Code** : `404 NOT FOUND` **Content** : ```json { "status": "error", "message": "User Not Found", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If request body contains invalid JSON. **Code** : `400 BAD REQUEST` **Content** : ```json { "status": "error", "message": "Invalid JSON", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If ':calendar_id' is not owned by the specified ':username'. **Code** : `400 BAD REQUEST` **Content** : ```json { "status": "error", "message": "Invalid Calendar ID", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If 'username' is not valid. **Code** : `400 BAD REQUEST` **Content** : ```json { "status": "error", "message": "Invalid Username", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If calendar instance or user to remove is not found. **Code** : `404 NOT FOUND` **Content** : ```json { "status": "error", "message": "Calendar Instance/User Not Found", "timestamp": "2026-01-23T15:01:33+01:00" } ``` ================================================ FILE: docs/api/v1/calendars/shares.md ================================================ # User Calendar Shares Gets a list of all users with whom a specific user calendar is shared. **URL** : `/api/v1/calendars/:user_id/shares/:calendar_id` **Method** : `GET` **Auth required** : YES **Params constraints** ``` :user_id -> "[user id as an int]", :calendar_id -> "[numeric id of a calendar owned by the user]", ``` **URL example** ```json /api/v1/calendars/mdoe/shares/1 ``` **Important Note** : The `:calendar_id` must be a calendar instance owned by the user. The endpoint retrieves shares of the underlying Calendar entity, ensuring shares are found correctly regardless of the instance reference. ## Success Response **Code** : `200 OK` **Content examples** ```json { "status": "success", "data": [ { "username": "adoe", "user_id": 5, "principal_id": 9, "displayname": "Aiden Doe", "email": "adoe@example.org", "write_access": false }, { "username": "jdoe", "user_id": 4, "principal_id": 3, "displayname": "John Doe", "email": "jdoe@example.org", "write_access": true } ] } ``` ## Error Response **Condition** : If 'X-Davis-API-Token' is not present or mismatched in headers. **Code** : `401 UNAUTHORIZED` **Content** : ```json { "message": "No API token provided", "timestamp": "2026-01-23T15:01:33+01:00" } ``` or ```json { "message": "Invalid API token", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If user is not found. **Code** : `404 NOT FOUND` **Content** : ```json { "status": "error", "message": "User Not Found", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If ':calendar_id' and ':username' combination is invalid. **Code** : `400 BAD REQUEST` **Content** : ```json { "status": "error", "message": "Invalid Calendar ID/Username", "timestamp": "2026-01-23T15:01:33+01:00" } ``` ================================================ FILE: docs/api/v1/health.md ================================================ # Health Used to check if the API endpoint is active. **URL** : `/api/v1/health` **Method** : `GET` **Auth required** : NO ## Success Response **Code** : `200 OK` **Content example** ```json { "status": "OK", "timestamp": "2026-01-23T15:01:33+01:00" } ``` ================================================ FILE: docs/api/v1/users/all.md ================================================ # Get Users Gets a list of all available users. **URL** : `/api/v1/users` **Method** : `GET` **Auth required** : YES ## Success Response **Code** : `200 OK` **Content examples** ```json { "status": "success", "data": [ { "user_id": 1, "principal_id": 3, "uri": "principals/jdoe", "username": "jdoe" } ], "timestamp": "2026-01-23T15:01:33+01:00" } ``` Shown when there are no users in Davis: ```json { "status": "success", "data": [], "timestamp": "2026-01-23T15:01:33+01:00" } ``` ## Error Response **Condition** : If 'X-Davis-API-Token' is not present or mismatched in headers. **Code** : `401 UNAUTHORIZED` **Content** : ```json { "message": "No API token provided", "timestamp": "2026-01-23T15:01:33+01:00" } ``` or ```json { "message": "Invalid API token", "timestamp": "2026-01-23T15:01:33+01:00" } ``` ================================================ FILE: docs/api/v1/users/details.md ================================================ # User Details Gets details about a specific user account. **URL** : `/api/v1/users/:user_id` **Method** : `GET` **Auth required** : YES **Params constraints** ``` :user_id -> "[user id as an int]", ``` **URL example** ```json /api/v1/users/jdoe ``` ## Success Response **Code** : `200 OK` **Content examples** ```json { "status": "success", "data": { "user_id": 1, "principal_id": 3, "uri": "principals/jdoe", "username": "jdoe", "displayname": "John Doe", "email": "jdoe@example.org" }, "timestamp": "2026-01-23T15:01:33+01:00" } ``` ## Error Response **Condition** : If 'X-Davis-API-Token' is not present or mismatched in headers. **Code** : `401 UNAUTHORIZED` **Content** : ```json { "message": "No API token provided", "timestamp": "2026-01-23T15:01:33+01:00" } ``` or ```json { "message": "Invalid API token", "timestamp": "2026-01-23T15:01:33+01:00" } ``` **Condition** : If user is not found. **Code** : `404 NOT FOUND` **Content** : ```json { "status": "error", "message": "User Not Found", "timestamp": "2026-01-23T15:01:33+01:00" } ================================================ FILE: migrations/Version20191030113307.php ================================================ skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); $this->addSql('CREATE TABLE cards (id INT AUTO_INCREMENT NOT NULL, addressbookid INT NOT NULL, carddata LONGBLOB DEFAULT NULL, uri VARBINARY(255) DEFAULT NULL, lastmodified INT DEFAULT NULL, etag VARBINARY(32) DEFAULT NULL, size INT NOT NULL, INDEX IDX_4C258FD8B26C2E9 (addressbookid), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); $this->addSql('CREATE TABLE principals (id INT AUTO_INCREMENT NOT NULL, uri VARBINARY(255) NOT NULL, email VARBINARY(255) DEFAULT NULL, displayname VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); $this->addSql('CREATE TABLE locks (id INT AUTO_INCREMENT NOT NULL, owner VARCHAR(255) DEFAULT NULL, timeout INT DEFAULT NULL, created INT DEFAULT NULL, token VARBINARY(255) DEFAULT NULL, scope SMALLINT DEFAULT NULL, depth SMALLINT DEFAULT NULL, uri VARBINARY(255) DEFAULT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); $this->addSql('CREATE TABLE propertystorage (id INT AUTO_INCREMENT NOT NULL, path VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, valuetype INT DEFAULT NULL, value LONGTEXT DEFAULT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); $this->addSql('CREATE TABLE users (id INT AUTO_INCREMENT NOT NULL, username VARBINARY(255) NOT NULL, digesta1 VARBINARY(255) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); $this->addSql('CREATE TABLE calendarobjects (id INT AUTO_INCREMENT NOT NULL, calendarid INT NOT NULL, calendardata LONGBLOB DEFAULT NULL, uri VARBINARY(255) DEFAULT NULL, lastmodified INT DEFAULT NULL, etag VARBINARY(255) DEFAULT NULL, size INT NOT NULL, componenttype VARBINARY(255) DEFAULT NULL, firstoccurence INT DEFAULT NULL, lastoccurence INT DEFAULT NULL, uid VARBINARY(255) DEFAULT NULL, INDEX IDX_E14F332CB8CB7204 (calendarid), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); $this->addSql('CREATE TABLE addressbooks (id INT AUTO_INCREMENT NOT NULL, principaluri VARBINARY(255) NOT NULL, displayname VARCHAR(255) NOT NULL, uri VARBINARY(255) NOT NULL, description LONGTEXT NOT NULL, synctoken VARCHAR(255) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); $this->addSql('CREATE TABLE calendarsubscriptions (id INT AUTO_INCREMENT NOT NULL, uri VARBINARY(255) NOT NULL, principaluri VARBINARY(255) NOT NULL, source LONGTEXT DEFAULT NULL, displayname VARCHAR(255) DEFAULT NULL, refreshrate VARCHAR(10) DEFAULT NULL, calendarorder INT NOT NULL, calendarcolor VARBINARY(10) DEFAULT NULL, striptodos SMALLINT DEFAULT NULL, stripalarms SMALLINT DEFAULT NULL, stripattachments SMALLINT DEFAULT NULL, lastmodified INT DEFAULT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); $this->addSql('CREATE TABLE schedulingobjects (id INT AUTO_INCREMENT NOT NULL, principaluri VARBINARY(255) DEFAULT NULL, calendardata LONGBLOB DEFAULT NULL, uri VARBINARY(255) DEFAULT NULL, lastmodified INT DEFAULT NULL, etag VARBINARY(255) DEFAULT NULL, size INT NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); $this->addSql('CREATE TABLE calendarinstances (id INT AUTO_INCREMENT NOT NULL, calendarid INT NOT NULL, principaluri VARBINARY(255) DEFAULT NULL, access SMALLINT NOT NULL, displayname VARCHAR(255) DEFAULT NULL, uri VARBINARY(255) DEFAULT NULL, description LONGTEXT DEFAULT NULL, calendarorder INT NOT NULL, calendarcolor VARBINARY(10) DEFAULT NULL, timezone VARCHAR(255) DEFAULT NULL, transparent INT DEFAULT NULL, share_href VARBINARY(255) DEFAULT NULL, share_displayname VARCHAR(255) DEFAULT NULL, share_invitestatus INT NOT NULL, INDEX IDX_51856561B8CB7204 (calendarid), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); $this->addSql('CREATE TABLE calendars (id INT AUTO_INCREMENT NOT NULL, synctoken VARCHAR(255) NOT NULL, components VARBINARY(255) DEFAULT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); $this->addSql('CREATE TABLE calendarchanges (id INT AUTO_INCREMENT NOT NULL, calendarid INT NOT NULL, uri VARBINARY(255) NOT NULL, synctoken INT NOT NULL, operation SMALLINT NOT NULL, INDEX IDX_737547E2B8CB7204 (calendarid), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); $this->addSql('CREATE TABLE addressbookchanges (id INT AUTO_INCREMENT NOT NULL, addressbookid INT NOT NULL, uri VARBINARY(255) NOT NULL, synctoken VARCHAR(255) NOT NULL, operation INT NOT NULL, INDEX IDX_EB122CD58B26C2E9 (addressbookid), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); $this->addSql('ALTER TABLE cards ADD CONSTRAINT FK_4C258FD8B26C2E9 FOREIGN KEY (addressbookid) REFERENCES addressbooks (id)'); $this->addSql('ALTER TABLE calendarobjects ADD CONSTRAINT FK_E14F332CB8CB7204 FOREIGN KEY (calendarid) REFERENCES calendars (id)'); $this->addSql('ALTER TABLE calendarinstances ADD CONSTRAINT FK_51856561B8CB7204 FOREIGN KEY (calendarid) REFERENCES calendars (id)'); $this->addSql('ALTER TABLE calendarchanges ADD CONSTRAINT FK_737547E2B8CB7204 FOREIGN KEY (calendarid) REFERENCES calendars (id)'); $this->addSql('ALTER TABLE addressbookchanges ADD CONSTRAINT FK_EB122CD58B26C2E9 FOREIGN KEY (addressbookid) REFERENCES addressbooks (id)'); } public function down(Schema $schema): void { $this->skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); $this->addSql('ALTER TABLE cards DROP FOREIGN KEY FK_4C258FD8B26C2E9'); $this->addSql('ALTER TABLE addressbookchanges DROP FOREIGN KEY FK_EB122CD58B26C2E9'); $this->addSql('ALTER TABLE calendarobjects DROP FOREIGN KEY FK_E14F332CB8CB7204'); $this->addSql('ALTER TABLE calendarinstances DROP FOREIGN KEY FK_51856561B8CB7204'); $this->addSql('ALTER TABLE calendarchanges DROP FOREIGN KEY FK_737547E2B8CB7204'); $this->addSql('DROP TABLE cards'); $this->addSql('DROP TABLE principals'); $this->addSql('DROP TABLE locks'); $this->addSql('DROP TABLE propertystorage'); $this->addSql('DROP TABLE users'); $this->addSql('DROP TABLE calendarobjects'); $this->addSql('DROP TABLE addressbooks'); $this->addSql('DROP TABLE calendarsubscriptions'); $this->addSql('DROP TABLE schedulingobjects'); $this->addSql('DROP TABLE calendarinstances'); $this->addSql('DROP TABLE calendars'); $this->addSql('DROP TABLE calendarchanges'); $this->addSql('DROP TABLE addressbookchanges'); } } ================================================ FILE: migrations/Version20191113170650.php ================================================ skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); $this->addSql('CREATE TABLE groupmembers (principal_id INT NOT NULL, member_id INT NOT NULL, INDEX IDX_6F15EDAC474870EE (principal_id), INDEX IDX_6F15EDAC7597D3FE (member_id), PRIMARY KEY(principal_id, member_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); $this->addSql('ALTER TABLE groupmembers ADD CONSTRAINT FK_6F15EDAC474870EE FOREIGN KEY (principal_id) REFERENCES principals (id)'); $this->addSql('ALTER TABLE groupmembers ADD CONSTRAINT FK_6F15EDAC7597D3FE FOREIGN KEY (member_id) REFERENCES principals (id)'); $this->addSql('ALTER TABLE principals ADD is_main TINYINT(1) NOT NULL'); } public function down(Schema $schema): void { $this->skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); $this->addSql('DROP TABLE groupmembers'); $this->addSql('ALTER TABLE principals DROP is_main'); } } ================================================ FILE: migrations/Version20191125093508.php ================================================ skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); $this->addSql('ALTER TABLE principals ADD is_admin TINYINT(1) NOT NULL'); } public function down(Schema $schema): void { $this->skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); $this->addSql('ALTER TABLE principals DROP is_admin'); } } ================================================ FILE: migrations/Version20191202091507.php ================================================ skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); $this->addSql('ALTER TABLE calendarinstances CHANGE access access SMALLINT DEFAULT 1 NOT NULL, CHANGE share_invitestatus share_invitestatus INT DEFAULT 2 NOT NULL, CHANGE timezone timezone LONGTEXT DEFAULT NULL'); } public function down(Schema $schema): void { $this->skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); $this->addSql('ALTER TABLE calendarinstances CHANGE access access SMALLINT NOT NULL, CHANGE share_invitestatus share_invitestatus INT NOT NULL, CHANGE timezone timezone LONGTEXT DEFAULT NULL, CHANGE timezone timezone VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT \'NULL\' COLLATE `utf8mb4_unicode_ci`'); } } ================================================ FILE: migrations/Version20191203111729.php ================================================ skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); $this->addSql('ALTER TABLE addressbooks CHANGE description description LONGTEXT DEFAULT NULL'); } public function down(Schema $schema): void { $this->skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); $this->addSql('ALTER TABLE addressbooks CHANGE description description LONGTEXT CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`'); } } ================================================ FILE: migrations/Version20210928132307.php ================================================ skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); $this->addSql('ALTER TABLE calendarinstances CHANGE calendarorder calendarorder INT DEFAULT 0 NOT NULL'); } public function down(Schema $schema): void { $this->skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); $this->addSql('ALTER TABLE calendarinstances CHANGE calendarorder calendarorder INT NOT NULL'); } } ================================================ FILE: migrations/Version20221106220411.php ================================================ skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); $this->addSql('ALTER TABLE addressbookchanges CHANGE uri uri VARCHAR(255) NOT NULL'); $this->addSql('ALTER TABLE addressbooks CHANGE principaluri principaluri VARCHAR(255) NOT NULL, CHANGE uri uri VARCHAR(255) NOT NULL'); $this->addSql('ALTER TABLE calendarchanges CHANGE uri uri VARCHAR(255) NOT NULL'); $this->addSql('ALTER TABLE calendarinstances CHANGE principaluri principaluri VARCHAR(255) DEFAULT NULL, CHANGE uri uri VARCHAR(255) DEFAULT NULL, CHANGE calendarcolor calendarcolor VARCHAR(10) DEFAULT NULL, CHANGE share_href share_href VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE calendarobjects CHANGE uri uri VARCHAR(255) DEFAULT NULL, CHANGE etag etag VARCHAR(255) DEFAULT NULL, CHANGE componenttype componenttype VARCHAR(255) DEFAULT NULL, CHANGE uid uid VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE calendars CHANGE components components VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE calendarsubscriptions CHANGE uri uri VARCHAR(255) NOT NULL, CHANGE principaluri principaluri VARCHAR(255) NOT NULL, CHANGE calendarcolor calendarcolor VARCHAR(10) DEFAULT NULL'); $this->addSql('ALTER TABLE cards CHANGE uri uri VARCHAR(255) DEFAULT NULL, CHANGE etag etag VARCHAR(32) DEFAULT NULL'); $this->addSql('ALTER TABLE locks CHANGE token token VARCHAR(255) DEFAULT NULL, CHANGE uri uri VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE principals CHANGE uri uri VARCHAR(255) NOT NULL, CHANGE email email VARCHAR(255) DEFAULT NULL'); $this->addSql('CREATE UNIQUE INDEX UNIQ_E797E7FB841CB121 ON principals (uri)'); $this->addSql('ALTER TABLE schedulingobjects CHANGE principaluri principaluri VARCHAR(255) DEFAULT NULL, CHANGE uri uri VARCHAR(255) DEFAULT NULL, CHANGE etag etag VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE users CHANGE digesta1 digesta1 VARCHAR(255) NOT NULL, CHANGE username username VARCHAR(255) NOT NULL'); $this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E9F85E0677 ON users (username)'); } public function down(Schema $schema): void { $this->skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); $this->addSql('ALTER TABLE addressbookchanges CHANGE uri uri VARBINARY(255) NOT NULL'); $this->addSql('ALTER TABLE addressbooks CHANGE principaluri principaluri VARBINARY(255) NOT NULL, CHANGE uri uri VARBINARY(255) NOT NULL'); $this->addSql('ALTER TABLE calendarchanges CHANGE uri uri VARBINARY(255) NOT NULL'); $this->addSql('ALTER TABLE calendarinstances CHANGE principaluri principaluri VARBINARY(255) DEFAULT NULL, CHANGE uri uri VARBINARY(255) DEFAULT NULL, CHANGE calendarcolor calendarcolor VARBINARY(10) DEFAULT NULL, CHANGE share_href share_href VARBINARY(255) DEFAULT NULL'); $this->addSql('ALTER TABLE calendarobjects CHANGE uri uri VARBINARY(255) DEFAULT NULL, CHANGE etag etag VARBINARY(255) DEFAULT NULL, CHANGE componenttype componenttype VARBINARY(255) DEFAULT NULL, CHANGE uid uid VARBINARY(255) DEFAULT NULL'); $this->addSql('ALTER TABLE calendars CHANGE components components VARBINARY(255) DEFAULT NULL'); $this->addSql('ALTER TABLE calendarsubscriptions CHANGE uri uri VARBINARY(255) NOT NULL, CHANGE principaluri principaluri VARBINARY(255) NOT NULL, CHANGE calendarcolor calendarcolor VARBINARY(10) DEFAULT NULL'); $this->addSql('ALTER TABLE cards CHANGE uri uri VARBINARY(255) DEFAULT NULL, CHANGE etag etag VARBINARY(32) DEFAULT NULL'); $this->addSql('ALTER TABLE locks CHANGE token token VARBINARY(255) DEFAULT NULL, CHANGE uri uri VARBINARY(255) DEFAULT NULL'); $this->addSql('DROP INDEX UNIQ_E797E7FB841CB121 ON principals'); $this->addSql('ALTER TABLE principals CHANGE uri uri VARBINARY(255) NOT NULL, CHANGE email email VARBINARY(255) DEFAULT NULL'); $this->addSql('ALTER TABLE schedulingobjects CHANGE principaluri principaluri VARBINARY(255) DEFAULT NULL, CHANGE uri uri VARBINARY(255) DEFAULT NULL, CHANGE etag etag VARBINARY(255) DEFAULT NULL'); $this->addSql('DROP INDEX UNIQ_1483A5E9F85E0677 ON users'); $this->addSql('ALTER TABLE users CHANGE digesta1 digesta1 VARBINARY(255) NOT NULL, CHANGE username username VARBINARY(255) NOT NULL'); } } ================================================ FILE: migrations/Version20221106220412.php ================================================ skipIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'postgresql\'. Skipping it is fine.'); $this->addSql('CREATE SEQUENCE addressbooks_id_seq INCREMENT BY 1 MINVALUE 1 START 1;'); $this->addSql('CREATE SEQUENCE calendars_id_seq INCREMENT BY 1 MINVALUE 1 START 1;'); $this->addSql('CREATE SEQUENCE cards_id_seq INCREMENT BY 1 MINVALUE 1 START 1;'); $this->addSql('CREATE SEQUENCE calendarsubscriptions_id_seq INCREMENT BY 1 MINVALUE 1 START 1;'); $this->addSql('CREATE SEQUENCE schedulingobjects_id_seq INCREMENT BY 1 MINVALUE 1 START 1;'); $this->addSql('CREATE SEQUENCE locks_id_seq INCREMENT BY 1 MINVALUE 1 START 1;'); $this->addSql('CREATE SEQUENCE calendarinstances_id_seq INCREMENT BY 1 MINVALUE 1 START 1;'); $this->addSql('CREATE SEQUENCE addressbookchanges_id_seq INCREMENT BY 1 MINVALUE 1 START 1;'); $this->addSql('CREATE SEQUENCE principals_id_seq INCREMENT BY 1 MINVALUE 1 START 1;'); $this->addSql('CREATE SEQUENCE calendarchanges_id_seq INCREMENT BY 1 MINVALUE 1 START 1;'); $this->addSql('CREATE SEQUENCE users_id_seq INCREMENT BY 1 MINVALUE 1 START 1;'); $this->addSql('CREATE SEQUENCE calendarobjects_id_seq INCREMENT BY 1 MINVALUE 1 START 1;'); $this->addSql('CREATE SEQUENCE propertystorage_id_seq INCREMENT BY 1 MINVALUE 1 START 1;'); $this->addSql('CREATE TABLE addressbooks (id INT NOT NULL, principaluri VARCHAR(255) NOT NULL, displayname VARCHAR(255) NOT NULL, uri VARCHAR(255) NOT NULL, description TEXT DEFAULT NULL, synctoken VARCHAR(255) NOT NULL, PRIMARY KEY(id));'); $this->addSql('CREATE TABLE calendars (id INT NOT NULL, synctoken VARCHAR(255) NOT NULL, components VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id));'); $this->addSql('CREATE TABLE cards (id INT NOT NULL, addressbookid INT NOT NULL, carddata BYTEA DEFAULT NULL, uri VARCHAR(255) DEFAULT NULL, lastmodified INT DEFAULT NULL, etag VARCHAR(32) DEFAULT NULL, size INT NOT NULL, PRIMARY KEY(id));'); $this->addSql('CREATE INDEX IDX_4C258FD8B26C2E9 ON cards (addressbookid);'); $this->addSql('CREATE TABLE calendarsubscriptions (id INT NOT NULL, uri VARCHAR(255) NOT NULL, principaluri VARCHAR(255) NOT NULL, source TEXT DEFAULT NULL, displayname VARCHAR(255) DEFAULT NULL, refreshrate VARCHAR(10) DEFAULT NULL, calendarorder INT NOT NULL, calendarcolor VARCHAR(10) DEFAULT NULL, striptodos SMALLINT DEFAULT NULL, stripalarms SMALLINT DEFAULT NULL, stripattachments SMALLINT DEFAULT NULL, lastmodified INT DEFAULT NULL, PRIMARY KEY(id));'); $this->addSql('CREATE TABLE schedulingobjects (id INT NOT NULL, principaluri VARCHAR(255) DEFAULT NULL, calendardata BYTEA DEFAULT NULL, uri VARCHAR(255) DEFAULT NULL, lastmodified INT DEFAULT NULL, etag VARCHAR(255) DEFAULT NULL, size INT NOT NULL, PRIMARY KEY(id));'); $this->addSql('CREATE TABLE locks (id INT NOT NULL, owner VARCHAR(255) DEFAULT NULL, timeout INT DEFAULT NULL, created INT DEFAULT NULL, token VARCHAR(255) DEFAULT NULL, scope SMALLINT DEFAULT NULL, depth SMALLINT DEFAULT NULL, uri VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id));'); $this->addSql('CREATE TABLE calendarinstances (id INT NOT NULL, calendarid INT NOT NULL, principaluri VARCHAR(255) DEFAULT NULL, access SMALLINT DEFAULT 1 NOT NULL, displayname VARCHAR(255) DEFAULT NULL, uri VARCHAR(255) DEFAULT NULL, description TEXT DEFAULT NULL, calendarorder INT DEFAULT 0 NOT NULL, calendarcolor VARCHAR(10) DEFAULT NULL, timezone TEXT DEFAULT NULL, transparent INT DEFAULT NULL, share_href VARCHAR(255) DEFAULT NULL, share_displayname VARCHAR(255) DEFAULT NULL, share_invitestatus INT DEFAULT 2 NOT NULL, PRIMARY KEY(id));'); $this->addSql('CREATE INDEX IDX_51856561B8CB7204 ON calendarinstances (calendarid);'); $this->addSql('CREATE TABLE addressbookchanges (id INT NOT NULL, addressbookid INT NOT NULL, uri VARCHAR(255) NOT NULL, synctoken VARCHAR(255) NOT NULL, operation INT NOT NULL, PRIMARY KEY(id));'); $this->addSql('CREATE INDEX IDX_EB122CD58B26C2E9 ON addressbookchanges (addressbookid);'); $this->addSql('CREATE TABLE principals (id INT NOT NULL, uri VARCHAR(255) NOT NULL, email VARCHAR(255) DEFAULT NULL, displayname VARCHAR(255) DEFAULT NULL, is_main BOOLEAN NOT NULL, is_admin BOOLEAN NOT NULL, PRIMARY KEY(id));'); $this->addSql('CREATE UNIQUE INDEX UNIQ_E797E7FB841CB121 ON principals (uri);'); $this->addSql('CREATE TABLE groupmembers (principal_id INT NOT NULL, member_id INT NOT NULL, PRIMARY KEY(principal_id, member_id));'); $this->addSql('CREATE INDEX IDX_6F15EDAC474870EE ON groupmembers (principal_id);'); $this->addSql('CREATE INDEX IDX_6F15EDAC7597D3FE ON groupmembers (member_id);'); $this->addSql('CREATE TABLE calendarchanges (id INT NOT NULL, calendarid INT NOT NULL, uri VARCHAR(255) NOT NULL, synctoken INT NOT NULL, operation SMALLINT NOT NULL, PRIMARY KEY(id));'); $this->addSql('CREATE INDEX IDX_737547E2B8CB7204 ON calendarchanges (calendarid);'); $this->addSql('CREATE TABLE users (id INT NOT NULL, username VARCHAR(255) NOT NULL, digesta1 VARCHAR(255) NOT NULL, PRIMARY KEY(id));'); $this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E9F85E0677 ON users (username);'); $this->addSql('CREATE TABLE calendarobjects (id INT NOT NULL, calendarid INT NOT NULL, calendardata BYTEA DEFAULT NULL, uri VARCHAR(255) DEFAULT NULL, lastmodified INT DEFAULT NULL, etag VARCHAR(255) DEFAULT NULL, size INT NOT NULL, componenttype VARCHAR(255) DEFAULT NULL, firstoccurence INT DEFAULT NULL, lastoccurence INT DEFAULT NULL, uid VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id));'); $this->addSql('CREATE INDEX IDX_E14F332CB8CB7204 ON calendarobjects (calendarid);'); $this->addSql('CREATE TABLE propertystorage (id INT NOT NULL, path VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, valuetype INT DEFAULT NULL, value TEXT DEFAULT NULL, PRIMARY KEY(id));'); $this->addSql('ALTER TABLE cards ADD CONSTRAINT FK_4C258FD8B26C2E9 FOREIGN KEY (addressbookid) REFERENCES addressbooks (id) NOT DEFERRABLE INITIALLY IMMEDIATE;'); $this->addSql('ALTER TABLE calendarinstances ADD CONSTRAINT FK_51856561B8CB7204 FOREIGN KEY (calendarid) REFERENCES calendars (id) NOT DEFERRABLE INITIALLY IMMEDIATE;'); $this->addSql('ALTER TABLE addressbookchanges ADD CONSTRAINT FK_EB122CD58B26C2E9 FOREIGN KEY (addressbookid) REFERENCES addressbooks (id) NOT DEFERRABLE INITIALLY IMMEDIATE;'); $this->addSql('ALTER TABLE groupmembers ADD CONSTRAINT FK_6F15EDAC474870EE FOREIGN KEY (principal_id) REFERENCES principals (id) NOT DEFERRABLE INITIALLY IMMEDIATE;'); $this->addSql('ALTER TABLE groupmembers ADD CONSTRAINT FK_6F15EDAC7597D3FE FOREIGN KEY (member_id) REFERENCES principals (id) NOT DEFERRABLE INITIALLY IMMEDIATE;'); $this->addSql('ALTER TABLE calendarchanges ADD CONSTRAINT FK_737547E2B8CB7204 FOREIGN KEY (calendarid) REFERENCES calendars (id) NOT DEFERRABLE INITIALLY IMMEDIATE;'); $this->addSql('ALTER TABLE calendarobjects ADD CONSTRAINT FK_E14F332CB8CB7204 FOREIGN KEY (calendarid) REFERENCES calendars (id) NOT DEFERRABLE INITIALLY IMMEDIATE;'); } public function down(Schema $schema): void { $this->skipIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'postgresql\'. Skipping it is fine.'); $this->addSql('ALTER TABLE cards DROP CONSTRAINT FK_4C258FD8B26C2E9;'); $this->addSql('ALTER TABLE calendarinstances DROP CONSTRAINT FK_51856561B8CB7204;'); $this->addSql('ALTER TABLE addressbookchanges DROP CONSTRAINT FK_EB122CD58B26C2E9;'); $this->addSql('ALTER TABLE groupmembers DROP CONSTRAINT FK_6F15EDAC474870EE;'); $this->addSql('ALTER TABLE groupmembers DROP CONSTRAINT FK_6F15EDAC7597D3FE;'); $this->addSql('ALTER TABLE calendarchanges DROP CONSTRAINT FK_737547E2B8CB7204;'); $this->addSql('ALTER TABLE calendarobjects DROP CONSTRAINT FK_E14F332CB8CB7204;'); $this->addSql('DROP SEQUENCE addressbooks_id_seq CASCADE;'); $this->addSql('DROP SEQUENCE calendars_id_seq CASCADE;'); $this->addSql('DROP SEQUENCE cards_id_seq CASCADE;'); $this->addSql('DROP SEQUENCE calendarsubscriptions_id_seq CASCADE;'); $this->addSql('DROP SEQUENCE schedulingobjects_id_seq CASCADE;'); $this->addSql('DROP SEQUENCE locks_id_seq CASCADE;'); $this->addSql('DROP SEQUENCE calendarinstances_id_seq CASCADE;'); $this->addSql('DROP SEQUENCE addressbookchanges_id_seq CASCADE;'); $this->addSql('DROP SEQUENCE principals_id_seq CASCADE;'); $this->addSql('DROP SEQUENCE calendarchanges_id_seq CASCADE;'); $this->addSql('DROP SEQUENCE users_id_seq CASCADE;'); $this->addSql('DROP SEQUENCE calendarobjects_id_seq CASCADE;'); $this->addSql('DROP SEQUENCE propertystorage_id_seq CASCADE;'); $this->addSql('DROP TABLE addressbooks;'); $this->addSql('DROP TABLE calendars;'); $this->addSql('DROP TABLE cards;'); $this->addSql('DROP TABLE calendarsubscriptions;'); $this->addSql('DROP TABLE schedulingobjects;'); $this->addSql('DROP TABLE locks;'); $this->addSql('DROP TABLE calendarinstances;'); $this->addSql('DROP TABLE addressbookchanges;'); $this->addSql('DROP TABLE principals;'); $this->addSql('DROP TABLE groupmembers;'); $this->addSql('DROP TABLE calendarchanges;'); $this->addSql('DROP TABLE users;'); $this->addSql('DROP TABLE calendarobjects;'); $this->addSql('DROP TABLE propertystorage;'); } } ================================================ FILE: migrations/Version20221211154443.php ================================================ skipIf('sqlite' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'sqlite\'. Skipping it is fine.'); $this->addSql('CREATE TABLE addressbookchanges (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, addressbookid INTEGER NOT NULL, uri VARCHAR(255) NOT NULL, synctoken VARCHAR(255) NOT NULL, operation INTEGER NOT NULL)'); $this->addSql('CREATE INDEX IDX_EB122CD58B26C2E9 ON addressbookchanges (addressbookid)'); $this->addSql('CREATE TABLE addressbooks (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, principaluri VARCHAR(255) NOT NULL, displayname VARCHAR(255) NOT NULL, uri VARCHAR(255) NOT NULL, description CLOB DEFAULT NULL, synctoken VARCHAR(255) NOT NULL)'); $this->addSql('CREATE TABLE calendarchanges (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, calendarid INTEGER NOT NULL, uri VARCHAR(255) NOT NULL, synctoken INTEGER NOT NULL, operation SMALLINT NOT NULL)'); $this->addSql('CREATE INDEX IDX_737547E2B8CB7204 ON calendarchanges (calendarid)'); $this->addSql('CREATE TABLE calendarinstances (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, calendarid INTEGER NOT NULL, principaluri VARCHAR(255) DEFAULT NULL, access SMALLINT DEFAULT 1 NOT NULL, displayname VARCHAR(255) DEFAULT NULL, uri VARCHAR(255) DEFAULT NULL, description CLOB DEFAULT NULL, calendarorder INTEGER DEFAULT 0 NOT NULL, calendarcolor VARCHAR(10) DEFAULT NULL, timezone CLOB DEFAULT NULL, transparent INTEGER DEFAULT NULL, share_href VARCHAR(255) DEFAULT NULL, share_displayname VARCHAR(255) DEFAULT NULL, share_invitestatus INTEGER DEFAULT 2 NOT NULL)'); $this->addSql('CREATE INDEX IDX_51856561B8CB7204 ON calendarinstances (calendarid)'); $this->addSql('CREATE TABLE calendarobjects (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, calendarid INTEGER NOT NULL, calendardata BLOB DEFAULT NULL, uri VARCHAR(255) DEFAULT NULL, lastmodified INTEGER DEFAULT NULL, etag VARCHAR(255) DEFAULT NULL, size INTEGER NOT NULL, componenttype VARCHAR(255) DEFAULT NULL, firstoccurence INTEGER DEFAULT NULL, lastoccurence INTEGER DEFAULT NULL, uid VARCHAR(255) DEFAULT NULL)'); $this->addSql('CREATE INDEX IDX_E14F332CB8CB7204 ON calendarobjects (calendarid)'); $this->addSql('CREATE TABLE calendars (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, synctoken VARCHAR(255) NOT NULL, components VARCHAR(255) DEFAULT NULL)'); $this->addSql('CREATE TABLE calendarsubscriptions (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, uri VARCHAR(255) NOT NULL, principaluri VARCHAR(255) NOT NULL, source CLOB DEFAULT NULL, displayname VARCHAR(255) DEFAULT NULL, refreshrate VARCHAR(10) DEFAULT NULL, calendarorder INTEGER NOT NULL, calendarcolor VARCHAR(10) DEFAULT NULL, striptodos SMALLINT DEFAULT NULL, stripalarms SMALLINT DEFAULT NULL, stripattachments SMALLINT DEFAULT NULL, lastmodified INTEGER DEFAULT NULL)'); $this->addSql('CREATE TABLE cards (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, addressbookid INTEGER NOT NULL, carddata BLOB DEFAULT NULL, uri VARCHAR(255) DEFAULT NULL, lastmodified INTEGER DEFAULT NULL, etag VARCHAR(32) DEFAULT NULL, size INTEGER NOT NULL)'); $this->addSql('CREATE INDEX IDX_4C258FD8B26C2E9 ON cards (addressbookid)'); $this->addSql('CREATE TABLE locks (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, owner VARCHAR(255) DEFAULT NULL, timeout INTEGER DEFAULT NULL, created INTEGER DEFAULT NULL, token VARCHAR(255) DEFAULT NULL, scope SMALLINT DEFAULT NULL, depth SMALLINT DEFAULT NULL, uri VARCHAR(255) DEFAULT NULL)'); $this->addSql('CREATE TABLE principals (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, uri VARCHAR(255) NOT NULL, email VARCHAR(255) DEFAULT NULL, displayname VARCHAR(255) DEFAULT NULL, is_main BOOLEAN NOT NULL, is_admin BOOLEAN NOT NULL)'); $this->addSql('CREATE UNIQUE INDEX UNIQ_E797E7FB841CB121 ON principals (uri)'); $this->addSql('CREATE TABLE groupmembers (principal_id INTEGER NOT NULL, member_id INTEGER NOT NULL, PRIMARY KEY(principal_id, member_id))'); $this->addSql('CREATE INDEX IDX_6F15EDAC474870EE ON groupmembers (principal_id)'); $this->addSql('CREATE INDEX IDX_6F15EDAC7597D3FE ON groupmembers (member_id)'); $this->addSql('CREATE TABLE propertystorage (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, path VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, valuetype INTEGER DEFAULT NULL, value CLOB DEFAULT NULL)'); $this->addSql('CREATE TABLE schedulingobjects (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, principaluri VARCHAR(255) DEFAULT NULL, calendardata BLOB DEFAULT NULL, uri VARCHAR(255) DEFAULT NULL, lastmodified INTEGER DEFAULT NULL, etag VARCHAR(255) DEFAULT NULL, size INTEGER NOT NULL)'); $this->addSql('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, username VARCHAR(255) NOT NULL, digesta1 VARCHAR(255) NOT NULL)'); $this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E9F85E0677 ON users (username)'); } public function down(Schema $schema): void { $this->skipIf('sqlite' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'sqlite\'. Skipping it is fine.'); $this->addSql('DROP TABLE addressbookchanges'); $this->addSql('DROP TABLE addressbooks'); $this->addSql('DROP TABLE calendarchanges'); $this->addSql('DROP TABLE calendarinstances'); $this->addSql('DROP TABLE calendarobjects'); $this->addSql('DROP TABLE calendars'); $this->addSql('DROP TABLE calendarsubscriptions'); $this->addSql('DROP TABLE cards'); $this->addSql('DROP TABLE locks'); $this->addSql('DROP TABLE principals'); $this->addSql('DROP TABLE groupmembers'); $this->addSql('DROP TABLE propertystorage'); $this->addSql('DROP TABLE schedulingobjects'); $this->addSql('DROP TABLE users'); } } ================================================ FILE: migrations/Version20230209142217.php ================================================ skipIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'postgresql\'. Skipping it is fine.'); $this->addSql('ALTER TABLE addressbooks ALTER COLUMN id SET DEFAULT nextval(\'addressbooks_id_seq\');'); $this->addSql('ALTER TABLE calendars ALTER COLUMN id SET DEFAULT nextval(\'calendars_id_seq\');'); $this->addSql('ALTER TABLE cards ALTER COLUMN id SET DEFAULT nextval(\'cards_id_seq\');'); $this->addSql('ALTER TABLE calendarsubscriptions ALTER COLUMN id SET DEFAULT nextval(\'calendarsubscriptions_id_seq\');'); $this->addSql('ALTER TABLE schedulingobjects ALTER COLUMN id SET DEFAULT nextval(\'schedulingobjects_id_seq\');'); $this->addSql('ALTER TABLE locks ALTER COLUMN id SET DEFAULT nextval(\'locks_id_seq\');'); $this->addSql('ALTER TABLE calendarinstances ALTER COLUMN id SET DEFAULT nextval(\'calendarinstances_id_seq\');'); $this->addSql('ALTER TABLE addressbookchanges ALTER COLUMN id SET DEFAULT nextval(\'addressbookchanges_id_seq\');'); $this->addSql('ALTER TABLE principals ALTER COLUMN id SET DEFAULT nextval(\'principals_id_seq\');'); $this->addSql('ALTER TABLE calendarchanges ALTER COLUMN id SET DEFAULT nextval(\'calendarchanges_id_seq\');'); $this->addSql('ALTER TABLE users ALTER COLUMN id SET DEFAULT nextval(\'users_id_seq\');'); $this->addSql('ALTER TABLE calendarobjects ALTER COLUMN id SET DEFAULT nextval(\'calendarobjects_id_seq\');'); $this->addSql('ALTER TABLE propertystorage ALTER COLUMN id SET DEFAULT nextval(\'propertystorage_id_seq\');'); $this->addSql('ALTER TABLE addressbooks ALTER COLUMN synctoken TYPE integer USING synctoken::integer;'); $this->addSql('ALTER TABLE calendars ALTER COLUMN synctoken TYPE integer USING synctoken::integer;'); } public function down(Schema $schema): void { $this->skipIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'postgresql\'. Skipping it is fine.'); $this->addSql('ALTER TABLE addressbooks ALTER COLUMN id DROP DEFAULT;'); $this->addSql('ALTER TABLE calendars ALTER COLUMN id DROP DEFAULT;'); $this->addSql('ALTER TABLE cards ALTER COLUMN id DROP DEFAULT;'); $this->addSql('ALTER TABLE calendarsubscriptions ALTER COLUMN id DROP DEFAULT;'); $this->addSql('ALTER TABLE schedulingobjects ALTER COLUMN id DROP DEFAULT;'); $this->addSql('ALTER TABLE locks ALTER COLUMN id DROP DEFAULT;'); $this->addSql('ALTER TABLE calendarinstances ALTER COLUMN id DROP DEFAULT;'); $this->addSql('ALTER TABLE addressbookchanges ALTER COLUMN id DROP DEFAULT;'); $this->addSql('ALTER TABLE principals ALTER COLUMN id DROP DEFAULT;'); $this->addSql('ALTER TABLE calendarchanges ALTER COLUMN id DROP DEFAULT;'); $this->addSql('ALTER TABLE users ALTER COLUMN id DROP DEFAULT;'); $this->addSql('ALTER TABLE calendarobjects ALTER COLUMN id DROP DEFAULT;'); $this->addSql('ALTER TABLE propertystorage ALTER COLUMN id DROP DEFAULT;'); $this->addSql('ALTER TABLE addressbooks ALTER COLUMN synctoken TYPE varchar(255);'); $this->addSql('ALTER TABLE calendars ALTER COLUMN synctoken TYPE varchar(255);'); } } ================================================ FILE: migrations/Version20231001214111.php ================================================ skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); $this->addSql('ALTER TABLE calendarobjects CHANGE calendardata calendardata MEDIUMTEXT DEFAULT NULL'); $this->addSql('ALTER TABLE cards CHANGE carddata carddata MEDIUMTEXT DEFAULT NULL'); $this->addSql('ALTER TABLE schedulingobjects CHANGE calendardata calendardata MEDIUMTEXT DEFAULT NULL'); } public function down(Schema $schema): void { $this->skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); $this->addSql('ALTER TABLE schedulingobjects CHANGE calendardata calendardata LONGBLOB DEFAULT NULL'); $this->addSql('ALTER TABLE calendarobjects CHANGE calendardata calendardata LONGBLOB DEFAULT NULL'); $this->addSql('ALTER TABLE cards CHANGE carddata carddata LONGBLOB DEFAULT NULL'); } } ================================================ FILE: migrations/Version20231001214112.php ================================================ skipIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'postgresql\'. Skipping it is fine.'); $this->addSql("ALTER TABLE calendarobjects ALTER COLUMN calendardata TYPE TEXT USING convert_from(calendardata, 'utf8')"); $this->addSql("ALTER TABLE cards ALTER COLUMN carddata TYPE TEXT USING convert_from(carddata, 'utf8')"); $this->addSql("ALTER TABLE schedulingobjects ALTER COLUMN calendardata TYPE TEXT USING convert_from(calendardata, 'utf8')"); } public function down(Schema $schema): void { $this->skipIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'postgresql\'. Skipping it is fine.'); $this->addSql("ALTER TABLE calendarobjects ALTER COLUMN calendardata TYPE BYTEA DEFAULT NULL USING convert_from(calendardata, 'utf8')"); $this->addSql("ALTER TABLE cards ALTER COLUMN carddata TYPE BYTEA DEFAULT NULL USING convert_from(carddata, 'utf8')"); $this->addSql("ALTER TABLE schedulingobjects ALTER COLUMN calendardata TYPE BYTEA DEFAULT NULL USING convert_from(calendardata, 'utf8')"); } } ================================================ FILE: migrations/Version20231001214113.php ================================================ skipIf('sqlite' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'sqlite\'. Skipping it is fine.'); $this->addSql('ALTER TABLE calendarobjects ADD COLUMN new_calendardata TEXT DEFAULT NULL;'); $this->addSql('UPDATE calendarobjects SET new_calendardata = CAST(calendardata as TEXT);'); $this->addSql('ALTER TABLE calendarobjects RENAME COLUMN calendardata TO old_calendardata;'); $this->addSql('ALTER TABLE calendarobjects RENAME COLUMN new_calendardata TO calendardata;'); $this->addSql('ALTER TABLE calendarobjects DROP COLUMN old_calendardata;'); $this->addSql('ALTER TABLE cards ADD COLUMN new_carddata TEXT DEFAULT NULL;'); $this->addSql('UPDATE cards SET new_carddata = CAST(carddata as TEXT);'); $this->addSql('ALTER TABLE cards RENAME COLUMN carddata TO old_carddata;'); $this->addSql('ALTER TABLE cards RENAME COLUMN new_carddata TO carddata;'); $this->addSql('ALTER TABLE cards DROP COLUMN old_carddata;'); $this->addSql('ALTER TABLE schedulingobjects ADD COLUMN new_calendardata TEXT DEFAULT NULL;'); $this->addSql('UPDATE schedulingobjects SET new_calendardata = CAST(calendardata as TEXT);'); $this->addSql('ALTER TABLE schedulingobjects RENAME COLUMN calendardata TO old_calendardata;'); $this->addSql('ALTER TABLE schedulingobjects RENAME COLUMN new_calendardata TO calendardata;'); $this->addSql('ALTER TABLE schedulingobjects DROP COLUMN old_calendardata;'); } public function down(Schema $schema): void { $this->skipIf('sqlite' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'sqlite\'. Skipping it is fine.'); $this->addSql('ALTER TABLE calendarobjects ADD COLUMN new_calendardata BLOB DEFAULT NULL;'); $this->addSql('UPDATE calendarobjects SET new_calendardata = CAST(calendardata as BLOB);'); $this->addSql('ALTER TABLE calendarobjects RENAME COLUMN calendardata TO old_calendardata;'); $this->addSql('ALTER TABLE calendarobjects RENAME COLUMN new_calendardata TO calendardata;'); $this->addSql('ALTER TABLE calendarobjects DROP COLUMN old_calendardata;'); $this->addSql('ALTER TABLE cards ADD COLUMN new_carddata BLOB DEFAULT NULL;'); $this->addSql('UPDATE cards SET new_carddata = CAST(carddata as BLOB);'); $this->addSql('ALTER TABLE cards RENAME COLUMN carddata TO old_carddata;'); $this->addSql('ALTER TABLE cards RENAME COLUMN new_carddata TO carddata;'); $this->addSql('ALTER TABLE cards DROP COLUMN old_carddata;'); $this->addSql('ALTER TABLE schedulingobjects ADD COLUMN new_calendardata BLOB DEFAULT NULL;'); $this->addSql('UPDATE schedulingobjects SET new_calendardata = CAST(calendardata as BLOB);'); $this->addSql('ALTER TABLE schedulingobjects RENAME COLUMN calendardata TO old_calendardata;'); $this->addSql('ALTER TABLE schedulingobjects RENAME COLUMN new_calendardata TO calendardata;'); $this->addSql('ALTER TABLE schedulingobjects DROP COLUMN old_calendardata;'); } } ================================================ FILE: migrations/Version20231229203515.php ================================================ skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); $this->addSql('ALTER TABLE calendarobjects CHANGE calendardata calendardata MEDIUMTEXT DEFAULT NULL'); $this->addSql('ALTER TABLE cards CHANGE carddata carddata MEDIUMTEXT DEFAULT NULL'); $this->addSql('ALTER TABLE schedulingobjects CHANGE calendardata calendardata MEDIUMTEXT DEFAULT NULL'); } public function down(Schema $schema): void { $this->skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); $this->addSql('ALTER TABLE schedulingobjects CHANGE calendardata calendardata TEXT DEFAULT NULL'); $this->addSql('ALTER TABLE calendarobjects CHANGE calendardata calendardata TEXT DEFAULT NULL'); $this->addSql('ALTER TABLE cards CHANGE carddata carddata TEXT DEFAULT NULL'); } } ================================================ FILE: migrations/Version20250409193948.php ================================================ skipIf('sqlite' === $this->connection->getDatabasePlatform()->getName(), 'This migration is not needed on \'sqlite\'. Skipping it is fine.'); // MySQL if ('mysql' === $this->connection->getDatabasePlatform()->getName()) { $this->addSql('ALTER TABLE calendarobjects CHANGE lastmodified lastmodified BIGINT DEFAULT NULL, CHANGE firstoccurence firstoccurence BIGINT DEFAULT NULL, CHANGE lastoccurence lastoccurence BIGINT DEFAULT NULL'); $this->addSql('ALTER TABLE calendarsubscriptions CHANGE lastmodified lastmodified BIGINT DEFAULT NULL'); $this->addSql('ALTER TABLE locks CHANGE created created BIGINT DEFAULT NULL'); $this->addSql('ALTER TABLE schedulingobjects CHANGE lastmodified lastmodified BIGINT DEFAULT NULL'); } // Posgres if ('postgresql' === $this->connection->getDatabasePlatform()->getName()) { $this->addSql('ALTER TABLE calendarobjects ALTER COLUMN lastmodified TYPE BIGINT'); $this->addSql('ALTER TABLE calendarobjects ALTER COLUMN firstoccurence TYPE BIGINT'); $this->addSql('ALTER TABLE calendarobjects ALTER COLUMN lastoccurence TYPE BIGINT'); $this->addSql('ALTER TABLE calendarsubscriptions ALTER COLUMN lastmodified TYPE BIGINT'); $this->addSql('ALTER TABLE locks ALTER COLUMN created TYPE BIGINT'); $this->addSql('ALTER TABLE schedulingobjects ALTER COLUMN lastmodified TYPE BIGINT'); } } public function down(Schema $schema): void { // No need for a down here, it's fine } } ================================================ FILE: migrations/Version20250421163214.php ================================================ connection->getDatabasePlatform()->getName(); if ('mysql' === $engine) { $this->addSql('ALTER TABLE addressbooks ADD included_in_birthday_calendar TINYINT(1) DEFAULT 0'); } elseif ('postgresql' === $engine) { $this->addSql('ALTER TABLE addressbooks ADD COLUMN included_in_birthday_calendar BOOLEAN DEFAULT FALSE;'); } elseif ('sqlite' === $engine) { $this->addSql('ALTER TABLE addressbooks ADD COLUMN included_in_birthday_calendar INTEGER DEFAULT 0;'); } } public function down(Schema $schema): void { if ('mysql' === $this->connection->getDatabasePlatform()->getName()) { $this->addSql('ALTER TABLE addressbooks DROP included_in_birthday_calendar'); } else { $this->addSql('ALTER TABLE addressbooks DROP COLUMN included_in_birthday_calendar'); } } } ================================================ FILE: migrations/Version20260131161930.php ================================================ connection->getDatabasePlatform()->getName(); if ('mysql' === $engine) { $this->addSql('ALTER TABLE calendarinstances ADD public TINYINT(1) DEFAULT 0 NOT NULL'); } elseif ('postgresql' === $engine) { $this->addSql('ALTER TABLE calendarinstances ADD public BOOLEAN DEFAULT FALSE NOT NULL'); } elseif ('sqlite' === $engine) { $this->addSql('ALTER TABLE calendarinstances ADD public BOOLEAN DEFAULT 0 NOT NULL'); } // Migrate ACCESS_PUBLIC (10) to ACCESS_SHAREDOWNER (1) + public = true if ('postgresql' === $engine) { $this->addSql('UPDATE calendarinstances SET public = TRUE, access = 1 WHERE access = 10'); } else { // MySQL and SQLite accept 1/0 for booleans $this->addSql('UPDATE calendarinstances SET public = 1, access = 1 WHERE access = 10'); } } public function down(Schema $schema): void { $engine = $this->connection->getDatabasePlatform()->getName(); // Revert public = true back to ACCESS_PUBLIC (10) if ('postgresql' === $engine) { $this->addSql('UPDATE calendarinstances SET access = 10 WHERE is_public = TRUE'); } else { $this->addSql('UPDATE calendarinstances SET access = 10 WHERE is_public = 1'); } if ('mysql' === $engine) { $this->addSql('ALTER TABLE calendarinstances DROP public'); } elseif ('postgresql' === $engine) { $this->addSql('ALTER TABLE calendarinstances DROP COLUMN public'); } elseif ('sqlite' === $engine) { $this->addSql('ALTER TABLE calendarinstances DROP COLUMN public'); } } } ================================================ FILE: phpunit.xml.dist ================================================ tests src ================================================ FILE: public/.htaccess ================================================ # Use the front controller as index file. It serves as a fallback solution when # every other rewrite/redirect fails (e.g. in an aliased environment without # mod_rewrite). Additionally, this reduces the matching process for the # start page (path "/") because otherwise Apache will apply the rewriting rules # to each configured DirectoryIndex file (e.g. index.php, index.html, index.pl). DirectoryIndex index.php # By default, Apache does not evaluate symbolic links if you did not enable this # feature in your server configuration. Uncomment the following line if you # install assets as symlinks or if you experience problems related to symlinks # when compiling LESS/Sass/CoffeScript assets. # Options +FollowSymlinks # Disabling MultiViews prevents unwanted negotiation, e.g. "/index" should not resolve # to the front controller "/index.php" but be rewritten to "/index.php/index". Options -MultiViews RewriteEngine On # Add .well-known redirections RewriteRule ^\.well-known/carddav /dav/ [R=301,L] RewriteRule ^\.well-known/caldav /dav/ [R=301,L] # Determine the RewriteBase automatically and set it as environment variable. # If you are using Apache aliases to do mass virtual hosting or installed the # project in a subdirectory, the base path will be prepended to allow proper # resolution of the index.php file and to redirect to the correct URI. It will # work in environments without path prefix as well, providing a safe, one-size # fits all solution. But as you do not need it in this case, you can comment # the following 2 lines to eliminate the overhead. RewriteCond %{REQUEST_URI}::$0 ^(/.+)/(.*)::\2$ RewriteRule .* - [E=BASE:%1] # Sets the HTTP_AUTHORIZATION header removed by Apache RewriteCond %{HTTP:Authorization} .+ RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0] # Redirect to URI without front controller to prevent duplicate content # (with and without `/index.php`). Only do this redirect on the initial # rewrite by Apache and not on subsequent cycles. Otherwise we would get an # endless redirect loop (request -> rewrite to front controller -> # redirect -> request -> ...). # So in case you get a "too many redirects" error or you always get redirected # to the start page because your Apache does not expose the REDIRECT_STATUS # environment variable, you have 2 choices: # - disable this feature by commenting the following 2 lines or # - use Apache >= 2.3.9 and replace all L flags by END flags and remove the # following RewriteCond (best solution) RewriteCond %{ENV:REDIRECT_STATUS} ="" RewriteRule ^index\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L] # If the requested filename exists, simply serve it. # We only want to let Apache serve files and not directories. # Rewrite all other queries to the front controller. RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^ %{ENV:BASE}/index.php [L] # When mod_rewrite is not available, we instruct a temporary redirect of # the start page to the front controller explicitly so that the website # and the generated links can still be used. RedirectMatch 307 ^/$ /index.php/ # RedirectTemp cannot be used instead ================================================ FILE: public/css/style.css ================================================ body { padding-top: calc(56px + 30px); -webkit-touch-callout: none; /* iOS Safari */ -webkit-user-select: none; /* Safari */ -khtml-user-select: none; /* Konqueror HTML */ -moz-user-select: none; /* Firefox */ -ms-user-select: none; /* Internet Explorer/Edge */ user-select: none; /* Non-prefixed version, currently supported by Chrome and Opera */ } /* Add a simple line below display headings */ .display-4 { font-size: 2.5rem; border-bottom: 1px solid var(--bs-border-color); } /* Flashes (messages) styles */ .flashes { position: fixed; top: 20px; right: 0; z-index: 1050; } .flashes .inner { position: absolute; top: 0; right: 20px; } .flashes .toast { min-width: 270px; } /* Little color swatch to show calendar color */ #calendar_instance_calendarColor_help { position: relative; } #calendar_instance_calendarColor_help::after { content: ""; width: 30px; height: 30px; position: absolute; top: -38px; right: 5px; background: var(--calendar-color); border-radius: 3px; border: 1px solid #AAA; } /* Special indicator badge */ .badge.badge-indicator { height: 15px; width: 15px; padding: 0; border-radius: 50%; text-decoration: none; color: var(--bs-heading-color); /* Comes from Bootstrap */ } /* Allow selection in the Bootstrap popover */ .popover .popover-body { user-select: text; } /* Github link icon */ .github-link { background-image: url("/images/github-mark.png"); background-repeat: no-repeat; background-size: 23px; height: 23px; width: 23px; } [data-bs-theme=dark] .github-link { background-image: url("/images/github-mark-white.png"); } ================================================ FILE: public/index.php ================================================ bootEnv($overridenEnvDir.'/.env'); } return function (array $context) { return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']); }; ================================================ FILE: public/js/app.js ================================================ 'use strict' // Calendar share modal const shareModal = document.getElementById('shareModal') if (shareModal) { shareModal.addEventListener('show.bs.modal', event => { // Button that triggered the modal const button = event.relatedTarget // Grab calendar shares url and add url let shareesUrl = button.getAttribute('data-sharees-href'); let targetUrl = button.getAttribute('data-href'); // When adding the sharee, catch the click to add the query parameter const addShareeButton = document.getElementById('shareModal-addSharee'); addShareeButton.addEventListener("click", function(e) { const writeAccess = document.getElementById('shareModal-writeAccess').checked ? 'true' : 'false'; const principalId = document.getElementById('shareModal-member').value; e.preventDefault() window.location = targetUrl + "?principalId=" + principalId + "&write=" + writeAccess }); const noneElement = document.getElementById('shareModal-none') // Shares list const shares = document.getElementById('shareModal-shares') shares.innerHTML = '' // Get calendar shares fetch(shareesUrl) .then((response) => response.json()) .then((data) => { // No sharee if (data.length === 0) { noneElement.classList.remove("d-none"); return } noneElement.classList.add('d-none') // Share list item template const template = document.getElementById("shareModal-shareeTemplate"); data.forEach(element => { const clone = template.content.cloneNode(true); let name = clone.querySelectorAll("span.name"); name[0].textContent = element.displayName; let badge = clone.querySelectorAll("span.badge"); badge[0].textContent = element.accessText; if (element.isWriteAccess) { badge[0].classList.add('bg-success') badge[0].classList.remove('bg-info') } let revokeButton = clone.querySelectorAll("a.revoke"); revokeButton[0].href = element.revokeUrl; shares.appendChild(clone); }); }); }) } // Delete modals (all kind of entities, so we use the rel, not the id) const deleteModals = document.querySelectorAll('[rel="deleteModal"]'); deleteModals.forEach(element => { element.addEventListener('show.bs.modal', event => { // Button that triggered the modal const button = event.relatedTarget // Grab real target url for deletion let targetUrl = button.getAttribute('data-href'); let modalFlavour = button.getAttribute('data-flavour'); // Put it into the modal's OK button const deleteCTA = document.getElementById(`deleteModal-${modalFlavour}-cta`); console.log("setting href to " + targetUrl) deleteCTA.setAttribute('href', targetUrl); }) }) // Global account delegation modal const addDelegateModal = document.getElementById('addDelegateModal') if (addDelegateModal) { addDelegateModal.addEventListener('show.bs.modal', event => { // When adding the sharee, catch the click to add the query parameter const addDelegateButton = document.getElementById('addDelegateModal-cta'); addDelegateButton.addEventListener("click", function(e) { const targetUrl = addDelegateButton.getAttribute('data-href'); const writeAccess = document.getElementById('addDelegateModal-writeAccess').checked ? 'true' : 'false'; const principalId = document.getElementById('addDelegateModal-member').value; e.preventDefault() window.location = targetUrl + "?principalId=" + principalId + "&write=" + writeAccess }); }) } // Color swatch: update it live (not working in IE ¯\_(ツ)_/¯ but it's just a nice to have) const colorPicker = document.getElementById('calendar_instance_calendarColor'); if (colorPicker) { colorPicker.addEventListener('keyup', event => { document.body.style.setProperty('--calendar-color', event.target.value); }) document.body.style.setProperty('--calendar-color', colorPicker.value); } // Bootstrap 5 popovers const popoverTriggerList = document.querySelectorAll('[data-bs-toggle="popover"]') if (popoverTriggerList) { [...popoverTriggerList].map(popoverTriggerEl => new bootstrap.Popover(popoverTriggerEl)) } // Bootstrap 5 toasts const toastElList = document.querySelectorAll('.toast') if (toastElList) { [...toastElList].map(toastEl => { const toast = new bootstrap.Toast(toastEl) toast.show() }) } ================================================ FILE: public/js/color.mode.toggler.js ================================================ /* Based on Bootstrap' color mode toggler */ /*! * Color mode toggler for Bootstrap's docs (https://getbootstrap.com/) * Copyright 2011-2023 The Bootstrap Authors * Licensed under the Creative Commons Attribution 3.0 Unported License. */ (() => { 'use strict' const getStoredTheme = () => localStorage.getItem('theme') const setStoredTheme = theme => localStorage.setItem('theme', theme) const getPreferredTheme = () => { const storedTheme = getStoredTheme() if (storedTheme) { return storedTheme } return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' } const setTheme = theme => { if (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) { document.documentElement.setAttribute('data-bs-theme', 'dark') } else { document.documentElement.setAttribute('data-bs-theme', theme) } } setTheme(getPreferredTheme()) const showActiveTheme = (theme, focus = false) => { const themeSwitcher = document.querySelector('#bd-theme') if (!themeSwitcher) { return } const themeSwitcherText = document.querySelector('#bd-theme-text') const activeThemeIcon = document.querySelector('.theme-icon-active') const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"] .theme-icon`) document.querySelectorAll('[data-bs-theme-value]').forEach(element => { element.classList.remove('active') element.setAttribute('aria-pressed', 'false') }) btnToActive.classList.add('active') btnToActive.setAttribute('aria-pressed', 'true') activeThemeIcon.innerHTML = btnToActive.innerHTML const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})` themeSwitcher.setAttribute('aria-label', themeSwitcherLabel) if (focus) { themeSwitcher.focus() } } window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { const storedTheme = getStoredTheme() if (storedTheme !== 'light' && storedTheme !== 'dark') { setTheme(getPreferredTheme()) } }) window.addEventListener('DOMContentLoaded', () => { showActiveTheme(getPreferredTheme()) document.querySelectorAll('[data-bs-theme-value]') .forEach(toggle => { toggle.addEventListener('click', () => { const theme = toggle.getAttribute('data-bs-theme-value') setStoredTheme(theme) setTheme(theme) showActiveTheme(theme, true) }) }) }) })() ================================================ FILE: public/robots.txt ================================================ User-agent: * Disallow: / ================================================ FILE: public/site.webmanifest ================================================ {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} ================================================ FILE: src/Command/ApiGenerateCommand.php ================================================ setName('api:generate') ->setDescription('Generate a new API key') ->setHelp('This command allows you to generate a new API key') ; } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $apiKey = bin2hex(random_bytes(32)); $io->success($apiKey); $io->warning('Set the API key in your .env file as API_KEY, as it won\'t be stored otherwise.'); return self::SUCCESS; } } ================================================ FILE: src/Command/SyncBirthdayCalendars.php ================================================ getManager(); $pdo = $em->getConnection()->getNativeConnection(); $this->birthdayService->setBackend(new CalendarBackend($pdo)); } protected function configure(): void { $this ->setName('dav:sync-birthday-calendar') ->setDescription('Synchronizes the birthday calendar') ->addArgument('username', InputArgument::OPTIONAL, 'Username for whom the birthday calendar will be synchronized'); } protected function execute(InputInterface $input, OutputInterface $output): int { $username = $input->getArgument('username'); if (!is_null($username)) { if (!$this->doctrine->getRepository(User::class)->findOneByUsername($username)) { throw new \InvalidArgumentException("User <$username> is unknown."); } $output->writeln("Start birthday calendar sync for $username"); $this->birthdayService->syncUser($username); return self::SUCCESS; } $output->writeln('Start birthday calendar sync for all users ...'); $p = new ProgressBar($output); $p->start(); $users = $this->doctrine->getRepository(User::class)->findAll(); foreach ($users as $user) { $p->advance(); $this->birthdayService->syncUser($user->getUsername()); } $p->finish(); $output->writeln(''); return self::SUCCESS; } } ================================================ FILE: src/Constants.php ================================================ getRepository(User::class)->findOneById($userId); if (!$user) { throw $this->createNotFoundException('User not found'); } $username = $user->getUsername(); $principalUri = Principal::PREFIX.$username; $principal = $doctrine->getRepository(Principal::class)->findOneByUri($principalUri); $addressbooks = $doctrine->getRepository(AddressBook::class)->findByPrincipalUri($principalUri); return $this->render('addressbooks/index.html.twig', [ 'addressbooks' => $addressbooks, 'principal' => $principal, 'userId' => $userId, ]); } #[Route('/{userId}/new', name: 'create')] #[Route('/{userId}/edit/{id}', name: 'edit', requirements: ['id' => "\d+"])] public function addressbookCreate(ManagerRegistry $doctrine, Request $request, int $userId, ?int $id, TranslatorInterface $trans, BirthdayService $birthdayService): Response { $user = $doctrine->getRepository(User::class)->findOneById($userId); if (!$user) { throw $this->createNotFoundException('User not found'); } $username = $user->getUsername(); $principalUri = Principal::PREFIX.$username; $principal = $doctrine->getRepository(Principal::class)->findOneByUri($principalUri); if (!$principal) { throw $this->createNotFoundException('User not found'); } if ($id) { $addressbook = $doctrine->getRepository(AddressBook::class)->findOneById($id); if (!$addressbook) { throw $this->createNotFoundException('Address book not found'); } } else { $addressbook = new AddressBook(); } $isBirthdayCalendarEnabled = $this->getParameter('caldav_enabled') && $this->getParameter('carddav_enabled'); $form = $this->createForm(AddressBookType::class, $addressbook, ['new' => !$id, 'birthday_calendar_enabled' => $isBirthdayCalendarEnabled]); if ($isBirthdayCalendarEnabled) { $form->get('includedInBirthdayCalendar')->setData($addressbook->isIncludedInBirthdayCalendar()); } $form->get('principalUri')->setData($principalUri); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $entityManager = $doctrine->getManager(); $entityManager->persist($addressbook); $entityManager->flush(); $this->addFlash('success', $trans->trans('addressbooks.saved')); if ($isBirthdayCalendarEnabled && true === $form->get('includedInBirthdayCalendar')->getData()) { $addressbook->setIncludedInBirthdayCalendar(true); } else { $addressbook->setIncludedInBirthdayCalendar(false); } if ($isBirthdayCalendarEnabled) { // Let's sync the user birthday calendar if needed $birthdayService->syncUser($username); } return $this->redirectToRoute('addressbook_index', ['userId' => $userId]); } return $this->render('addressbooks/edit.html.twig', [ 'form' => $form->createView(), 'principal' => $principal, 'userId' => $userId, 'addressbook' => $addressbook, ]); } #[Route('/{userId}/delete/{id}', name: 'delete', requirements: ['id' => "\d+"])] public function addressbookDelete(ManagerRegistry $doctrine, int $userId, string $id, TranslatorInterface $trans, BirthdayService $birthdayService): Response { $user = $doctrine->getRepository(User::class)->findOneById($userId); if (!$user) { throw $this->createNotFoundException('User not found'); } $addressbook = $doctrine->getRepository(AddressBook::class)->findOneById($id); if (!$addressbook) { throw $this->createNotFoundException('Address Book not found'); } $entityManager = $doctrine->getManager(); foreach ($addressbook->getCards() ?? [] as $card) { $entityManager->remove($card); } foreach ($addressbook->getChanges() ?? [] as $change) { $entityManager->remove($change); } $entityManager->remove($addressbook); $entityManager->flush(); $this->addFlash('success', $trans->trans('addressbooks.deleted')); $isBirthdayCalendarEnabled = $this->getParameter('caldav_enabled') && $this->getParameter('carddav_enabled'); if ($isBirthdayCalendarEnabled) { // Let's sync the user birthday calendar if needed $birthdayService->syncUser($user->getUsername()); } return $this->redirectToRoute('addressbook_index', ['userId' => $userId]); } } ================================================ FILE: src/Controller/Admin/CalendarController.php ================================================ getRepository(User::class)->findOneById($userId); if (!$user) { throw $this->createNotFoundException('User not found'); } $username = $user->getUsername(); $principalUri = Principal::PREFIX.$username; $principal = $doctrine->getRepository(Principal::class)->findOneByUri($principalUri); $allCalendars = $doctrine->getRepository(CalendarInstance::class)->findByPrincipalUri($principalUri); $subscriptions = $doctrine->getRepository(CalendarSubscription::class)->findByPrincipalUri($principalUri); // Separate shared calendars $calendars = []; $shared = []; $auto = []; foreach ($allCalendars as $calendar) { if ($calendar->isAutomaticallyGenerated()) { $auto[] = [ 'entity' => $calendar, 'uri' => $router->generate('dav', ['path' => 'calendars/'.$username.'/'.$calendar->getUri()], UrlGeneratorInterface::ABSOLUTE_URL), ]; } elseif (!$calendar->isShared()) { $calendars[] = [ 'entity' => $calendar, 'uri' => $router->generate('dav', ['path' => 'calendars/'.$username.'/'.$calendar->getUri()], UrlGeneratorInterface::ABSOLUTE_URL), ]; } else { $shared[] = [ 'entity' => $calendar, 'uri' => $router->generate('dav', ['path' => 'calendars/'.$username.'/'.$calendar->getUri()], UrlGeneratorInterface::ABSOLUTE_URL), ]; } } // We need all the other users so we can propose to share calendars with them $allPrincipalsExcept = $doctrine->getRepository(Principal::class)->findAllExceptPrincipal($principalUri); return $this->render('calendars/index.html.twig', [ 'calendars' => $calendars, 'subscriptions' => $subscriptions, 'shared' => $shared, 'auto' => $auto, 'principal' => $principal, 'userId' => $userId, 'allPrincipals' => $allPrincipalsExcept, ]); } #[Route('/{userId}/new', name: 'create')] #[Route('/{userId}/edit/{id}', name: 'edit', requirements: ['id' => "\d+"])] public function calendarEdit(ManagerRegistry $doctrine, Request $request, int $userId, ?int $id, TranslatorInterface $trans): Response { $user = $doctrine->getRepository(User::class)->findOneById($userId); if (!$user) { throw $this->createNotFoundException('User not found'); } $username = $user->getUsername(); $principalUri = Principal::PREFIX.$username; $principal = $doctrine->getRepository(Principal::class)->findOneByUri($principalUri); if (!$principal) { throw $this->createNotFoundException('User not found'); } if ($id) { $calendarInstance = $doctrine->getRepository(CalendarInstance::class)->findOneById($id); if (!$calendarInstance) { throw $this->createNotFoundException('Calendar not found'); } } else { $calendarInstance = new CalendarInstance(); $calendar = new Calendar(); $calendarInstance->setCalendar($calendar); } $arePublicCalendarsEnabled = $this->getParameter('public_calendars_enabled'); $form = $this->createForm(CalendarInstanceType::class, $calendarInstance, [ 'new' => !$id, 'shared' => $calendarInstance->isShared(), 'public_calendars_enabled' => $arePublicCalendarsEnabled, ]); $components = explode(',', $calendarInstance->getCalendar()->getComponents()); $form->get('events')->setData(in_array(Calendar::COMPONENT_EVENTS, $components)); $form->get('todos')->setData(in_array(Calendar::COMPONENT_TODOS, $components)); $form->get('notes')->setData(in_array(Calendar::COMPONENT_NOTES, $components)); $form->get('principalUri')->setData($principalUri); $form->handleRequest($request); $entityManager = $doctrine->getManager(); if ($form->isSubmitted() && $form->isValid()) { // Only owners can change those if (!$calendarInstance->isShared()) { $components = []; if ($form->get('events')->getData()) { $components[] = Calendar::COMPONENT_EVENTS; } if ($form->get('todos')->getData()) { $components[] = Calendar::COMPONENT_TODOS; } if ($form->get('notes')->getData()) { $components[] = Calendar::COMPONENT_NOTES; } if ($arePublicCalendarsEnabled && true === $form->get('public')->getData()) { $calendarInstance->setPublic(true); } else { $calendarInstance->setPublic(false); } $calendarInstance->getCalendar()->setComponents(implode(',', $components)); } // We want to remove all shares if a calendar goes public if ($arePublicCalendarsEnabled && true === $form->get('public')->getData() && $id) { $calendarId = $calendarInstance->getCalendar()->getId(); $instances = $doctrine->getRepository(CalendarInstance::class)->findSharedInstancesOfInstance($calendarId, false); foreach ($instances as $instance) { $entityManager->remove($instance); } } $entityManager->persist($calendarInstance); $entityManager->flush(); $this->addFlash('success', $trans->trans('calendar.saved')); return $this->redirectToRoute('calendar_index', ['userId' => $userId]); } return $this->render('calendars/edit.html.twig', [ 'form' => $form->createView(), 'principal' => $principal, 'userId' => $userId, 'calendar' => $calendarInstance, ]); } #[Route('/{userId}/shares/{calendarid}', name: 'shares', requirements: ['calendarid' => "\d+"])] public function calendarShares(ManagerRegistry $doctrine, int $userId, string $calendarid, TranslatorInterface $trans): Response { $instances = $doctrine->getRepository(CalendarInstance::class)->findSharedInstancesOfInstance($calendarid, true); $response = []; foreach ($instances as $instance) { $response[] = [ 'principalUri' => $instance[0]['principalUri'], 'displayName' => $instance['displayName'], 'email' => $instance['email'], 'accessText' => $trans->trans('calendar.share_access.'.$instance[0]['access']), 'isWriteAccess' => SharingPlugin::ACCESS_READWRITE === $instance[0]['access'], 'revokeUrl' => $this->generateUrl('calendar_revoke', ['userId' => $userId, 'id' => $instance[0]['id']]), ]; } return new JsonResponse($response); } #[Route('/{userId}/share/{instanceid}', name: 'share_add', requirements: ['instanceid' => "\d+"])] public function calendarShareAdd(ManagerRegistry $doctrine, Request $request, int $userId, string $instanceid, TranslatorInterface $trans): Response { $instance = $doctrine->getRepository(CalendarInstance::class)->findOneById($instanceid); if (!$instance) { throw $this->createNotFoundException('Calendar not found'); } if (!is_numeric($request->get('principalId'))) { throw new BadRequestHttpException(); } $newShareeToAdd = $doctrine->getRepository(Principal::class)->findOneById($request->get('principalId')); if (!$newShareeToAdd) { throw $this->createNotFoundException('Member not found'); } // Let's check that there wasn't another instance // already existing first, so we can update it: $existingSharedInstance = $doctrine->getRepository(CalendarInstance::class)->findSharedInstanceOfInstanceFor($instance->getCalendar()->getId(), $newShareeToAdd->getUri()); $writeAccess = ('true' === $request->get('write') ? SharingPlugin::ACCESS_READWRITE : SharingPlugin::ACCESS_READ); $entityManager = $doctrine->getManager(); if ($existingSharedInstance) { $existingSharedInstance->setAccess($writeAccess); } else { $sharedInstance = new CalendarInstance(); $sharedInstance->setTransparent(1) ->setCalendar($instance->getCalendar()) ->setShareHref('mailto:'.$newShareeToAdd->getEmail()) ->setDescription($instance->getDescription()) ->setDisplayName($instance->getDisplayName()) ->setUri(\Sabre\DAV\UUIDUtil::getUUID()) ->setPrincipalUri($newShareeToAdd->getUri()) ->setAccess($writeAccess); $entityManager->persist($sharedInstance); } $entityManager->flush(); $this->addFlash('success', $trans->trans('calendar.shared')); return $this->redirectToRoute('calendar_index', ['userId' => $userId]); } #[Route('/{userId}/delete/{id}', name: 'delete', requirements: ['id' => "\d+"])] public function calendarDelete(ManagerRegistry $doctrine, int $userId, string $id, TranslatorInterface $trans): Response { $user = $doctrine->getRepository(User::class)->findOneById($userId); if (!$user) { throw $this->createNotFoundException('User not found'); } $principalUri = Principal::PREFIX.$user->getUsername(); $instance = $doctrine->getRepository(CalendarInstance::class)->findOneById($id); if (!$instance) { throw $this->createNotFoundException('Calendar not found'); } $entityManager = $doctrine->getManager(); // Scheduling objects attached to the calendar objects of the calendar $schedulingObjectsOfCalendarObjects = $doctrine->getRepository(CalendarInstance::class)->findAllSchedulingObjectsForCalendar($instance->getId(), $principalUri); foreach ($schedulingObjectsOfCalendarObjects ?? [] as $object) { $entityManager->remove($object); } foreach ($instance->getCalendar()->getObjects() ?? [] as $object) { $entityManager->remove($object); } foreach ($instance->getCalendar()->getChanges() ?? [] as $change) { $entityManager->remove($change); } // Remove the original calendar instance $entityManager->remove($instance); // Remove shared instances $sharedInstances = $doctrine->getRepository(CalendarInstance::class)->findSharedInstancesOfInstance($instance->getCalendar()->getId(), false); foreach ($sharedInstances as $sharedInstance) { $entityManager->remove($sharedInstance); } // Finally remove the calendar itself $entityManager->remove($instance->getCalendar()); $entityManager->flush(); $this->addFlash('success', $trans->trans('calendar.deleted')); return $this->redirectToRoute('calendar_index', ['userId' => $userId]); } #[Route('/{userId}/revoke/{id}', name: 'revoke', requirements: ['id' => "\d+"])] public function calendarRevoke(ManagerRegistry $doctrine, int $userId, string $id, TranslatorInterface $trans): Response { $instance = $doctrine->getRepository(CalendarInstance::class)->findOneById($id); if (!$instance) { throw $this->createNotFoundException('Calendar not found'); } $entityManager = $doctrine->getManager(); $entityManager->remove($instance); $entityManager->flush(); $this->addFlash('success', $trans->trans('calendar.revoked')); return $this->redirectToRoute('calendar_index', ['userId' => $userId]); } } ================================================ FILE: src/Controller/Admin/DashboardController.php ================================================ getRepository(User::class)->findAll(); $calendars = $doctrine->getRepository(CalendarInstance::class)->findAll(); $addressbooks = $doctrine->getRepository(AddressBook::class)->findAll(); $events = $doctrine->getRepository(CalendarObject::class)->findAll(); $contacts = $doctrine->getRepository(Card::class)->findAll(); $timezoneParameter = $this->getParameter('timezone'); return $this->render('dashboard.html.twig', [ 'users' => $users, 'calendars' => $calendars, 'addressbooks' => $addressbooks, 'events' => $events, 'contacts' => $contacts, 'timezone' => [ 'actual_default' => date_default_timezone_get(), 'not_set_in_app' => '' === $timezoneParameter, 'bad_value' => '' !== $timezoneParameter && !in_array($timezoneParameter, \DateTimeZone::listIdentifiers()), ], 'version' => \App\Version::VERSION, 'sabredav_version' => \Sabre\DAV\Version::VERSION, ]); } } ================================================ FILE: src/Controller/Admin/UserController.php ================================================ getRepository(Principal::class)->findAllMainPrincipalsWithUserIds(); return $this->render('users/index.html.twig', [ 'results' => $results, ]); } #[Route('/new', name: 'create')] #[Route('/edit/{userId}', name: 'edit')] public function userCreate(ManagerRegistry $doctrine, Utils $utils, Request $request, ?int $userId, TranslatorInterface $trans): Response { if ($userId) { $user = $doctrine->getRepository(User::class)->findOneById($userId); if (!$user) { throw $this->createNotFoundException('User not found'); } $oldHash = $user->getPassword(); $principal = $doctrine->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$user->getUsername()); } else { $user = new User(); $principal = new Principal(); } $form = $this->createForm(UserType::class, $user, ['new' => !$userId]); $form->get('displayName')->setData($principal->getDisplayName()); $form->get('email')->setData($principal->getEmail()); $form->get('isAdmin')->setData($principal->getIsAdmin()); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $displayName = $form->get('displayName')->getData(); $email = $form->get('email')->getData(); $isAdmin = $form->get('isAdmin')->getData(); // Create password for user if ($userId && is_null($user->getPassword())) { // The user is not new and does not want to change its password $user->setPassword($oldHash); } else { $hash = password_hash($user->getPassword(), PASSWORD_DEFAULT); $user->setPassword($hash); } $entityManager = $doctrine->getManager(); // If it's a new user, create default calendar and address book, and principal if (null === $user->getId()) { $principal->setUri(Principal::PREFIX.$user->getUsername()); $calendarInstance = new CalendarInstance(); $calendar = new Calendar(); $calendarInstance->setPrincipalUri(Principal::PREFIX.$user->getUsername()) ->setUri('default') // No risk of collision since unicity is guaranteed by the new user principal ->setDisplayName($trans->trans('default.calendar.title')) ->setDescription($trans->trans('default.calendar.description', ['user' => $displayName])) ->setCalendar($calendar); // Enable delegation by default $principalProxyRead = new Principal(); $principalProxyRead->setUri($principal->getUri().Principal::READ_PROXY_SUFFIX) ->setIsMain(false); $entityManager->persist($principalProxyRead); $principalProxyWrite = new Principal(); $principalProxyWrite->setUri($principal->getUri().Principal::WRITE_PROXY_SUFFIX) ->setIsMain(false); $entityManager->persist($principalProxyWrite); $addressbook = new AddressBook(); $addressbook->setPrincipalUri(Principal::PREFIX.$user->getUsername()) ->setUri('default') // No risk of collision since unicity is guaranteed by the new user principal ->setDisplayName($trans->trans('default.addressbook.title')) ->setDescription($trans->trans('default.addressbook.description', ['user' => $displayName])); $entityManager->persist($calendarInstance); $entityManager->persist($addressbook); $entityManager->persist($principal); } $principal->setDisplayName($displayName) ->setEmail($email) ->setIsAdmin($isAdmin); $entityManager->persist($user); $entityManager->flush(); $this->addFlash('success', $trans->trans('user.saved')); return $this->redirectToRoute('user_index'); } return $this->render('users/edit.html.twig', [ 'form' => $form->createView(), 'userId' => $userId, 'username' => $user->getUsername(), ]); } #[Route('/delete/{userId}', name: 'delete')] public function userDelete(ManagerRegistry $doctrine, int $userId, TranslatorInterface $trans): Response { $user = $doctrine->getRepository(User::class)->findOneById($userId); if (!$user) { throw $this->createNotFoundException('User not found'); } $entityManager = $doctrine->getManager(); $entityManager->remove($user); $principal = $doctrine->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$user->getUsername()); $principalProxyRead = $doctrine->getRepository(Principal::class)->findOneByUri($principal->getUri().Principal::READ_PROXY_SUFFIX); $principalProxyWrite = $doctrine->getRepository(Principal::class)->findOneByUri($principal->getUri().Principal::WRITE_PROXY_SUFFIX); $entityManager->remove($principal); $entityManager->remove($principalProxyRead); $entityManager->remove($principalProxyWrite); $principalUri = Principal::PREFIX.$user->getUsername(); // Remove calendars and addressbooks $calendars = $doctrine->getRepository(CalendarInstance::class)->findByPrincipalUri($principalUri); foreach ($calendars ?? [] as $instance) { // We're only removing the calendar objects / changes / and calendar if the deleted user is an owner, // which means that the underlying calendar instance should not have another principal as owner. $hasDifferentOwner = $doctrine->getRepository(CalendarInstance::class)->hasDifferentOwner($instance->getCalendar()->getId(), $principalUri); if (!$hasDifferentOwner) { foreach ($instance->getCalendar()->getObjects() ?? [] as $object) { $entityManager->remove($object); } foreach ($instance->getCalendar()->getChanges() ?? [] as $change) { $entityManager->remove($change); } // We need to remove the shared versions of this calendar, too foreach ($instance->getCalendar()->getInstances() ?? [] as $instances) { $entityManager->remove($instances); } $entityManager->remove($instance->getCalendar()); } $entityManager->remove($instance); } $calendarsSubscriptions = $doctrine->getRepository(CalendarSubscription::class)->findByPrincipalUri($principalUri); foreach ($calendarsSubscriptions ?? [] as $subscription) { $entityManager->remove($subscription); } $schedulingObjects = $doctrine->getRepository(SchedulingObject::class)->findByPrincipalUri($principalUri); foreach ($schedulingObjects ?? [] as $object) { $entityManager->remove($object); } $addressbooks = $doctrine->getRepository(AddressBook::class)->findByPrincipalUri($principalUri); foreach ($addressbooks ?? [] as $addressbook) { foreach ($addressbook->getCards() ?? [] as $card) { $entityManager->remove($card); } foreach ($addressbook->getChanges() ?? [] as $change) { $entityManager->remove($change); } $entityManager->remove($addressbook); } $entityManager->flush(); $this->addFlash('success', $trans->trans('user.deleted')); return $this->redirectToRoute('user_index'); } #[Route('/delegates/{userId}', name: 'delegates')] public function userDelegates(ManagerRegistry $doctrine, int $userId): Response { $user = $doctrine->getRepository(User::class)->findOneById($userId); if (!$user) { throw $this->createNotFoundException('User not found'); } $principalUri = Principal::PREFIX.$user->getUsername(); $principal = $doctrine->getRepository(Principal::class)->findOneByUri($principalUri); $allPrincipalsExcept = $doctrine->getRepository(Principal::class)->findAllExceptPrincipal($principalUri); // Get delegates. They are not linked to the principal in itself, but to its proxies $principalProxyRead = $doctrine->getRepository(Principal::class)->findOneByUri($principal->getUri().Principal::READ_PROXY_SUFFIX); $principalProxyWrite = $doctrine->getRepository(Principal::class)->findOneByUri($principal->getUri().Principal::WRITE_PROXY_SUFFIX); return $this->render('users/delegates.html.twig', [ 'principal' => $principal, 'userId' => $userId, 'delegation' => $principalProxyRead && $principalProxyWrite, 'principalProxyRead' => $principalProxyRead, 'principalProxyWrite' => $principalProxyWrite, 'allPrincipals' => $allPrincipalsExcept, ]); } #[Route('/delegation/{userId}/{toggle}', name: 'delegation_toggle', requirements: ['toggle' => '(on|off)'])] public function userToggleDelegation(ManagerRegistry $doctrine, int $userId, string $toggle): Response { $user = $doctrine->getRepository(User::class)->findOneById($userId); if (!$user) { throw $this->createNotFoundException('User not found'); } $principalUri = Principal::PREFIX.$user->getUsername(); $principal = $doctrine->getRepository(Principal::class)->findOneByUri($principalUri); if (!$principal) { throw $this->createNotFoundException('Principal not found'); } $entityManager = $doctrine->getManager(); if ('on' === $toggle) { $principalProxyRead = new Principal(); $principalProxyRead->setUri($principal->getUri().Principal::READ_PROXY_SUFFIX) ->setIsMain(false); $entityManager->persist($principalProxyRead); $principalProxyWrite = new Principal(); $principalProxyWrite->setUri($principal->getUri().Principal::WRITE_PROXY_SUFFIX) ->setIsMain(false); $entityManager->persist($principalProxyWrite); } else { $principalProxyRead = $doctrine->getRepository(Principal::class)->findOneByUri($principal->getUri().Principal::READ_PROXY_SUFFIX); $principalProxyRead && $entityManager->remove($principalProxyRead); $principalProxyWrite = $doctrine->getRepository(Principal::class)->findOneByUri($principal->getUri().Principal::WRITE_PROXY_SUFFIX); $principalProxyWrite && $entityManager->remove($principalProxyWrite); // Remove also delegates $principal->removeAllDelegees(); } $entityManager->flush(); return $this->redirectToRoute('user_delegates', ['userId' => $userId]); } #[Route('/delegates/{userId}/add', name: 'delegate_add')] public function userDelegateAdd(ManagerRegistry $doctrine, Request $request, int $userId): Response { if (!is_numeric($request->get('principalId'))) { throw new BadRequestHttpException(); } $user = $doctrine->getRepository(User::class)->findOneById($userId); if (!$user) { throw $this->createNotFoundException('User not found'); } $principalUri = Principal::PREFIX.$user->getUsername(); $newMemberToAdd = $doctrine->getRepository(Principal::class)->findOneById($request->get('principalId')); if (!$newMemberToAdd) { throw $this->createNotFoundException('Member not found'); } // Depending on write access or not, attach to the correct principal if ('true' === $request->get('write')) { // Let's check that there wasn't a read proxy first $principalProxyRead = $doctrine->getRepository(Principal::class)->findOneByUri($principalUri.Principal::READ_PROXY_SUFFIX); if (!$principalProxyRead) { throw $this->createNotFoundException('Principal linked to this calendar not found'); } $principalProxyRead->removeDelegee($newMemberToAdd); // And then add the Write access $principal = $doctrine->getRepository(Principal::class)->findOneByUri($principalUri.Principal::WRITE_PROXY_SUFFIX); } else { $principal = $doctrine->getRepository(Principal::class)->findOneByUri($principalUri.Principal::READ_PROXY_SUFFIX); } if (!$principal) { throw $this->createNotFoundException('Principal linked to this calendar not found'); } $principal->addDelegee($newMemberToAdd); $entityManager = $doctrine->getManager(); $entityManager->flush(); return $this->redirectToRoute('user_delegates', ['userId' => $userId]); } #[Route('/delegates/{userId}/remove/{principalProxyId}/{delegateId}', name: 'delegate_remove', requirements: ['principalProxyId' => "\d+", 'delegateId' => "\d+"])] public function userDelegateRemove(ManagerRegistry $doctrine, Request $request, int $userId, int $principalProxyId, int $delegateId): Response { $user = $doctrine->getRepository(User::class)->findOneById($userId); if (!$user) { throw $this->createNotFoundException('User not found'); } $principalProxy = $doctrine->getRepository(Principal::class)->findOneById($principalProxyId); if (!$principalProxy) { throw $this->createNotFoundException('Principal linked to this calendar not found'); } $memberToRemove = $doctrine->getRepository(Principal::class)->findOneById($delegateId); if (!$memberToRemove) { throw $this->createNotFoundException('Member not found'); } $principalProxy->removeDelegee($memberToRemove); $entityManager = $doctrine->getManager(); $entityManager->flush(); return $this->redirectToRoute('user_delegates', ['userId' => $userId]); } } ================================================ FILE: src/Controller/Api/ApiController.php ================================================ getRepository(User::class)->findOneById($userId); } /** * Health check endpoint. * * @param Request $request The HTTP GET request * * @return JsonResponse A JSON response indicating the health status */ #[Route('/health', name: 'health', methods: ['GET'])] public function healthCheck(Request $request): JsonResponse { return $this->json(['status' => 'OK', 'timestamp' => $this->getTimestamp()], 200); } /** * Retrieves a list of users (with their user_id, principal_id, uri, username, and displayname). * * @param Request $request The HTTP GET request * * @return JsonResponse A JSON response containing the list of users */ #[Route('/users', name: 'users', methods: ['GET'])] public function getUsers(Request $request, ManagerRegistry $doctrine): JsonResponse { $results = $doctrine->getRepository(Principal::class)->findAllMainPrincipalsWithUserIds(); $users = []; foreach ($results as $result) { $principal = $result[0]; $users[] = [ 'user_id' => $result['userId'], 'principal_id' => $principal->getId(), 'uri' => $principal->getUri(), 'username' => $principal->getUsername(), ]; } $response = [ 'status' => 'success', 'data' => $users, 'timestamp' => $this->getTimestamp(), ]; return $this->json($response, 200); } /** * Retrieves details of a specific user (user_id, principal_id, uri, username, displayname, email). * * @param Request $request The HTTP GET request * @param int $userId The ID of the user whose details are to be retrieved * * @return JsonResponse A JSON response containing the user details */ #[Route('/users/{userId}', name: 'user_detail', methods: ['GET'], requirements: ['userId' => '\d+'])] public function getUserDetails(Request $request, ManagerRegistry $doctrine, int $userId): JsonResponse { $user = $this->resolveUser($doctrine, $userId); if (!$user) { return $this->json(['status' => 'error', 'message' => 'User Not Found', 'timestamp' => $this->getTimestamp()], 404); } $principal = $doctrine->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$user->getUsername()); if (!$principal) { return $this->json(['status' => 'error', 'message' => 'Principal Not Found', 'timestamp' => $this->getTimestamp()], 404); } $data = [ 'user_id' => $user->getId(), 'principal_id' => $principal->getId(), 'uri' => $principal->getUri(), 'username' => $principal->getUsername(), 'displayname' => $principal->getDisplayName(), 'email' => $principal->getEmail(), ]; $response = [ 'status' => 'success', 'data' => $data, 'timestamp' => $this->getTimestamp(), ]; return $this->json($response, 200); } /** * Retrieves a list of calendars for a specific user, including user calendars, shared calendars, and subscriptions. * * @param Request $request The HTTP GET request * @param int $userId The ID of the user whose calendars are to be retrieved * * @return JsonResponse A JSON response containing the list of calendars for the specified user */ #[Route('/calendars/{userId}', name: 'calendars', methods: ['GET'], requirements: ['userId' => '\d+'])] public function getUserCalendars(Request $request, int $userId, ManagerRegistry $doctrine): JsonResponse { $user = $this->resolveUser($doctrine, $userId); if (!$user) { return $this->json(['status' => 'error', 'message' => 'User Not Found', 'timestamp' => $this->getTimestamp()], 404); } $principalUri = Principal::PREFIX.$user->getUsername(); if (!$doctrine->getRepository(Principal::class)->findOneByUri($principalUri)) { return $this->json(['status' => 'error', 'message' => 'Principal Not Found', 'timestamp' => $this->getTimestamp()], 404); } $allCalendars = $doctrine->getRepository(CalendarInstance::class)->findByPrincipalUri($principalUri); $allSubscriptions = $doctrine->getRepository(CalendarSubscription::class)->findByPrincipalUri($principalUri); $calendars = []; $sharedCalendars = []; foreach ($allCalendars as $calendar) { $objectCounts = $doctrine->getRepository(CalendarInstance::class)->getObjectCountsByComponentType($calendar->getCalendar()->getId()); $eventsCount = $calendar->getCalendar()->isComponentEnabled(Calendar::COMPONENT_EVENTS) ? $objectCounts['events'] : null; $notesCount = $calendar->getCalendar()->isComponentEnabled(Calendar::COMPONENT_NOTES) ? $objectCounts['notes'] : null; $tasksCount = $calendar->getCalendar()->isComponentEnabled(Calendar::COMPONENT_TODOS) ? $objectCounts['tasks'] : null; $calendarData = [ 'id' => $calendar->getId(), 'uri' => $calendar->getUri(), 'displayname' => $calendar->getDisplayName(), 'events' => $eventsCount, 'notes' => $notesCount, 'tasks' => $tasksCount, ]; if (!$calendar->isShared()) { $calendars[] = $calendarData; } else { $sharedCalendars[] = $calendarData; } } $subscriptions = []; foreach ($allSubscriptions as $subscription) { $objectCounts = $doctrine->getRepository(CalendarInstance::class)->getObjectCountsByComponentType($subscription->getCalendar()->getId()); $eventsCount = $subscription->getCalendar()->isComponentEnabled(Calendar::COMPONENT_EVENTS) ? $objectCounts['events'] : null; $notesCount = $subscription->getCalendar()->isComponentEnabled(Calendar::COMPONENT_NOTES) ? $objectCounts['notes'] : null; $tasksCount = $subscription->getCalendar()->isComponentEnabled(Calendar::COMPONENT_TODOS) ? $objectCounts['tasks'] : null; $subscriptions[] = [ 'id' => $subscription->getId(), 'uri' => $subscription->getUri(), 'displayname' => $subscription->getDisplayName(), 'events' => $eventsCount, 'notes' => $notesCount, 'tasks' => $tasksCount, ]; } $response = [ 'status' => 'success', 'data' => [ 'user_calendars' => $calendars, 'shared_calendars' => $sharedCalendars, 'subscriptions' => $subscriptions, ], 'timestamp' => $this->getTimestamp(), ]; return $this->json($response, 200); } /** * Retrieves details of a specific calendar for a specific user (id, uri, displayname, description, number of events, notes, and tasks). * * @param Request $request The HTTP GET request * @param int $userId The ID of the user whose calendar details are to be retrieved * @param int $calendar_id The ID of the calendar whose details are to be retrieved * * @return JsonResponse A JSON response containing the calendar details */ #[Route('/calendars/{userId}/{calendar_id}', name: 'calendar_details', methods: ['GET'], requirements: ['calendar_id' => '\d+', 'userId' => '\d+'])] public function getUserCalendarDetails(Request $request, int $userId, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { $user = $this->resolveUser($doctrine, $userId); if (!$user) { return $this->json(['status' => 'error', 'message' => 'User Not Found', 'timestamp' => $this->getTimestamp()], 404); } $principalUri = Principal::PREFIX.$user->getUsername(); if (!$doctrine->getRepository(Principal::class)->findOneByUri($principalUri)) { return $this->json(['status' => 'error', 'message' => 'Principal Not Found', 'timestamp' => $this->getTimestamp()], 404); } $allCalendars = $doctrine->getRepository(CalendarInstance::class)->findByPrincipalUri($principalUri); $calendar_details = []; foreach ($allCalendars as $calendar) { if (!$calendar->isShared() && $calendar->getId() === $calendar_id) { $objectCounts = $doctrine->getRepository(CalendarInstance::class)->getObjectCountsByComponentType($calendar->getCalendar()->getId()); $calendar_details = [ 'id' => $calendar->getId(), 'uri' => $calendar->getUri(), 'displayname' => $calendar->getDisplayName(), 'description' => $calendar->getDescription(), 'events' => [ 'enabled' => $calendar->getCalendar()->isComponentEnabled(Calendar::COMPONENT_EVENTS), 'count' => $objectCounts['events'], ], 'notes' => [ 'enabled' => $calendar->getCalendar()->isComponentEnabled(Calendar::COMPONENT_NOTES), 'count' => $objectCounts['notes'], ], 'tasks' => [ 'enabled' => $calendar->getCalendar()->isComponentEnabled(Calendar::COMPONENT_TODOS), 'count' => $objectCounts['tasks'], ], ]; } } $response = [ 'status' => 'success', 'data' => $calendar_details, 'timestamp' => $this->getTimestamp(), ]; return $this->json($response, 200); } /** * Creates a new calendar for a specific user. * * @param Request $request The HTTP POST request * @param int $userId The ID of the user for whom the calendar is to be created * * @return JsonResponse A JSON response indicating the success or failure of the operation */ #[Route('/calendars/{userId}/create', name: 'calendar_create', methods: ['POST'], requirements: ['userId' => '\d+'])] public function createNewUserCalendar(Request $request, int $userId, ManagerRegistry $doctrine): JsonResponse { $user = $this->resolveUser($doctrine, $userId); if (!$user) { return $this->json(['status' => 'error', 'message' => 'User Not Found', 'timestamp' => $this->getTimestamp()], 404); } $principalUri = Principal::PREFIX.$user->getUsername(); if (!$doctrine->getRepository(Principal::class)->findOneByUri($principalUri)) { return $this->json(['status' => 'error', 'message' => 'Principal Not Found', 'timestamp' => $this->getTimestamp()], 404); } // Parse JSON body $data = json_decode($request->getContent(), true); if (JSON_ERROR_NONE !== json_last_error()) { return $this->json(['status' => 'error', 'message' => 'Invalid JSON', 'timestamp' => $this->getTimestamp()], 400); } $calendarName = $data['name'] ?? null; if (empty($calendarName) || 1 !== preg_match('/^[a-zA-Z0-9 ._-]{1,64}$/', $calendarName)) { return $this->json(['status' => 'error', 'message' => 'Invalid Calendar Name', 'timestamp' => $this->getTimestamp()], 400); } $calendarURI = $data['uri'] ?? null; if (empty($calendarURI) || 1 !== preg_match('/^[a-z0-9_-]{1,128}$/', $calendarURI)) { return $this->json(['status' => 'error', 'message' => 'Invalid Calendar URI', 'timestamp' => $this->getTimestamp()], 400); } $uriCheck = $doctrine->getRepository(CalendarInstance::class)->findOneBy([ 'principalUri' => $principalUri, 'uri' => $calendarURI, ]); if ($uriCheck) { return $this->json(['status' => 'error', 'message' => 'Calendar URI Already Exists', 'timestamp' => $this->getTimestamp()], 400); } $calendarDescription = $data['description'] ?? ''; if (!empty($calendarDescription) && 1 !== preg_match('/^[a-zA-Z0-9 ._-]{1,256}$/', $calendarDescription)) { return $this->json(['status' => 'error', 'message' => 'Invalid Calendar Description', 'timestamp' => $this->getTimestamp()], 400); } $entityManager = $doctrine->getManager(); $calendarInstance = new CalendarInstance(); $calendar = new Calendar(); $calendarInstance->setCalendar($calendar); $calendarComponents = []; $eventsSupport = $data['events_support'] ?? true; if (true === $eventsSupport || 'true' === $eventsSupport) { $calendarComponents[] = Calendar::COMPONENT_EVENTS; } $notesSupport = $data['notes_support'] ?? false; if (true === $notesSupport || 'true' === $notesSupport) { $calendarComponents[] = Calendar::COMPONENT_NOTES; } $tasksSupport = $data['tasks_support'] ?? false; if (true === $tasksSupport || 'true' === $tasksSupport) { $calendarComponents[] = Calendar::COMPONENT_TODOS; } // Validate that at least one component is selected if (empty($calendarComponents)) { return $this->json(['status' => 'error', 'message' => 'At least one calendar component must be enabled (events, notes, or tasks)', 'timestamp' => $this->getTimestamp()], 400); } $calendar->setComponents(implode(',', $calendarComponents)); try { $calendarInstance ->setCalendar($calendar) ->setAccess(SharingPlugin::ACCESS_SHAREDOWNER) ->setDescription($calendarDescription) ->setDisplayName($calendarName) ->setUri($calendarURI) ->setPrincipalUri($principalUri); $entityManager->persist($calendarInstance); $entityManager->flush(); } catch (\Exception $e) { return $this->json(['status' => 'error', 'message' => 'Failed to Create Calendar', 'timestamp' => $this->getTimestamp()], 500); } $response = [ 'status' => 'success', 'data' => [ 'calendar_id' => $calendarInstance->getId(), 'calendar_uri' => $calendarInstance->getUri(), ], 'timestamp' => $this->getTimestamp(), ]; return $this->json($response, 200); } /** * Edits an existing calendar for a specific user. * * @param Request $request The HTTP POST request * @param int $userId The ID of the user whose calendar is to be edited * @param int $calendar_id The ID of the calendar to be edited * * @return JsonResponse A JSON response indicating the success or failure of the operation */ #[Route('/calendars/{userId}/{calendar_id}', name: 'calendar_edit', methods: ['PUT', 'PATCH'], requirements: ['calendar_id' => '\d+', 'userId' => '\d+'])] public function editUserCalendar(Request $request, int $userId, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { $user = $this->resolveUser($doctrine, $userId); if (!$user) { return $this->json(['status' => 'error', 'message' => 'User Not Found', 'timestamp' => $this->getTimestamp()], 404); } $principalUri = Principal::PREFIX.$user->getUsername(); if (!$doctrine->getRepository(Principal::class)->findOneByUri($principalUri)) { return $this->json(['status' => 'error', 'message' => 'Principal Not Found', 'timestamp' => $this->getTimestamp()], 404); } $ownerInstance = $doctrine->getRepository(CalendarInstance::class)->findOneBy([ 'id' => $calendar_id, 'principalUri' => $principalUri, ]); if (!$ownerInstance) { return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID', 'timestamp' => $this->getTimestamp()], 400); } $calendarInstance = $doctrine->getRepository(CalendarInstance::class)->findOneById($calendar_id); if (!$calendarInstance) { return $this->json(['status' => 'error', 'message' => 'Calendar Instance Not Found', 'timestamp' => $this->getTimestamp()], 404); } // Parse JSON body $data = json_decode($request->getContent(), true); if (JSON_ERROR_NONE !== json_last_error()) { return $this->json(['status' => 'error', 'message' => 'Invalid JSON', 'timestamp' => $this->getTimestamp()], 400); } $calendarName = $data['name'] ?? null; if (empty($calendarName) || 1 !== preg_match('/^[a-zA-Z0-9 ._-]{1,64}$/', $calendarName)) { return $this->json(['status' => 'error', 'message' => 'Invalid Calendar Name', 'timestamp' => $this->getTimestamp()], 400); } $calendarDescription = $data['description'] ?? ''; if (!empty($calendarDescription) && 1 !== preg_match('/^[a-zA-Z0-9 ._-]{1,256}$/', $calendarDescription)) { return $this->json(['status' => 'error', 'message' => 'Invalid Calendar Description', 'timestamp' => $this->getTimestamp()], 400); } $entityManager = $doctrine->getManager(); $calendarInstance->setDisplayName($calendarName); $calendarInstance->setDescription($calendarDescription); $calendarComponents = []; $eventsSupport = $data['events_support'] ?? true; if (true === $eventsSupport || 'true' === $eventsSupport) { $calendarComponents[] = Calendar::COMPONENT_EVENTS; } $notesSupport = $data['notes_support'] ?? false; if (true === $notesSupport || 'true' === $notesSupport) { $calendarComponents[] = Calendar::COMPONENT_NOTES; } $tasksSupport = $data['tasks_support'] ?? false; if (true === $tasksSupport || 'true' === $tasksSupport) { $calendarComponents[] = Calendar::COMPONENT_TODOS; } // Validate that at least one component is selected if (empty($calendarComponents)) { return $this->json(['status' => 'error', 'message' => 'At least one calendar component must be enabled (events, notes, or tasks)', 'timestamp' => $this->getTimestamp()], 400); } $calendarInstance->getCalendar()->setComponents(implode(',', $calendarComponents)); try { $entityManager->persist($calendarInstance); $entityManager->flush(); } catch (\Exception $e) { return $this->json(['status' => 'error', 'message' => 'Failed to Edit Calendar', 'timestamp' => $this->getTimestamp()], 500); } return $this->json(['status' => 'success', 'timestamp' => $this->getTimestamp()], 200); } /** * Deletes a specific calendar for a specific user. * * @param Request $request The HTTP POST request * @param int $userId The ID of the user whose calendar is to be deleted * @param int $calendar_id The ID of the calendar to be deleted * * @return JsonResponse A JSON response indicating the success or failure of the operation */ #[Route('/calendars/{userId}/{calendar_id}', name: 'calendar_delete', methods: ['DELETE'], requirements: ['calendar_id' => '\d+', 'userId' => '\d+'])] public function deleteUserCalendar(Request $request, int $userId, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { $user = $this->resolveUser($doctrine, $userId); if (!$user) { return $this->json(['status' => 'error', 'message' => 'User Not Found', 'timestamp' => $this->getTimestamp()], 404); } $principalUri = Principal::PREFIX.$user->getUsername(); if (!$doctrine->getRepository(Principal::class)->findOneByUri($principalUri)) { return $this->json(['status' => 'error', 'message' => 'Principal Not Found', 'timestamp' => $this->getTimestamp()], 404); } $instance = $doctrine->getRepository(CalendarInstance::class)->findOneBy([ 'id' => $calendar_id, 'principalUri' => $principalUri, ]); if (!$instance) { return $this->json(['status' => 'error', 'message' => 'Invalid Instance Not Found', 'timestamp' => $this->getTimestamp()], 400); } try { $entityManager = $doctrine->getManager(); $calendarsSubscriptions = $doctrine->getRepository(CalendarSubscription::class)->findByPrincipalUri($instance->getPrincipalUri()); $schedulingObjects = $doctrine->getRepository(SchedulingObject::class)->findByPrincipalUri($instance->getPrincipalUri()); // Remove calendar objects foreach ($calendarsSubscriptions ?? [] as $subscription) { $entityManager->remove($subscription); } foreach ($schedulingObjects ?? [] as $object) { $entityManager->remove($object); } foreach ($instance->getCalendar()->getObjects() ?? [] as $object) { $entityManager->remove($object); } foreach ($instance->getCalendar()->getChanges() ?? [] as $change) { $entityManager->remove($change); } // Remove the original calendar instance $entityManager->remove($instance); // Remove shared instances of the calendar $sharedInstances = $doctrine->getRepository(CalendarInstance::class)->findSharedInstancesOfInstance($instance->getCalendar()->getId(), false); foreach ($sharedInstances as $sharedInstance) { $entityManager->remove($sharedInstance); } $entityManager->remove($instance->getCalendar()); $entityManager->flush(); } catch (\Exception $e) { return $this->json(['status' => 'error', 'message' => 'Failed to Delete Calendar', 'timestamp' => $this->getTimestamp()], 500); } return $this->json(['status' => 'success', 'timestamp' => $this->getTimestamp()], 200); } /** * Retrieves a list of shares for a specific calendar of a specific user (id, username, displayname, email, write_access). * * @param Request $request The HTTP GET request * @param int $userId The ID of the user whose calendar shares are to be retrieved * @param int $calendar_id The ID of the calendar whose shares are to be retrieved * * @return JsonResponse A JSON response containing the list of calendar shares */ #[Route('/calendars/{userId}/shares/{calendar_id}', name: 'calendars_shares', methods: ['GET'], requirements: ['calendar_id' => '\d+', 'userId' => '\d+'])] public function getUserCalendarsShares(Request $request, int $userId, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { $user = $this->resolveUser($doctrine, $userId); if (!$user) { return $this->json(['status' => 'error', 'message' => 'User Not Found', 'timestamp' => $this->getTimestamp()], 404); } $principalUri = Principal::PREFIX.$user->getUsername(); if (!$doctrine->getRepository(Principal::class)->findOneByUri($principalUri)) { return $this->json(['status' => 'error', 'message' => 'Principal Not Found', 'timestamp' => $this->getTimestamp()], 404); } $ownerInstance = $doctrine->getRepository(CalendarInstance::class)->findOneBy([ 'id' => $calendar_id, 'principalUri' => $principalUri, ]); if (!$ownerInstance) { return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID/Username', 'timestamp' => $this->getTimestamp()], 400); } // This fixes the issue where shared calendars are not being retrieved properly $instances = $doctrine->getRepository(CalendarInstance::class)->findSharedInstancesOfInstance($ownerInstance->getCalendar()->getId(), true); $calendars = []; foreach ($instances as $instance) { $principalId = $doctrine->getRepository(Principal::class)->findOneByUri($instance[0]['principalUri']); $instanceUsername = mb_substr($instance[0]['principalUri'], strlen(Principal::PREFIX)); $instanceUserId = $doctrine->getRepository(User::class)->findOneByUsername($instanceUsername)->getId(); $calendars[] = [ 'username' => $instanceUsername, 'user_id' => $instanceUserId, 'principal_id' => $principalId?->getId() ?? null, 'displayname' => $instance['displayName'], 'email' => $instance['email'], 'write_access' => SharingPlugin::ACCESS_READWRITE === $instance[0]['access'], ]; } $response = [ 'status' => 'success', 'data' => $calendars, 'timestamp' => $this->getTimestamp(), ]; return $this->json($response, 200); } /** * Sets or updates a share for a specific calendar of a specific user. * * @param Request $request The HTTP POST request * @param int $userId The ID of the user whose calendar share is to be set or updated * @param string $calendar_id The ID of the calendar whose share is to be set or updated * * @return JsonResponse A JSON response indicating the success or failure of the operation */ #[Route('/calendars/{userId}/share/{calendar_id}/add', name: 'calendars_share', methods: ['POST'], requirements: ['calendar_id' => '\d+', 'userId' => '\d+'])] public function setUserCalendarsShare(Request $request, int $userId, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { $user = $this->resolveUser($doctrine, $userId); if (!$user) { return $this->json(['status' => 'error', 'message' => 'User Not Found', 'timestamp' => $this->getTimestamp()], 404); } $principalUri = Principal::PREFIX.$user->getUsername(); if (!$doctrine->getRepository(Principal::class)->findOneByUri($principalUri)) { return $this->json(['status' => 'error', 'message' => 'Principal Not Found', 'timestamp' => $this->getTimestamp()], 404); } $ownerInstance = $doctrine->getRepository(CalendarInstance::class)->findOneBy([ 'id' => $calendar_id, 'principalUri' => $principalUri, ]); if (!$ownerInstance) { return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID and User ID', 'timestamp' => $this->getTimestamp()], 400); } // Parse JSON body $data = json_decode($request->getContent(), true); if (JSON_ERROR_NONE !== json_last_error()) { return $this->json(['status' => 'error', 'message' => 'Invalid JSON', 'timestamp' => $this->getTimestamp()], 400); } $shareeUsername = $data['username'] ?? null; $writeAccess = $data['write_access'] ?? null; if (!$this->validateUsername($shareeUsername) || !in_array($writeAccess, [true, false, 'true', 'false'], true)) { return $this->json(['status' => 'error', 'message' => 'Invalid Sharee ID/Write Access Value', 'timestamp' => $this->getTimestamp()], 400); } $instance = $doctrine->getRepository(CalendarInstance::class)->findOneById($calendar_id); $newShareeToAdd = $doctrine->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$shareeUsername); if (!$instance || !$newShareeToAdd) { return $this->json(['status' => 'error', 'message' => 'Calendar Instance/User Not Found', 'timestamp' => $this->getTimestamp()], 404); } $existingSharedInstance = $doctrine->getRepository(CalendarInstance::class)->findSharedInstanceOfInstanceFor($instance->getCalendar()->getId(), $newShareeToAdd->getUri()); $accessLevel = (true === $writeAccess || 'true' === $writeAccess ? SharingPlugin::ACCESS_READWRITE : SharingPlugin::ACCESS_READ); $entityManager = $doctrine->getManager(); try { if ($existingSharedInstance) { $existingSharedInstance->setAccess($accessLevel); } else { $sharedInstance = new CalendarInstance(); $sharedInstance->setTransparent(1) ->setCalendar($instance->getCalendar()) ->setShareHref('mailto:'.$newShareeToAdd->getEmail()) ->setDescription($instance->getDescription()) ->setDisplayName($instance->getDisplayName()) ->setUri(\Sabre\DAV\UUIDUtil::getUUID()) ->setPrincipalUri($newShareeToAdd->getUri()) ->setAccess($accessLevel); $entityManager->persist($sharedInstance); } $entityManager->flush(); } catch (\Exception $e) { return $this->json(['status' => 'error', 'message' => 'Failed to Edit Calendar', 'timestamp' => $this->getTimestamp()], 500); } return $this->json(['status' => 'success', 'timestamp' => $this->getTimestamp()], 200); } /** * Removes a share for a specific calendar of a specific user. * * @param Request $request The HTTP POST request * @param int $userId The ID of the user whose calendar share is to be removed * @param string $calendar_id The ID of the calendar whose share is to be removed * * @return JsonResponse A JSON response indicating the success or failure of the operation */ #[Route('/calendars/{userId}/share/{calendar_id}/remove', name: 'calendars_share_remove', methods: ['POST'], requirements: ['calendar_id' => '\d+', 'userId' => '\d+'])] public function removeUserCalendarsShare(Request $request, int $userId, int $calendar_id, ManagerRegistry $doctrine): JsonResponse { $user = $this->resolveUser($doctrine, $userId); if (!$user) { return $this->json(['status' => 'error', 'message' => 'User Not Found', 'timestamp' => $this->getTimestamp()], 404); } $principalUri = Principal::PREFIX.$user->getUsername(); if (!$doctrine->getRepository(Principal::class)->findOneByUri($principalUri)) { return $this->json(['status' => 'error', 'message' => 'Principal Not Found', 'timestamp' => $this->getTimestamp()], 404); } $ownerInstance = $doctrine->getRepository(CalendarInstance::class)->findOneBy([ 'id' => $calendar_id, 'principalUri' => $principalUri, ]); if (!$ownerInstance) { return $this->json(['status' => 'error', 'message' => 'Invalid Calendar ID', 'timestamp' => $this->getTimestamp()], 400); } // Parse JSON body $data = json_decode($request->getContent(), true); if (JSON_ERROR_NONE !== json_last_error()) { return $this->json(['status' => 'error', 'message' => 'Invalid JSON', 'timestamp' => $this->getTimestamp()], 400); } $shareeUsername = $data['username'] ?? null; if (!$this->validateUsername($shareeUsername)) { return $this->json(['status' => 'error', 'message' => 'Invalid Username', 'timestamp' => $this->getTimestamp()], 400); } $instance = $doctrine->getRepository(CalendarInstance::class)->findOneById($calendar_id); $shareeToRemove = $doctrine->getRepository(Principal::class)->findOneByUri(Principal::PREFIX.$shareeUsername); if (!$instance || !$shareeToRemove) { return $this->json(['status' => 'error', 'message' => 'Calendar Instance/User Not Found', 'timestamp' => $this->getTimestamp()], 404); } try { $existingSharedInstance = $doctrine->getRepository(CalendarInstance::class)->findSharedInstanceOfInstanceFor($instance->getCalendar()->getId(), $shareeToRemove->getUri()); if ($existingSharedInstance) { $entityManager = $doctrine->getManager(); $entityManager->remove($existingSharedInstance); $entityManager->flush(); } } catch (\Exception $e) { return $this->json(['status' => 'error', 'message' => 'Failed to Remove Share', 'timestamp' => $this->getTimestamp()], 500); } return $this->json(['status' => 'success', 'timestamp' => $this->getTimestamp()], 200); } } ================================================ FILE: src/Controller/DAVController.php ================================================ publicDir = $publicDir; $this->calDAVEnabled = $calDAVEnabled; $this->cardDAVEnabled = $cardDAVEnabled; $this->webDAVEnabled = $webDAVEnabled; $this->publicCalendarsEnabled = $publicCalendarsEnabled; $this->inviteAddress = $inviteAddress ?? null; $this->webdavPublicDir = $webdavPublicDir; $this->webdavHomesDir = $webdavHomesDir; $this->webdavTmpDir = $webdavTmpDir; $this->em = $entityManager; $this->logger = $logger; $this->mailer = $mailer; $this->birthdayService = $birthdayService; $this->baseUri = $router->generate('dav', ['path' => '']); $this->basicAuthBackend = $basicAuthBackend; $this->IMAPAuthBackend = $IMAPAuthBackend; $this->LDAPAuthBackend = $LDAPAuthBackend; $this->initServer($authMethod, $authRealm); $this->initExceptionListener(); } #[Route('/', name: 'home')] public function home(): Response { return $this->render('index.html.twig', [ 'version' => \App\Version::VERSION, ]); } private function initServer(string $authMethod, string $authRealm = User::DEFAULT_AUTH_REALM) { // Get the PDO Connection of type PDO $pdo = $this->em->getConnection()->getNativeConnection(); /* * The backends. */ switch ($authMethod) { case self::AUTH_IMAP: $authBackend = $this->IMAPAuthBackend; break; case self::AUTH_LDAP: $authBackend = $this->LDAPAuthBackend; break; case self::AUTH_BASIC: default: $authBackend = $this->basicAuthBackend; break; } $authBackend->setRealm($authRealm); $principalBackend = new \Sabre\DAVACL\PrincipalBackend\PDO($pdo); /** * The directory tree. * * Basically this is an array which contains the 'top-level' directories in the * WebDAV server. */ $nodes = [ // /principals new \Sabre\CalDAV\Principal\Collection($principalBackend), ]; if ($this->webdavHomesDir) { $nodes[] = new \Sabre\DAVACL\FS\HomeCollection($principalBackend, $this->webdavHomesDir); } if ($this->calDAVEnabled) { $calendarBackend = new \Sabre\CalDAV\Backend\PDO($pdo); $nodes[] = new \Sabre\CalDAV\CalendarRoot($principalBackend, $calendarBackend); } if ($this->cardDAVEnabled) { $carddavBackend = new \Sabre\CardDAV\Backend\PDO($pdo); $nodes[] = new \Sabre\CardDAV\AddressBookRoot($principalBackend, $carddavBackend); } if ($this->webDAVEnabled && $this->webdavTmpDir && $this->webdavPublicDir) { $nodes[] = new \Sabre\DAV\FS\Directory($this->webdavPublicDir); } // The object tree needs in turn to be passed to the server class $this->server = new \Sabre\DAV\Server($nodes); $this->server->setBaseUri($this->baseUri); // Plugins $this->server->addPlugin(new \Sabre\DAV\Auth\Plugin($authBackend, $authRealm)); $this->server->addPlugin(new \Sabre\DAV\Browser\Plugin(false)); // We disable the file creation / upload / sharing in the browser $this->server->addPlugin(new \Sabre\DAV\Sync\Plugin()); $aclPlugin = new PublicAwareDAVACLPlugin($this->em, $this->publicCalendarsEnabled); $aclPlugin->hideNodesFromListings = true; $aclPlugin->allowUnauthenticatedAccess = true; // Already the default, but setting it is future-proof // Fetch admins, if any $admins = $this->em->getRepository(Principal::class)->findBy(['isAdmin' => true]); foreach ($admins as $principal) { $aclPlugin->adminPrincipals[] = $principal->getUri(); } $this->server->addPlugin($aclPlugin); $this->server->addPlugin(new \Sabre\DAV\PropertyStorage\Plugin( new \Sabre\DAV\PropertyStorage\Backend\PDO($pdo) )); // CalDAV plugins if ($this->calDAVEnabled) { $this->server->addPlugin(new \Sabre\DAV\Sharing\Plugin()); $this->server->addPlugin(new \Sabre\CalDAV\Plugin()); $this->server->addPlugin(new \Sabre\CalDAV\Schedule\Plugin()); $this->server->addPlugin(new \Sabre\CalDAV\SharingPlugin()); $this->server->addPlugin(new \Sabre\CalDAV\ICSExportPlugin()); $this->server->addPlugin(new \Sabre\CalDAV\Subscriptions\Plugin()); if ($this->inviteAddress) { $this->server->addPlugin(new DavisIMipPlugin($this->mailer, $this->inviteAddress, $this->publicDir)); } } // CardDAV plugins if ($this->cardDAVEnabled) { $this->server->addPlugin(new \Sabre\CardDAV\Plugin()); $this->server->addPlugin(new \Sabre\CardDAV\VCFExportPlugin()); } if ($this->cardDAVEnabled && $this->calDAVEnabled) { $this->server->addPlugin(new BirthdayCalendarPlugin($this->birthdayService, $calendarBackend)); } // WebDAV plugins if ($this->webDAVEnabled && $this->webdavTmpDir && $this->webdavPublicDir) { if (!is_dir($this->webdavTmpDir) || !is_dir($this->webdavPublicDir)) { throw new \Exception('The WebDAV temp dir and/or public dir are not available. Make sure they are created with the correct permissions.'); } $lockBackend = new \Sabre\DAV\Locks\Backend\File($this->webdavTmpDir.'/locksdb'); $this->server->addPlugin(new \Sabre\DAV\Locks\Plugin($lockBackend)); $this->server->addPlugin(new \Sabre\DAV\Browser\GuessContentType()); $this->server->addPlugin(new \Sabre\DAV\TemporaryFileFilterPlugin($this->webdavTmpDir)); } } private function initExceptionListener() { $this->server->on('exception', function (\Throwable $e) { // We don't need a trace for simple authentication exceptions if ($e instanceof \Sabre\DAV\Exception\NotAuthenticated) { $this->logger->warning('[401]: '.get_class($e)." - No 'Authorization: Basic' header found. Login was needed"); return; } $httpCode = ($e instanceof \Sabre\DAV\Exception) ? $e->getHTTPCode() : 500; $this->logger->error('['.$httpCode.']: '.get_class($e).' - '.$e->getMessage(), $e->getTrace()); }); } #[Route('/dav/{path}', name: 'dav', requirements: ['path' => '.*'])] public function dav(Request $request, ?string $path, ?Profiler $profiler = null) { // We don't want the toolbar on the /dav/* routes if ($profiler instanceof Profiler) { $profiler->disable(); } // We need to acknowledge the OPTIONS call before sabre/dav for public // calendars since we're circumventing the lib if ('OPTIONS' === $request->getMethod()) { $response = new Response(); // Adapted from CorePlugin's httpOptions() // https://github.com/sabre-io/dav/blob/master/lib/DAV/CorePlugin.php#L210 $methods = $this->server->getAllowedMethods(''); $response->headers->set('Allow', strtoupper(implode(', ', $methods))); $features = ['1', '3', 'extended-mkcol']; foreach ($this->server->getPlugins() as $plugin) { $features = array_merge($features, $plugin->getFeatures()); } $response->headers->set('DAV', implode(', ', $features)); $response->headers->set('MS-Author-Via', 'DAV'); return $response; } // \Sabre\DAV\Server does not let us use a custom SAPI, and its behaviour // is to directly output headers and content to php://output. Hence, we // let the headers pass (we have not choice) and capture the output in a // buffer. // This allows us to use a Response, and not to break the events triggered // by Symfony after the response is sent, like for instance the TERMINATE // event from the Kernel, that is used to send emails... ob_start(); // Does not capture headers! $this->server->start(); $output = ob_get_contents(); ob_end_clean(); // As previously said, headers are already _prepared_ by the server, // so we can't modify them or remove them. But they are not _sent_ yet, // so headers_sent() is false, and Symfony will add its own headers above it. // // The Content-type header is the problem, since Symfony will // output `text/html` for everything since it doesn't know any better. // Thus, we have to get the _real_ Content-type header already prepared, // and force it in the Symfony Response. // // That's what we do here. $response = new Response($output, http_response_code(), []); foreach (headers_list() as $header) { if ('content-type:' === strtolower(substr($header, 0, 13))) { $headerArray = explode(':', $header); $response->headers->set('Content-type', $headerArray[1]); } } return $response; } } ================================================ FILE: src/Controller/SecurityController.php ================================================ getUser()) { // return $this->redirectToRoute('target_path'); // } // get the login error if there is one $error = $authenticationUtils->getLastAuthenticationError(); // last username entered by the user $lastUsername = $authenticationUtils->getLastUsername(); return $this->render('security/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]); } #[Route('/logout', name: 'app_logout')] public function logout() { throw new \Exception('This method can be blank - it will be intercepted by the logout key on your firewall'); } } ================================================ FILE: src/DataFixtures/AppFixtures.php ================================================ setUsername('test_user') ->setPassword($hash); $manager->persist($user); $principal = (new Principal()) ->setUri(Principal::PREFIX.$user->getUsername()) ->setEmail('test@test.com') ->setDisplayName('Test User') ->setIsAdmin(true); $manager->persist($principal); // Create all the default calendar / addressbook $calendarInstance = new CalendarInstance(); $calendar = new Calendar(); $calendarInstance->setPrincipalUri(Principal::PREFIX.$user->getUsername()) ->setUri('default') ->setDisplayName('default.calendar.title') ->setDescription('default.calendar.description') ->setCalendar($calendar); $manager->persist($calendarInstance); // Enable delegation by default $principalProxyRead = new Principal(); $principalProxyRead->setUri($principal->getUri().Principal::READ_PROXY_SUFFIX) ->setIsMain(false); $manager->persist($principalProxyRead); $principalProxyWrite = new Principal(); $principalProxyWrite->setUri($principal->getUri().Principal::WRITE_PROXY_SUFFIX) ->setIsMain(false); $manager->persist($principalProxyWrite); $addressbook = new AddressBook(); $addressbook->setPrincipalUri(Principal::PREFIX.$user->getUsername()) ->setUri('default') ->setDisplayName('default.addressbook.title') ->setDescription('default.addressbook.description'); $manager->persist($addressbook); $manager->flush(); // Test User 2 - For API testing $hash = password_hash('password2', PASSWORD_DEFAULT); $user = (new User()) ->setUsername('test_user2') ->setPassword($hash); $manager->persist($user); $principal = (new Principal()) ->setUri(Principal::PREFIX.$user->getUsername()) ->setEmail('test2@test.com') ->setDisplayName('Test User 2') ->setIsAdmin(false); $manager->persist($principal); // Create all the default calendar / addressbook $calendarInstance = new CalendarInstance(); $calendar = new Calendar(); $calendarInstance->setPrincipalUri(Principal::PREFIX.$user->getUsername()) ->setUri('default') ->setDisplayName('default.calendar.title2') ->setDescription('default.calendar.description2') ->setCalendar($calendar); $manager->persist($calendarInstance); // Enable delegation by default $principalProxyRead = new Principal(); $principalProxyRead->setUri($principal->getUri().Principal::READ_PROXY_SUFFIX) ->setIsMain(false); $manager->persist($principalProxyRead); $principalProxyWrite = new Principal(); $principalProxyWrite->setUri($principal->getUri().Principal::WRITE_PROXY_SUFFIX) ->setIsMain(false); $manager->persist($principalProxyWrite); $addressbook = new AddressBook(); $addressbook->setPrincipalUri(Principal::PREFIX.$user->getUsername()) ->setUri('default') ->setDisplayName('default.addressbook.title2') ->setDescription('default.addressbook.description2'); $manager->persist($addressbook); $manager->flush(); } } ================================================ FILE: src/Entity/AddressBook.php ================================================ false])] private $includedInBirthdayCalendar; #[ORM\OneToMany(targetEntity: "App\Entity\Card", mappedBy: 'addressBook')] private $cards; #[ORM\OneToMany(targetEntity: "App\Entity\AddressBookChange", mappedBy: 'addressBook')] private $changes; public function __construct() { $this->synctoken = 1; $this->includedInBirthdayCalendar = false; $this->cards = new ArrayCollection(); $this->changes = new ArrayCollection(); } public function getId(): ?int { return $this->id; } public function getPrincipalUri(): ?string { return $this->principalUri; } public function setPrincipalUri(string $principalUri): self { $this->principalUri = $principalUri; return $this; } public function getDisplayName(): ?string { return $this->displayName; } public function setDisplayName(string $displayName): self { $this->displayName = $displayName; return $this; } public function isIncludedInBirthdayCalendar(): ?bool { return $this->includedInBirthdayCalendar; } public function setIncludedInBirthdayCalendar(bool $includedInBirthdayCalendar): self { $this->includedInBirthdayCalendar = $includedInBirthdayCalendar; return $this; } public function getUri(): ?string { return $this->uri; } public function setUri(string $uri): self { $this->uri = $uri; return $this; } public function getDescription(): ?string { return $this->description; } public function setDescription(string $description): self { $this->description = $description; return $this; } public function getSynctoken(): ?string { return $this->synctoken; } public function setSynctoken(string $synctoken): self { $this->synctoken = $synctoken; return $this; } /** * @return Collection|Card[] */ public function getCards(): Collection { return $this->cards; } public function addCard(Card $card): self { if (!$this->cards->contains($card)) { $this->cards[] = $card; $card->setAddressBook($this); } return $this; } public function removeCard(Card $card): self { if ($this->cards->contains($card)) { $this->cards->removeElement($card); // set the owning side to null (unless already changed) if ($card->getAddressBook() === $this) { $card->setAddressBook(null); } } return $this; } /** * @return Collection|AddressBookChange[] */ public function getChanges(): Collection { return $this->changes; } public function addChange(AddressBookChange $change): self { if (!$this->changes->contains($change)) { $this->changes[] = $change; $change->setCalendar($this); } return $this; } public function removeChange(AddressBookChange $change): self { if ($this->changes->contains($change)) { $this->changes->removeElement($change); // set the owning side to null (unless already changed) if ($change->getCalendar() === $this) { $change->setCalendar(null); } } return $this; } } ================================================ FILE: src/Entity/AddressBookChange.php ================================================ id; } public function getUri(): ?string { return $this->uri; } public function setUri(string $uri): self { $this->uri = $uri; return $this; } public function getSynctoken(): ?string { return $this->synctoken; } public function setSynctoken(string $synctoken): self { $this->synctoken = $synctoken; return $this; } public function getAddressBook(): ?AddressBook { return $this->addressBook; } public function setAddressBook(?AddressBook $addressBook): self { $this->addressBook = $addressBook; return $this; } public function getOperation(): ?int { return $this->operation; } public function setOperation(int $operation): self { $this->operation = $operation; return $this; } } ================================================ FILE: src/Entity/Calendar.php ================================================ synctoken = 1; $this->components = static::COMPONENT_EVENTS; $this->objects = new ArrayCollection(); $this->changes = new ArrayCollection(); } public function getId(): ?int { return $this->id; } public function getSynctoken(): ?string { return $this->synctoken; } public function setSynctoken(string $synctoken): self { $this->synctoken = $synctoken; return $this; } public function getComponents(): ?string { return $this->components; } public function setComponents(?string $components): self { $this->components = $components; return $this; } /** * @return Collection|CalendarObject[] */ public function getObjects(): Collection { return $this->objects; } public function addObject(CalendarObject $object): self { if (!$this->objects->contains($object)) { $this->objects[] = $object; $object->setCalendar($this); } return $this; } public function removeObject(CalendarObject $object): self { if ($this->objects->contains($object)) { $this->objects->removeElement($object); // set the owning side to null (unless already changed) if ($object->getCalendar() === $this) { $object->setCalendar(null); } } return $this; } /** * @return Collection|CalendarChange[] */ public function getChanges(): Collection { return $this->changes; } public function addChange(CalendarChange $change): self { if (!$this->changes->contains($change)) { $this->changes[] = $change; $change->setCalendar($this); } return $this; } public function removeChange(CalendarChange $change): self { if ($this->changes->contains($change)) { $this->changes->removeElement($change); // set the owning side to null (unless already changed) if ($change->getCalendar() === $this) { $change->setCalendar(null); } } return $this; } /** * @return Collection|CalendarInstance[] */ public function getInstances(): Collection { return $this->instances; } /** * Check if this calendar supports a specific component type. * * @param string $componentType The component type to check * * @return bool True if the component is supported, false otherwise */ public function isComponentEnabled(string $componentType): bool { return in_array($componentType, explode(',', $this->components ?? ''), true); } } ================================================ FILE: src/Entity/CalendarChange.php ================================================ id; } public function getUri(): ?string { return $this->uri; } public function setUri(string $uri): self { $this->uri = $uri; return $this; } public function getSynctoken(): ?int { return $this->synctoken; } public function setSynctoken(int $synctoken): self { $this->synctoken = $synctoken; return $this; } public function getCalendar(): ?Calendar { return $this->calendar; } public function setCalendar(?Calendar $calendar): self { $this->calendar = $calendar; return $this; } public function getOperation(): ?int { return $this->operation; } public function setOperation(int $operation): self { $this->operation = $operation; return $this; } } ================================================ FILE: src/Entity/CalendarInstance.php ================================================ 1])] private $access; #[ORM\Column(name: 'displayname', type: 'string', length: 255, nullable: true)] private $displayName; #[ORM\Column(type: 'string', length: 255, nullable: true)] #[Assert\Regex("/[0-9a-z\-]+/")] private $uri; #[ORM\Column(type: 'text', nullable: true)] private $description; #[ORM\Column(name: 'calendarorder', type: 'integer', options: ['default' => 0])] private $calendarOrder; #[ORM\Column(name: 'calendarcolor', type: 'string', length: 10, nullable: true)] #[Assert\Regex("/\#[0-9A-F]{6}/")] private $calendarColor; #[ORM\Column(type: 'text', nullable: true)] private $timezone; #[ORM\Column(type: 'integer', nullable: true)] private $transparent; #[ORM\Column(name: 'share_href', type: 'string', length: 255, nullable: true)] private $shareHref; #[ORM\Column(name: 'share_displayname', type: 'string', length: 255, nullable: true)] private $shareDisplayName; #[ORM\Column(name: 'share_invitestatus', type: 'integer', options: ['default' => 2])] private $shareInviteStatus; #[ORM\Column(name: 'public', type: 'boolean', options: ['default' => false])] private $public; public function __construct() { $this->shareInviteStatus = SharingPlugin::INVITE_ACCEPTED; $this->transparent = 0; $this->calendarOrder = 0; $this->access = SharingPlugin::ACCESS_SHAREDOWNER; $this->public = false; } public function getId(): ?int { return $this->id; } public function getCalendar(): ?Calendar { return $this->calendar; } public function setCalendar(?Calendar $calendar): self { $this->calendar = $calendar; return $this; } public function getPrincipalUri(): ?string { return $this->principalUri; } public function setPrincipalUri(?string $principalUri): self { $this->principalUri = $principalUri; return $this; } public function getAccess(): ?int { return $this->access; } public function setAccess(int $access): self { $this->access = $access; return $this; } public function isShared(): bool { return !in_array($this->access, self::getOwnerAccesses()); } public function setPublic(bool $public): self { $this->public = $public; return $this; } public function isPublic(): bool { return $this->public; } public function isAutomaticallyGenerated(): bool { return in_array($this->uri, [Constants::BIRTHDAY_CALENDAR_URI]); } public function getDisplayName(): ?string { return $this->displayName; } public function setDisplayName(?string $displayName): self { $this->displayName = $displayName; return $this; } public function getUri(): ?string { return $this->uri; } public function setUri(?string $uri): self { $this->uri = $uri; return $this; } public function getDescription(): ?string { return $this->description; } public function setDescription(?string $description): self { $this->description = $description; return $this; } public function getCalendarOrder(): ?int { return $this->calendarOrder; } public function setCalendarOrder(int $calendarOrder): self { $this->calendarOrder = $calendarOrder; return $this; } public function getCalendarColor(): ?string { return $this->calendarColor; } public function setCalendarColor(?string $calendarColor): self { $this->calendarColor = $calendarColor; return $this; } public function getTimezone(): ?string { return $this->timezone; } public function setTimezone(?string $timezone): self { $this->timezone = $timezone; return $this; } public function getTransparent(): ?int { return $this->transparent; } public function setTransparent(?int $transparent): self { $this->transparent = $transparent; return $this; } public function getShareHref(): ?string { return $this->shareHref; } public function setShareHref(?string $shareHref): self { $this->shareHref = $shareHref; return $this; } public function getShareDisplayName(): ?string { return $this->shareDisplayName; } public function setShareDisplayName(?string $shareDisplayName): self { $this->shareDisplayName = $shareDisplayName; return $this; } public function getShareInviteStatus(): ?int { return $this->shareInviteStatus; } public function setShareInviteStatus(int $shareInviteStatus): self { $this->shareInviteStatus = $shareInviteStatus; return $this; } } ================================================ FILE: src/Entity/CalendarObject.php ================================================ id; } public function getCalendarData(): ?string { return $this->calendarData; } public function setCalendarData(?string $calendarData): self { $this->calendarData = $calendarData; return $this; } public function getUri(): ?string { return $this->uri; } public function setUri(?string $uri): self { $this->uri = $uri; return $this; } public function getCalendar(): ?Calendar { return $this->calendar; } public function setCalendar(?Calendar $calendar): self { $this->calendar = $calendar; return $this; } public function getLastModifier(): ?int { return $this->lastModifier; } public function setLastModifier(?int $lastModifier): self { $this->lastModifier = $lastModifier; return $this; } public function getEtag(): ?string { return $this->etag; } public function setEtag(?string $etag): self { $this->etag = $etag; return $this; } public function getSize(): ?int { return $this->size; } public function setSize(int $size): self { $this->size = $size; return $this; } public function getComponentType(): ?string { return $this->componentType; } public function setComponentType(?string $componentType): self { $this->componentType = $componentType; return $this; } public function getFirstOccurence(): ?int { return $this->firstOccurence; } public function setFirstOccurence(?int $firstOccurence): self { $this->firstOccurence = $firstOccurence; return $this; } public function getLastOccurence(): ?int { return $this->lastOccurence; } public function setLastOccurence(?int $lastOccurence): self { $this->lastOccurence = $lastOccurence; return $this; } public function getUid(): ?string { return $this->uid; } public function setUid(?string $uid): self { $this->uid = $uid; return $this; } public function getLastModified(): ?int { return $this->lastModified; } public function setLastModified(?int $lastModified): self { $this->lastModified = $lastModified; return $this; } } ================================================ FILE: src/Entity/CalendarSubscription.php ================================================ id; } public function getUri(): ?string { return $this->uri; } public function setUri(string $uri): self { $this->uri = $uri; return $this; } public function getPrincipalUri(): ?string { return $this->principalUri; } public function setPrincipalUri(string $principalUri): self { $this->principalUri = $principalUri; return $this; } public function getSource(): ?string { return $this->source; } public function setSource(?string $source): self { $this->source = $source; return $this; } public function getDisplayName(): ?string { return $this->displayName; } public function setDisplayName(?string $displayName): self { $this->displayName = $displayName; return $this; } public function getRefreshRate(): ?string { return $this->refreshRate; } public function setRefreshRate(?string $refreshRate): self { $this->refreshRate = $refreshRate; return $this; } public function getCalendarOrder(): ?int { return $this->calendarOrder; } public function setCalendarOrder(int $calendarOrder): self { $this->calendarOrder = $calendarOrder; return $this; } public function getCalendarColor(): ?string { return $this->calendarColor; } public function setCalendarColor(?string $calendarColor): self { $this->calendarColor = $calendarColor; return $this; } public function getStripTodos(): ?int { return $this->stripTodos; } public function setStripTodos(?int $stripTodos): self { $this->stripTodos = $stripTodos; return $this; } public function getStripAlarms(): ?int { return $this->stripAlarms; } public function setStripAlarms(?int $stripAlarms): self { $this->stripAlarms = $stripAlarms; return $this; } public function getStripAttachments(): ?int { return $this->stripAttachments; } public function setStripAttachments(?int $stripAttachments): self { $this->stripAttachments = $stripAttachments; return $this; } public function getLastModified(): ?int { return $this->lastModified; } public function setLastModified(?int $lastModified): self { $this->lastModified = $lastModified; return $this; } } ================================================ FILE: src/Entity/Card.php ================================================ id; } public function getAddressBook(): ?AddressBook { return $this->addressBook; } public function setAddressBook(?AddressBook $addressBook): self { $this->addressBook = $addressBook; return $this; } public function getCardData(): ?string { return $this->cardData; } public function setCardData(?string $cardData): self { $this->cardData = $cardData; return $this; } public function getUri(): ?string { return $this->uri; } public function setUri(?string $uri): self { $this->uri = $uri; return $this; } public function getLastModified(): ?int { return $this->lastModified; } public function setLastModified(?int $lastModified): self { $this->lastModified = $lastModified; return $this; } public function getEtag(): ?string { return $this->etag; } public function setEtag(?string $etag): self { $this->etag = $etag; return $this; } public function getSize(): ?int { return $this->size; } public function setSize(int $size): self { $this->size = $size; return $this; } } ================================================ FILE: src/Entity/Lock.php ================================================ id; } public function getOwner(): ?string { return $this->owner; } public function setOwner(?string $owner): self { $this->owner = $owner; return $this; } public function getTimeout(): ?int { return $this->timeout; } public function setTimeout(?int $timeout): self { $this->timeout = $timeout; return $this; } public function getCreated(): ?int { return $this->created; } public function setCreated(?int $created): self { $this->created = $created; return $this; } public function getToken(): ?string { return $this->token; } public function setToken(?string $token): self { $this->token = $token; return $this; } public function getScope(): ?int { return $this->scope; } public function setScope(?int $scope): self { $this->scope = $scope; return $this; } public function getDepth(): ?int { return $this->depth; } public function setDepth(?int $depth): self { $this->depth = $depth; return $this; } public function getUri(): ?string { return $this->uri; } public function setUri(?string $uri): self { $this->uri = $uri; return $this; } } ================================================ FILE: src/Entity/Principal.php ================================================ delegees = new ArrayCollection(); $this->isMain = true; $this->isAdmin = false; } public function getId(): ?int { return $this->id; } public function getUri(): ?string { return $this->uri; } public function setUri(string $uri): self { $this->uri = $uri; return $this; } public function getUsername(): ?string { return str_replace(self::PREFIX, '', $this->getUri()); } public function getEmail(): ?string { return $this->email; } public function setEmail(?string $email): self { $this->email = $email; return $this; } public function getDisplayName(): ?string { return $this->displayName; } public function setDisplayName(?string $displayName): self { $this->displayName = $displayName; return $this; } /** * @return Collection|Principal[] */ public function getDelegees(): Collection { return $this->delegees; } public function addDelegee(Principal $delegee): self { if (!$this->delegees->contains($delegee)) { $this->delegees[] = $delegee; } return $this; } public function removeDelegee(Principal $delegee): self { if ($this->delegees->contains($delegee)) { $this->delegees->removeElement($delegee); } return $this; } public function removeAllDelegees(): self { $this->delegees->clear(); return $this; } public function getIsMain(): ?bool { return $this->isMain; } public function setIsMain(bool $isMain): self { $this->isMain = $isMain; return $this; } public function getIsAdmin(): ?bool { return $this->isAdmin; } public function setIsAdmin(bool $isAdmin): self { $this->isAdmin = $isAdmin; return $this; } } ================================================ FILE: src/Entity/PropertyStorage.php ================================================ id; } public function getPath(): ?string { return $this->path; } public function setPath(string $path): self { $this->path = $path; return $this; } public function getName(): ?string { return $this->name; } public function setName(string $name): self { $this->name = $name; return $this; } public function getValueType(): ?int { return $this->valueType; } public function setValueType(?int $valueType): self { $this->valueType = $valueType; return $this; } public function getValue(): ?string { return $this->value; } public function setValue(?string $value): self { $this->value = $value; return $this; } } ================================================ FILE: src/Entity/SchedulingObject.php ================================================ id; } public function getPrincipalUri(): ?string { return $this->principalUri; } public function setPrincipalUri(?string $principalUri): self { $this->principalUri = $principalUri; return $this; } public function getCalendarData(): ?string { return $this->calendarData; } public function setCalendarData(?string $calendarData): self { $this->calendarData = $calendarData; return $this; } public function getUri(): ?string { return $this->uri; } public function setUri(?string $uri): self { $this->uri = $uri; return $this; } public function getLastModified(): ?int { return $this->lastModified; } public function setLastModified(?int $lastModified): self { $this->lastModified = $lastModified; return $this; } public function getEtag(): ?string { return $this->etag; } public function setEtag(?string $etag): self { $this->etag = $etag; return $this; } public function getSize(): ?int { return $this->size; } public function setSize(int $size): self { $this->size = $size; return $this; } } ================================================ FILE: src/Entity/User.php ================================================ id; } public function getUsername(): ?string { return $this->username; } public function setUsername(string $username): self { $this->username = $username; return $this; } public function getPassword(): ?string { return $this->password; } // $password _can_ be NULL here, in the case when we edit a user // and do not change its password public function setPassword(?string $password): self { $this->password = $password; return $this; } } ================================================ FILE: src/Form/AddressBookType.php ================================================ add('principalUri', HiddenType::class, [ 'required' => true, ]) ->add('uri', TextType::class, [ 'label' => 'form.uri', 'disabled' => !$options['new'], 'help' => 'form.uri.help.carddav', ]) ->add('displayName', TextType::class, [ 'label' => 'form.displayName', 'help' => 'form.name.help.carddav', ]) ->add('includedInBirthdayCalendar', ChoiceType::class, [ 'label' => 'form.includedInBirthdayCalendar', 'help' => 'form.includedInBirthdayCalendar.help', 'required' => true, 'choices' => ['yes' => true, 'no' => false], ]) ->add('description', TextareaType::class, [ 'label' => 'form.description', 'required' => false, ]) ->add('save', SubmitType::class, [ 'label' => 'save', ]); if (!$options['birthday_calendar_enabled']) { $builder->remove('includedInBirthdayCalendar'); } } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'new' => false, 'data_class' => AddressBook::class, 'birthday_calendar_enabled' => true, ]); } } ================================================ FILE: src/Form/CalendarInstanceType.php ================================================ add('principalUri', HiddenType::class, [ 'required' => true, ]) ->add('uri', TextType::class, [ 'label' => 'form.uri', 'disabled' => !$options['new'], 'help' => 'form.uri.help.caldav', 'required' => true, ]) ->add('public', ChoiceType::class, [ 'label' => 'form.public', 'disabled' => $options['shared'], 'help' => 'form.public.help.caldav', 'required' => true, 'choices' => ['yes' => true, 'no' => false], ]) ->add('displayName', TextType::class, [ 'label' => 'form.displayName', 'help' => 'form.name.help.caldav', ]) ->add('description', TextareaType::class, [ 'label' => 'form.description', 'required' => false, ]) ->add('calendarColor', TextType::class, [ 'label' => 'form.color', 'required' => false, 'help' => 'form.color.help', 'attr' => ['placeholder' => '#RRGGBBAA'], ]) ->add('events', CheckboxType::class, [ 'label' => 'form.events', 'mapped' => false, 'disabled' => $options['shared'], 'help' => 'form.events.help', 'required' => false, ]) ->add('todos', CheckboxType::class, [ 'label' => 'form.todos', 'mapped' => false, 'disabled' => $options['shared'], 'help' => 'form.todos.help', 'required' => false, ]) ->add('notes', CheckboxType::class, [ 'label' => 'form.notes', 'mapped' => false, 'disabled' => $options['shared'], 'help' => 'form.notes.help', 'required' => false, ]) ->add('save', SubmitType::class, [ 'label' => 'save', ]); if (!$options['public_calendars_enabled']) { $builder->remove('public'); } } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'new' => false, 'shared' => false, 'data_class' => CalendarInstance::class, 'public_calendars_enabled' => true, ]); } } ================================================ FILE: src/Form/UserType.php ================================================ add('username', TextType::class, [ 'label' => 'form.username', 'disabled' => !$options['new'], 'help' => 'form.username.help', ]) ->add('displayName', TextType::class, [ 'label' => 'form.displayName', 'mapped' => false, ]) ->add('email', EmailType::class, [ 'label' => 'form.email', 'mapped' => false, ]) ->add('password', RepeatedType::class, [ 'type' => PasswordType::class, 'invalid_message' => 'form.password.match', 'options' => ['attr' => ['class' => 'password-field', 'placeholder' => $options['new'] ? '' : 'form.password.empty']], 'required' => $options['new'], 'first_options' => ['label' => 'form.password'], 'second_options' => ['label' => 'form.password.repeat'], ]) ->add('isAdmin', CheckboxType::class, [ 'label' => 'form.admin', 'help' => 'form.admin.help', 'required' => false, 'mapped' => false, ]) ->add('save', SubmitType::class, [ 'label' => 'save', ]); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'new' => false, 'data_class' => User::class, ]); } } ================================================ FILE: src/Kernel.php ================================================ getContainer()->getParameter('timezone'); if ('' === $timezone) { return; } try { date_default_timezone_set($timezone); } catch (\Exception $e) { // We don't crash the app, the setting will be flagged as incorrect in the dashboard } } } ================================================ FILE: src/Logging/Monolog/PasswordFilterProcessor.php ================================================ $item) { if (in_array(strtolower($key), self::PASSWORD_KEYS) || ('args' === $key && $shouldRedactArgs)) { $context[$key] = self::REDACTED; } elseif (is_array($item)) { $context[$key] = static::redactContextRecursive($item); } } return $context; } public function __invoke(LogRecord $record): LogRecord { $context = $record->context; $redactedContext = static::redactContextRecursive($context); return $record->with(context: $redactedContext); } } ================================================ FILE: src/Plugins/BirthdayCalendarPlugin.php ================================================ birthdayService = $birthdayService; $this->birthdayService->setBackend($calendarBackend); } public function initialize(DAV\Server $server) { $this->server = $server; // Hook into card creation $server->on('afterCreateFile', [$this, 'afterCardCreate']); // Hook into card updates $server->on('afterWriteContent', [$this, 'afterCardUpdate']); // Hook into card deletion // Note: The node no longer exists at afterCardDelete so we // use beforeCardDelete for simplicity $server->on('beforeUnbind', [$this, 'beforeCardDelete']); } public function afterCardCreate(string $path, DAV\ICollection $parentNode): void { if (!$parentNode instanceof CardDAV\AddressBook) { return; } $this->handleCardChange($path, $parentNode); } public function afterCardUpdate(string $path, DAV\IFile $node): void { if (!$node instanceof CardDAV\ICard) { return; } $parentPath = dirname($path); $parentNode = $this->server->tree->getNodeForPath($parentPath); if (!$parentNode instanceof CardDAV\AddressBook) { return; } $this->handleCardChange($path, $parentNode); } public function beforeCardDelete(string $path): void { $node = $this->server->tree->getNodeForPath($path); if (!$node instanceof CardDAV\ICard) { return; } $parentPath = dirname($path); $parentNode = $this->server->tree->getNodeForPath($parentPath); if (!$parentNode instanceof CardDAV\AddressBook) { return; } $addressBookId = $parentNode->getProperties(['id'])['id']; $this->birthdayService->onCardDeleted($addressBookId, basename($path)); } private function handleCardChange(string $path, CardDAV\AddressBook $parentNode): void { $cardUri = basename($path); $addressBookId = $parentNode->getProperties(['id'])['id']; $cardNode = $this->server->tree->getNodeForPath($path); $this->birthdayService->onCardChanged($addressBookId, $cardUri, $cardNode->get()); } public function getPluginName(): string { return 'birthday-calendar'; } } ================================================ FILE: src/Plugins/DavisIMipPlugin.php ================================================ mailer = $mailer; $this->senderEmail = $senderEmail; $this->publicDir = $publicDir; } /** * Event handler for the 'schedule' event. */ public function schedule(ITip\Message $itip) { // Not sending any emails if the system considers the update // insignificant. if (!$itip->significantChange) { if (empty($itip->scheduleStatus)) { $itip->scheduleStatus = '1.0;We got the message, but it\'s not significant enough to warrant an email'; } return; } $summary = $itip->message->VEVENT->SUMMARY; if ('mailto' !== parse_url($itip->sender, PHP_URL_SCHEME) || 'mailto' !== parse_url($itip->recipient, PHP_URL_SCHEME)) { return; } $deliveredLocally = '1.2' === $itip->getScheduleStatus(); // 7 is the length of `mailto:`. $senderEmail = substr($itip->sender, 7); $recipientEmail = substr($itip->recipient, 7); // We fallback the senderName to the email if the iTIP originator does not send a name along. $senderName = $itip->senderName ?? $senderEmail; $recipientName = $itip->recipientName; $subject = 'CalDAV message'; switch (strtoupper($itip->method)) { case 'REPLY': // In the case of a reply, we need to find the `PARTSTAT` from // the user. $partstat = (string) $itip->message->VEVENT->ATTENDEE['PARTSTAT']; switch (strtoupper($partstat)) { case 'DECLINED': $subject = $senderName.' declined your invitation to "'.$summary.'"'; $action = 'DECLINED'; break; case 'ACCEPTED': $subject = $senderName.' accepted your invitation to "'.$summary.'"'; $action = 'ACCEPTED'; break; case 'TENTATIVE': $subject = $senderName.' tentatively accepted your invitation to "'.$summary.'"'; $action = 'TENTATIVE'; break; default: $itip->scheduleStatus = '5.0;Email not delivered. We didn\'t understand this PARTSTAT.'; return; } break; case 'REQUEST': $subject = $senderName.' invited you to "'.$summary.'"'; $action = 'REQUEST'; break; case 'CANCEL': $subject = '"'.$summary.'" has been canceled.'; $action = 'CANCEL'; break; } // Construct objects for the mail template $dateTime = isset($itip->message->VEVENT->DTSTART) ? $itip->message->VEVENT->DTSTART->getDateTime() : new \DateTime('now'); $allDay = isset($itip->message->VEVENT->DTSTART) && false === $itip->message->VEVENT->DTSTART->hasTime(); $attendees = []; if (isset($itip->message->VEVENT->ATTENDEE)) { $_attendees = &$itip->message->VEVENT->ATTENDEE; for ($i = 0, $max = count($_attendees); $i < $max; ++$i) { $attendee = $_attendees[$i]; $attendees[] = [ 'cn' => isset($attendee['CN']) ? (string) $attendee['CN'] : (string) $attendee['EMAIL'], 'email' => isset($attendee['EMAIL']) ? (string) $attendee['EMAIL'] : null, 'role' => isset($attendee['ROLE']) ? (string) $attendee['ROLE'] : null, ]; } usort($attendees, function ($a, $b) { if ('CHAIR' === $a['role']) { return -1; } return 1; }); } $notEmpty = function ($property, $else) use ($itip) { if (isset($itip->message->VEVENT->$property)) { $handle = (string) $itip->message->VEVENT->$property; if (!empty($handle)) { return $handle; } } return $else; }; $url = $notEmpty('URL', false); $description = $notEmpty('DESCRIPTION', false); $location = $notEmpty('LOCATION', false); $locationImageDataAsBase64 = false; $locationLink = false; if (isset($itip->message->VEVENT->{'X-APPLE-STRUCTURED-LOCATION'})) { $match = preg_match( '/^(geo:)?(?\-?\d+\.\d+),(?\-?\d+\.\d+)$/', (string) $itip->message->VEVENT->{'X-APPLE-STRUCTURED-LOCATION'}, $coordinates ); if (0 !== $match) { $zoom = 16; $width = 500; $height = 220; $latLng = new LatLng($coordinates['latitude'], $coordinates['longitude']); // https://github.com/DantSu/php-osm-static-api $locationImageDataAsBase64 = (new OpenStreetMap($latLng, $zoom, $width, $height)) ->addMarkers( (new Markers($this->publicDir.'/images/marker.png')) ->setAnchor(Markers::ANCHOR_CENTER, Markers::ANCHOR_BOTTOM) ->addMarker(new LatLng($coordinates['latitude'], $coordinates['longitude'])) ) ->getImage() ->getBase64PNG(); $locationLink = 'https://www.openstreetmap.org'. '/?mlat='.$coordinates['latitude']. '&mlon='.$coordinates['longitude']. '#map='.$zoom. '/'.$coordinates['latitude']. '/'.$coordinates['longitude']; } } // For the mail headers, we don't automatically default to the email if ($itip->senderName) { // If we have a proper name, we use it with the optional message origin indicator $mailSenderName = $itip->senderName; if (static::MESSAGE_ORIGIN_INDICATOR) { $mailSenderName = $mailSenderName.' '.static::MESSAGE_ORIGIN_INDICATOR; } } elseif (static::MESSAGE_ORIGIN_INDICATOR) { // Otherwise, we don't use the email except if we explicitly have a message origin // indicator (it would be redundant if there wasn't) $mailSenderName = $senderEmail.' '.static::MESSAGE_ORIGIN_INDICATOR; } $message = (new TemplatedEmail()) ->from(new Address($this->senderEmail, $mailSenderName)) ->to(new Address($recipientEmail, $recipientName ?? '')) ->replyTo(new Address($senderEmail, $mailSenderName)) ->subject($subject); if (DAV\Server::$exposeVersion) { $message->getHeaders() ->addTextHeader('X-Sabre-Version: ', DAV\Version::VERSION) ->addTextHeader('X-Auto-Response-Suppress', 'OOF, DR, RN, NRN, AutoReply'); } // Now that we have everything, we can set the message body $message->htmlTemplate('mails/scheduling.html.twig') ->textTemplate('mails/scheduling.txt.twig') ->context([ 'senderName' => $senderName, 'summary' => $summary, 'action' => $action, 'dateTime' => $dateTime, 'allDay' => $allDay, 'attendees' => $attendees, 'location' => $location, 'locationImageDataAsBase64' => $locationImageDataAsBase64, 'locationLink' => $locationLink, 'url' => $url, 'description' => $description, ]); if (false === $deliveredLocally) { // Attach the event file (invite.ics) $message->attach($itip->message->serialize(), 'invite.ics', 'text/calendar; method='.(string) $itip->method.'; charset=UTF-8'); } $this->mailer->send($message); if (false === $deliveredLocally) { $itip->scheduleStatus = '1.1;Scheduling message is sent via iMip.'; } } /** * Returns a bunch of meta-data about the plugin. * * Providing this information is optional, and is mainly displayed by the * Browser plugin. * * The description key in the returned array may contain html and will not * be sanitized. * * @return array */ public function getPluginInfo() { return [ 'name' => $this->getPluginName(), 'description' => 'HTML Email delivery (rfc6047) for CalDAV scheduling', 'link' => 'http://github.com/tchapi/davis', ]; } } ================================================ FILE: src/Plugins/PublicAwareDAVACLPlugin.php ================================================ em = $entityManager; $this->public_calendars_enabled = $public_calendars_enabled; } /** * We override this method so that public objects can be seen correctly in the browser, * with the assets (css, images). */ public function beforeMethod(RequestInterface $request, ResponseInterface $response) { $params = $request->getQueryParameters(); if (isset($params['sabreAction']) && 'asset' === $params['sabreAction']) { return; } return parent::beforeMethod($request, $response); } public function getAcl($node): array { // Note: // '{DAV:}unauthenticated' - only unauthenticated users // '{DAV:}all' - all users (both authenticated and unauthenticated) // '{DAV:}authenticated' - only authenticated users $acl = parent::getAcl($node); if ($this->public_calendars_enabled) { // Handle both Calendar AND SharedCalendar (which extends Calendar) if ($node instanceof \Sabre\CalDAV\Calendar || $node instanceof \Sabre\CalDAV\CalendarObject) { // The property is private in \Sabre\CalDAV\CalendarObject and we don't want to create // a new class just to access it, so we use a closure. $calendarInfo = (fn () => $this->calendarInfo)->call($node); // [0] is the calendarId, [1] is the calendarInstanceId $calendarInstanceId = $calendarInfo['id'][1]; $calendar = $this->em->getRepository(CalendarInstance::class)->findOneById($calendarInstanceId); if ($calendar && $calendar->isPublic()) { // Add unauthenticated read access on the object itself $acl[] = [ 'principal' => '{DAV:}unauthenticated', 'privilege' => '{DAV:}read', 'protected' => false, ]; } } } return $acl; } } ================================================ FILE: src/Repository/CalendarInstanceRepository.php ================================================ createQueryBuilder('c') ->leftJoin(Principal::class, 'p', \Doctrine\ORM\Query\Expr\Join::WITH, 'c.principalUri = p.uri') ->where('c.calendar = :id') ->setParameter('id', $calendarId) ->andWhere('c.access NOT IN (:ownerAccess)') ->setParameter('ownerAccess', CalendarInstance::getOwnerAccesses()); if ($withCalendar) { // Returns CalendarInstances as arrays, with displayName and email of the owner return $query->addSelect('p.displayName', 'p.email') ->getQuery() ->getArrayResult(); } // Returns CalendarInstances as objects return $query->getQuery() ->getResult(); } /** * @return CalendarInstance Returns a CalendarInstance object */ public function findSharedInstanceOfInstanceFor(int $calendarId, string $principalUri) { return $this->createQueryBuilder('c') ->where('c.calendar = :id') ->setParameter('id', $calendarId) ->andWhere('c.access NOT IN (:ownerAccess)') ->setParameter('ownerAccess', CalendarInstance::getOwnerAccesses()) ->andWhere('c.principalUri = :principalUri') ->setParameter('principalUri', $principalUri) ->getQuery() ->getOneOrNullResult(); } public function hasDifferentOwner(int $calendarId, string $principalUri): bool { return $this->createQueryBuilder('c') ->select('COUNT(c.id)') ->where('c.calendar = :id') ->setParameter('id', $calendarId) ->andWhere('c.access IN (:ownerAccess)') ->setParameter('ownerAccess', CalendarInstance::getOwnerAccesses()) ->andWhere('c.principalUri != :principalUri') ->setParameter('principalUri', $principalUri) ->getQuery() ->getSingleScalarResult() > 0; } public function findAllSchedulingObjectsForCalendar(int $calendarInstanceId, string $principalUri): array { $objectRepository = $this->getEntityManager()->getRepository(SchedulingObject::class); return $objectRepository->createQueryBuilder('s') ->leftJoin(CalendarObject::class, 'c', \Doctrine\ORM\Query\Expr\Join::WITH, 'c.uri = s.uri') ->leftJoin(CalendarInstance::class, 'ci', \Doctrine\ORM\Query\Expr\Join::WITH, 'ci.calendar = c.calendar') ->where('ci.id = :id') // uri is not unique across calendars — two different calendars can have objects with the same uri. // The join should also filter by principaluri as a consequence ->andWhere('s.principalUri = :principalUri') ->setParameter('id', $calendarInstanceId) ->setParameter('principalUri', $principalUri) ->getQuery() ->getResult(); } /** * Get counts of calendar objects by component type for a calendar instance. * * @param int $calendarId The ID of the calendar * * @return array An associative array with keys 'events', 'notes', 'tasks' containing their respective counts */ public function getObjectCountsByComponentType(int $calendarId): array { $objectRepository = $this->getEntityManager()->getRepository(CalendarObject::class); // Instead of three separate queries, get all counts in a single query $results = $objectRepository->createQueryBuilder('o') ->select('o.componentType, COUNT(o.id) as count') ->where('o.calendar = :calendarId') ->setParameter('calendarId', $calendarId) ->groupBy('o.componentType') ->getQuery() ->getResult(); $componentTypeMap = [ Calendar::COMPONENT_EVENTS => 'events', Calendar::COMPONENT_NOTES => 'notes', Calendar::COMPONENT_TODOS => 'tasks', ]; $counts = [ 'events' => 0, 'notes' => 0, 'tasks' => 0, ]; // Map query results to the expected keys foreach ($results as $result) { if (isset($componentTypeMap[$result['componentType']])) { $counts[$componentTypeMap[$result['componentType']]] = (int) $result['count']; } } return $counts; } } ================================================ FILE: src/Repository/PrincipalRepository.php ================================================ createQueryBuilder('p') ->andWhere('p.isMain = :isMain') ->andWhere('p.uri <> :val') ->setParameter('isMain', true) ->setParameter('val', $principalUri) ->getQuery() ->getResult(); } /** * @return array */ public function findAllMainPrincipalsWithUserIds(): array { return $this->createQueryBuilder('p') ->addSelect('u.id AS userId') ->leftJoin( \App\Entity\User::class, 'u', \Doctrine\ORM\Query\Expr\Join::WITH, 'CONCAT(:prefix, u.username) = p.uri' ) ->andWhere('p.isMain = :isMain') ->setParameter('isMain', true) ->setParameter('prefix', Principal::PREFIX) ->getQuery() ->getResult(); } } ================================================ FILE: src/Security/AdminUser.php ================================================ username = $username; $this->password = $password; } /** * @return (Role|string)[] The user roles */ public function getRoles(): array { return ['ROLE_ADMIN']; } /** * Returns the password used to authenticate the user. */ public function getPassword(): string { return $this->password; } /** * Returns the salt that was originally used to encode the password. * * This can return null if the password was not encoded using a salt. * * @return string|null The salt */ public function getSalt() { return null; } /** * Returns the username used to authenticate the user. * * @return string The username */ public function getUsername() { return $this->username; } public function getUserIdentifier(): string { return $this->username; } /** * Removes sensitive data from the user. * * This is important if, at any given point, sensitive information like * the plain-text password is stored on this object. */ public function eraseCredentials(): void { } } ================================================ FILE: src/Security/AdminUserProvider.php ================================================ apiKey = $apiKey; } public function supports(Request $request): ?bool { // Skip authentication for public health endpoint if (preg_match('#^/api/v1/health$#', $request->getPathInfo())) { return false; } // Always attempt to authenticate even if no API token is provided in the request // This stops the login page from being shown when accessing API routes return true; } public function authenticate(Request $request): Passport { $apiToken = $request->headers->get('X-Davis-API-Token'); if (null === $apiToken) { throw new CustomUserMessageAuthenticationException('Missing X-Davis-API-Token header'); } if (false === hash_equals($this->apiKey, $apiToken)) { throw new CustomUserMessageAuthenticationException('Invalid X-Davis-API-Token header'); } return new SelfValidatingPassport(new UserBadge('X-DAVIS-API')); } public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response { return null; } public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response { $data = [ 'status' => 'error', 'message' => $exception->getMessage(), 'timestamp' => date('c'), ]; return new JsonResponse($data, Response::HTTP_UNAUTHORIZED); } } ================================================ FILE: src/Security/LoginFormAuthenticator.php ================================================ urlGenerator = $urlGenerator; $this->csrfTokenManager = $csrfTokenManager; $this->adminLogin = $adminLogin; $this->adminPassword = $adminPassword; } protected function getLoginUrl(Request $request): string { return $this->urlGenerator->generate('app_login'); } public function supports(Request $request): bool { return 'app_login' === $request->attributes->get('_route') && $request->isMethod('POST'); } public function authenticate(Request $request): Passport { $credentials = [ 'username' => $request->request->get('_username'), 'password' => $request->request->get('_password'), 'csrf_token' => $request->request->get('_csrf_token'), ]; $request->getSession()->set( SecurityRequestAttributes::LAST_USERNAME, $credentials['username'] ); if ($credentials['username'] !== $this->adminLogin) { // fail authentication with a custom error throw new CustomUserMessageAuthenticationException('Username could not be found.'); } if ($credentials['password'] !== $this->adminPassword) { // fail authentication with a custom error throw new CustomUserMessageAuthenticationException('Invalid credentials.'); } return new SelfValidatingPassport( new UserBadge($this->adminLogin), [new CsrfTokenBadge('authenticate', $credentials['csrf_token'])] ); } public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey): ?Response { if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) { return new RedirectResponse($targetPath); } return new RedirectResponse($this->urlGenerator->generate('dashboard')); } } ================================================ FILE: src/Services/BasicAuth.php ================================================ utils = $utils; $this->doctrine = $doctrine; } protected function validateUserPass($username, $password): bool { $user = $this->doctrine->getRepository(User::class)->findOneByUsername($username); if (!$user) { return false; } if ('$2y$' === substr($user->getPassword(), 0, 4)) { // Use password_verify with secure passwords return password_verify($password, $user->getPassword()); } // Use unsecure legacy password hashing (from legacy sabre/dav implementation) return $user->getPassword() === $this->utils->hashPassword($username, $password); } } ================================================ FILE: src/Services/BirthdayService.php ================================================ calendarBackend = $calendarBackend; } public function onCardChanged(int $addressBookId, string $cardUri, string $cardData): void { $book = $this->doctrine->getRepository(AddressBook::class)->findOneById($addressBookId); if (!$book->isIncludedInBirthdayCalendar()) { return; } $principalUri = $book->getPrincipalUri(); $calendarInstance = $this->ensureBirthdayCalendarExists($principalUri); $this->updateCalendar($cardUri, $cardData, $book, $calendarInstance); } public function onCardDeleted(int $addressBookId, string $cardUri): void { $book = $this->doctrine->getRepository(AddressBook::class)->findOneById($addressBookId); if (!$book->isIncludedInBirthdayCalendar()) { return; } $principalUri = $book->getPrincipalUri(); $calendarInstance = $this->ensureBirthdayCalendarExists($principalUri); $objectUri = $book->getUri().'-'.$cardUri.'.ics'; $calendar = $calendarInstance->getCalendar(); // This is the structure that needs to be passed to the backend methods $calendarId = [$calendar->getId(), $calendarInstance->getId()]; $this->calendarBackend->deleteCalendarObject( $calendarId, $objectUri ); } public function shouldBirthdayCalendarExist(string $principalUri): bool { $addressbooks = $this->doctrine->getRepository(AddressBook::class)->findByPrincipalUri($principalUri); return array_reduce($addressbooks, function ($carry, $addressbook) { return $carry || $addressbook->isIncludedInBirthdayCalendar(); }, false); } public function ensureBirthdayCalendarExists(string $principalUri): CalendarInstance { $instance = $this->doctrine->getRepository(CalendarInstance::class)->findOneBy(['principalUri' => $principalUri, 'uri' => Constants::BIRTHDAY_CALENDAR_URI]); if ($instance) { return $instance; } $em = $this->doctrine->getManager(); $calendar = new Calendar(); $em->persist($calendar); $instance = (new CalendarInstance()) ->setPrincipalUri($principalUri) ->setDisplayName('🎁 Birthdays') ->setDescription('Birthdays') ->setAccess(SharingPlugin::ACCESS_READ) ->setCalendarOrder(0) ->setCalendar($calendar) ->setTransparent(1) ->setShareInviteStatus(SharingPlugin::INVITE_ACCEPTED) ->setUri(Constants::BIRTHDAY_CALENDAR_URI); $em->persist($instance); $em->flush(); return $instance; } public function deleteBirthdayCalendar(string $principalUri): void { $instance = $this->doctrine->getRepository(CalendarInstance::class)->findOneBy(['principalUri' => $principalUri, 'uri' => Constants::BIRTHDAY_CALENDAR_URI]); if (!$instance) { return; } $em = $this->doctrine->getManager(); $em->remove($instance); $em->remove($instance->getCalendar()); $em->flush(); } /** * @throws InvalidDataException */ public function buildDataFromContact(string $cardData): ?VCalendar { if (empty($cardData)) { return null; } try { $doc = Reader::read($cardData); // We're always converting to vCard 4.0 so we can rely on the // VCardConverter handling the X-APPLE-OMIT-YEAR property for us. if (!$doc instanceof VCard) { return null; } $doc = $doc->convert(Document::VCARD40); } catch (\Exception $e) { return null; } if (!isset($doc->BDAY) || !isset($doc->FN)) { return null; } $birthday = $doc->BDAY; if (!(string) $birthday) { return null; } // Skip if the BDAY property is not of the right type. if (!$birthday instanceof DateAndOrTime) { return null; } // Skip if we can't parse the BDAY value. try { $dateParts = DateTimeParser::parseVCardDateTime($birthday->getValue()); } catch (InvalidDataException $e) { return null; } if (null !== $dateParts['year']) { $parameters = $birthday->parameters(); $omitYear = (isset($parameters['X-APPLE-OMIT-YEAR']) && $parameters['X-APPLE-OMIT-YEAR'] === $dateParts['year']); // 'X-APPLE-OMIT-YEAR' is not always present, at least iOS 12.4 uses the hard coded date of 1604 (the start of the gregorian calendar) when the year is unknown if ($omitYear || 1604 === (int) $dateParts['year']) { $dateParts['year'] = null; } } $originalYear = null; if (null !== $dateParts['year']) { $originalYear = (int) $dateParts['year']; } try { if ($birthday instanceof DateAndOrTime) { $date = $birthday->getDateTime(); } else { $date = new \DateTimeImmutable($birthday); } } catch (\Exception $e) { return null; } $summary = '🎂 '.$doc->FN->getValue().($originalYear ? (' ('.$originalYear.')') : ''); $vCal = new VCalendar(); $vCal->VERSION = '2.0'; $vCal->PRODID = '-//IDN davis//Birthday calendar//EN'; $vEvent = $vCal->createComponent('VEVENT'); $vEvent->add('DTSTART'); $vEvent->DTSTART->setDateTime( $date ); $vEvent->DTSTART['VALUE'] = 'DATE'; $vEvent->add('DTEND'); $dtEndDate = (new \DateTime())->setTimestamp($date->getTimeStamp()); $dtEndDate->add(new \DateInterval('P1D')); $vEvent->DTEND->setDateTime( $dtEndDate ); $vEvent->DTEND['VALUE'] = 'DATE'; $vEvent->{'UID'} = $doc->UID; $leapDay = (2 === (int) $dateParts['month'] && 29 === (int) $dateParts['date']); if (null === $dateParts['year'] || $originalYear < 1970) { $birthday = ($leapDay ? '1972-' : '1970-') .$dateParts['month'].'-'.$dateParts['date']; } if ($leapDay) { /* Sabre\VObject supports BYMONTHDAY only if BYMONTH * is also set */ $vEvent->{'RRULE'} = 'FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=-1'; } else { $vEvent->{'RRULE'} = 'FREQ=YEARLY'; } $vEvent->{'SUMMARY'} = $summary; $vEvent->{'TRANSP'} = 'TRANSPARENT'; // Set a reminder, if needed if ('false' !== strtolower($this->birthdayReminderOffset)) { $alarm = $vCal->createComponent('VALARM'); $alarm->add($vCal->createProperty('TRIGGER', $this->birthdayReminderOffset, ['VALUE' => 'DURATION'])); $alarm->add($vCal->createProperty('ACTION', 'DISPLAY')); $alarm->add($vCal->createProperty('DESCRIPTION', $vEvent->{'SUMMARY'})); $vEvent->add($alarm); } $vCal->add($vEvent); return $vCal; } public function resetForPrincipal(string $principal): void { $calendarInstance = $this->doctrine->getRepository(CalendarInstance::class)->findOneBy(['principalUri' => $principal, 'uri' => Constants::BIRTHDAY_CALENDAR_URI]); if (!$calendarInstance) { return; // The user's birthday calendar doesn't exist, no need to purge it } $calendarObjects = $this->doctrine->getRepository(CalendarObject::class)->findByCalendar($calendarInstance->getCalendar()); $em = $this->doctrine->getManager(); foreach ($calendarObjects as $calendarObject) { $em->remove($calendarObject); } $em->flush(); } public function syncUser(string $username): void { $this->syncPrincipal(Principal::PREFIX.$username); } public function syncPrincipal(string $principal): void { if (!$this->shouldBirthdayCalendarExist($principal)) { $this->deleteBirthdayCalendar($principal); return; } $calendarInstance = $this->ensureBirthdayCalendarExists($principal); // Reset the calendar $this->resetForPrincipal($principal); // Get all address books that should be included and iterate $addressbooks = $this->doctrine->getRepository(AddressBook::class)->findBy(['principalUri' => $principal, 'includedInBirthdayCalendar' => true]); foreach ($addressbooks as $book) { $cards = $this->doctrine->getRepository(Card::class)->findByAddressBook($book); foreach ($cards as $card) { $this->onCardChanged($book->getId(), $card->getUri(), $card->getCardData()); } } } public function birthdayEventChanged(string $existingCalendarData, VCalendar $newCalendarData): bool { try { $existingBirthday = Reader::read($existingCalendarData); } catch (\Exception $ex) { return true; } return $newCalendarData->VEVENT->DTSTART->getValue() !== $existingBirthday->VEVENT->DTSTART->getValue() || $newCalendarData->VEVENT->SUMMARY->getValue() !== $existingBirthday->VEVENT->SUMMARY->getValue() ; } /** * @throws InvalidDataException */ private function updateCalendar(string $cardUri, string $cardData, AddressBook $book, CalendarInstance $calendarInstance): void { $objectUid = $book->getUri().'-'.$cardUri; $objectUri = $objectUid.'.ics'; $calendarData = $this->buildDataFromContact($cardData); $calendar = $calendarInstance->getCalendar(); // This is the structure that needs to be passed to the backend methods $calendarId = [$calendar->getId(), $calendarInstance->getId()]; $existing = $this->doctrine->getRepository(CalendarObject::class)->findOneBy(['calendar' => $calendar, 'uri' => $objectUri]); if (null === $calendarData) { if (null !== $existing) { $this->calendarBackend->deleteCalendarObject( [$calendar->getId(), $calendarInstance->getId()], $objectUri ); } } else { if (null === $existing) { $this->calendarBackend->createCalendarObject( [$calendar->getId(), $calendarInstance->getId()], $objectUri, $calendarData->serialize() ); } else { if ($this->birthdayEventChanged($existing->getCalendarData(), $calendarData)) { $this->calendarBackend->updateCalendarObject( [$calendar->getId(), $calendarInstance->getId()], $objectUri, $calendarData->serialize() ); } } } } } ================================================ FILE: src/Services/IMAPAuth.php ================================================ getMessage()); } $this->IMAPHost = $components['host'] ?? null; // Trying to choose the best port if it was not provided, // defaulting to 993 (secure) if (isset($components['port'])) { $this->IMAPPort = $components['port']; } elseif (false === $this->IMAPEncryptionMethod) { $this->IMAPPort = 143; } else { $this->IMAPPort = 993; } // We're making sure that only ssl, tls or 'false' are passed down to the IMAP client, // defaulting to SSL $IMAPEncryptionMethodCleaned = strtolower($IMAPEncryptionMethod); if ('false' === $IMAPEncryptionMethodCleaned) { $this->IMAPEncryptionMethod = false; } elseif ('tls' === $IMAPEncryptionMethodCleaned) { $this->IMAPEncryptionMethod = 'tls'; } else { $this->IMAPEncryptionMethod = 'ssl'; } $this->IMAPCertificateValidation = $IMAPCertificateValidation; $this->autoCreate = $autoCreate; $this->doctrine = $doctrine; $this->utils = $utils; } /** * Connects to an IMAP server and tries to authenticate. * If the user does not exist, create it (depending on the autoCreate flag). */ protected function imapOpen(string $username, string $password): bool { $cm = new ClientManager($options = []); // Create a new instance of the IMAP client manually $client = $cm->make([ 'host' => $this->IMAPHost, 'port' => $this->IMAPPort, 'encryption' => $this->IMAPEncryptionMethod, 'validate_cert' => $this->IMAPCertificateValidation, 'username' => $username, 'password' => $password, 'protocol' => 'imap', ]); try { $client->connect(); $client->disconnect(); $success = true; } catch (\Exception $e) { error_log('IMAP Error (connection): '.$e->getMessage()); $success = false; } // Auto-create the user if it does not already exist in the database if ($success && $this->autoCreate) { $user = $this->doctrine->getRepository(User::class)->findOneBy(['username' => $username]); if (!$user) { // We only have a username, so we use it for displayname and email $this->utils->createPasswordlessUserWithDefaultObjects($username, $username, $username); $em = $this->doctrine->getManager(); try { $em->flush(); } catch (\Exception $e) { error_log('IMAP Error (flush): '.$e->getMessage()); } } } return $success; } /** * Validates a username and password by trying to authenticate against IMAP. */ protected function validateUserPass($username, $password): bool { return $this->imapOpen($username, $password); } } ================================================ FILE: src/Services/LDAPAuth.php ================================================ LDAPAuthUrl = $LDAPAuthUrl; $this->LDAPDnPattern = $LDAPDnPattern; $this->LDAPMailAttribute = $LDAPMailAttribute ?? 'mail'; $this->autoCreate = $autoCreate; $this->LDAPCertificateCheckingStrategy = $LDAPCertificateCheckingStrategy ?? 'try'; $this->doctrine = $doctrine; $this->utils = $utils; } /** * Connects to an LDAP server and tries to authenticate. * * @param string $username * @param string $password * * @return bool */ protected function ldapOpen($username, $password) { switch ($this->LDAPCertificateCheckingStrategy) { case 'never': $cert_strategy = LDAP_OPT_X_TLS_NEVER; break; case 'hard': $cert_strategy = LDAP_OPT_X_TLS_HARD; break; case 'demand': $cert_strategy = LDAP_OPT_X_TLS_DEMAND; break; case 'allow': $cert_strategy = LDAP_OPT_X_TLS_ALLOW; break; case 'try': $cert_strategy = LDAP_OPT_X_TLS_TRY; break; default: error_log('Invalid certificate checking strategy: '.$this->LDAPCertificateCheckingStrategy); return false; } if (false === ldap_set_option(null, LDAP_OPT_X_TLS_REQUIRE_CERT, $cert_strategy)) { error_log('LDAP Error (ldap_set_option with '.$cert_strategy.'): failed'); return false; } try { $ldap = ldap_connect($this->LDAPAuthUrl); } catch (\Exception $e) { error_log('LDAP Error (ldap_connect with '.$this->LDAPAuthUrl.'): '.$e->getMessage()); return false; } if (false === $ldap) { error_log('LDAP Error (ldap_connect with '.$this->LDAPAuthUrl.'): provided LDAP URI does not seems plausible'); return false; } if (!ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3)) { error_log('LDAP Error (ldap_set_option): could not set LDAP_OPT_PROTOCOL_VERSION to 3'); return false; } // Extract user and domain from username (in the form user@domain.org) $user_parts = explode('@', $username, 2); $ldap_user = $user_parts[0]; if (count($user_parts) > 1) { $ldap_domain = $user_parts[1]; } else { $ldap_domain = ''; } // Replace common placeholders $dn = str_replace(['%u', '%U', '%d'], [$username, $ldap_user, $ldap_domain], $this->LDAPDnPattern); // Replace domain parts $domain_split = array_reverse(explode('.', $ldap_domain)); for ($i = 1; $i <= count($domain_split) and $i <= 9; ++$i) { $dn = str_replace('%'.$i, $domain_split[$i - 1], $dn); } $success = false; try { $bind = ldap_bind($ldap, $dn, $password); if ($bind) { $success = true; } } catch (\Exception $e) { error_log('LDAP Error (ldap_bind to '.$this->LDAPAuthUrl.'): '.ldap_error($ldap).' ('.ldap_errno($ldap).')'); } if ($success && $this->autoCreate) { $user = $this->doctrine->getRepository(User::class)->findOneBy(['username' => $username]); if (!$user) { // Default fallback values $displayName = $username; $email = $username; // Try to extract display name and email for this user. // NB: We suppose display name is `cn` (email is configurable, generally `mail`) try { $search_results = ldap_read($ldap, $dn, '(objectclass=*)', ['cn', $this->LDAPMailAttribute]); } catch (\Exception $e) { $search_results = false; // Probably a "No such object" error, ignore and use available credentials (username) } if (false !== $search_results) { $entry = ldap_get_entries($ldap, $search_results); if (false !== $entry) { if (!empty($entry[0]['cn'])) { $displayName = $entry[0]['cn'][0]; } if (!empty($entry[0][$this->LDAPMailAttribute])) { $email = $entry[0][$this->LDAPMailAttribute][0]; } } } $this->utils->createPasswordlessUserWithDefaultObjects($username, $displayName, $email); $em = $this->doctrine->getManager(); try { $em->flush(); } catch (\Exception $e) { error_log('LDAP Error (flush): '.$e->getMessage()); } } } if (isset($ldap) && $ldap) { ldap_close($ldap); } return $success; } /** * Validates a username and password by trying to authenticate against LDAP. * * @param string $username * @param string $password */ protected function validateUserPass($username, $password): bool { return $this->ldapOpen($username, $password); } } ================================================ FILE: src/Services/Utils.php ================================================ authRealm = $authRealm ?? User::DEFAULT_AUTH_REALM; $this->trans = $trans; $this->doctrine = $doctrine; } /** * Hash a password according to the realm. * Important note: It is very insecure and this is used only for the legacy sabre/dav implementation. */ public function hashPassword(string $username, string $password): string { return md5($username.':'.$this->authRealm.':'.$password); } public function createPasswordlessUserWithDefaultObjects(string $username, string $displayName, string $email) { $user = new User(); $user->setUsername($username); // Set the password to a random string (but hashed beforehand) $randomBytes = substr(bin2hex(random_bytes(256)), 0, 48); $hash = password_hash($randomBytes, PASSWORD_DEFAULT); $user->setPassword($hash); // Create principal, default calendar and addressbook $principal = new Principal(); $principal->setUri(Principal::PREFIX.$username) ->setDisplayName($displayName) ->setEmail($email) ->setIsAdmin(false); $calendarInstance = new CalendarInstance(); $calendar = new Calendar(); $calendarInstance->setPrincipalUri(Principal::PREFIX.$username) ->setUri('default') // No risk of collision since unicity is guaranteed by the new user principal ->setDisplayName($this->trans->trans('default.calendar.title')) ->setDescription($this->trans->trans('default.calendar.description', ['user' => $displayName])) ->setCalendar($calendar); // Enable delegation by default $principalProxyRead = new Principal(); $principalProxyRead->setUri($principal->getUri().Principal::READ_PROXY_SUFFIX) ->setIsMain(false); $principalProxyWrite = new Principal(); $principalProxyWrite->setUri($principal->getUri().Principal::WRITE_PROXY_SUFFIX) ->setIsMain(false); $addressbook = new AddressBook(); $addressbook->setPrincipalUri(Principal::PREFIX.$username) ->setUri('default') // No risk of collision since unicity is guaranteed by the new user principal ->setDisplayName($this->trans->trans('default.addressbook.title')) ->setDescription($this->trans->trans('default.addressbook.description', ['user' => $displayName])); // Persist all items $em = $this->doctrine->getManager(); $em->persist($principalProxyRead); $em->persist($principalProxyWrite); $em->persist($calendarInstance); $em->persist($addressbook); $em->persist($principal); $em->persist($user); } } ================================================ FILE: src/Version.php ================================================ ================================================ FILE: templates/_partials/back_button.html.twig ================================================ ================================================ FILE: templates/_partials/delegate_row.html.twig ================================================
{{ delegate.displayName }} ‹{{ delegate.email }}› {% if has_write %} {{ "delegates.write"|trans }} {% else %} {{ "delegates.readonly"|trans }} {% endif %}

{{ "users.username"|trans }} : {{ delegate.username }}

{{ "users.uri"|trans }} : {{ delegate.uri }}
================================================ FILE: templates/_partials/delete_modal.html.twig ================================================ ================================================ FILE: templates/_partials/flashes.html.twig ================================================
{% for label, messages in app.flashes %} {% for message in messages %} {% endfor %} {% endfor %}
================================================ FILE: templates/_partials/navigation.html.twig ================================================ ================================================ FILE: templates/_partials/share_modal.html.twig ================================================ ================================================ FILE: templates/addressbooks/edit.html.twig ================================================ {% extends 'base.html.twig' %} {% set menu = 'resources' %} {% block body %} {% include '_partials/back_button.html.twig' with { url: path('addressbook_index', {userId: userId}), text: "addressbooks.back"|trans({'user': principal.displayName }) } %} {% if addressbook.id %}

{{ "addressbooks.edit"|trans({'name': addressbook.displayName }) }}

{% else %}

{{ "addressbooks.new"|trans }} for {{ principal.displayName }}

{% endif %} {{ form(form) }} {% endblock %} ================================================ FILE: templates/addressbooks/index.html.twig ================================================ {% extends 'base.html.twig' %} {% set menu = 'resources' %} {% block body %} {% include '_partials/back_button.html.twig' with { url: path('user_index'), text: "users.back"|trans } %}

{{ "addressbooks.for"|trans({'who': principal.displayName}) }} + {{ "addressbooks.new"|trans }}

{% for addressbook in addressbooks %}
{{ addressbook.displayName }}

{{ addressbook.description }}

{{ "addressbooks.uri"|trans }} : {{ addressbook.uri }} — {{ "addressbooks.contacts"|trans({'%count%': addressbook.cards|length}) }}
{% endfor %}
{% include '_partials/delete_modal.html.twig' with {flavour: 'addressbooks'} %} {% endblock %} ================================================ FILE: templates/base.html.twig ================================================ {% block title %}Davis{% endblock %} {% include '_partials/navigation.html.twig' %} {% include '_partials/flashes.html.twig' %}
{% block body %}{% endblock %}
================================================ FILE: templates/calendars/edit.html.twig ================================================ {% extends 'base.html.twig' %} {% set menu = 'resources' %} {% block body %} {% include '_partials/back_button.html.twig' with { url: path('calendar_index', {userId: userId}), text: "calendars.back"|trans({'user': principal.displayName }) } %} {% if calendar.id %}

{{ "calendars.edit"|trans({'name': calendar.displayName }) }}

{% else %}

{{ "calendars.new"|trans }} for {{ principal.displayName }}

{% endif %} {{ form(form) }} {% endblock %} ================================================ FILE: templates/calendars/index.html.twig ================================================ {% extends 'base.html.twig' %} {% set menu = 'resources' %} {% block body %} {% include '_partials/back_button.html.twig' with { url: path('user_index'), text: "users.back"|trans } %}

{{ "calendars.for"|trans({'who': principal.displayName}) }} + {{ "calendars.new"|trans }}

{% for compoundObject in calendars %} {% set calendar = compoundObject.entity %} {% set davUri = compoundObject.uri %}
{{ calendar.displayName }} {% if calendar.isPublic() %} {{ ('calendar.public')|trans }} {% endif %}  
{% if not calendar.isPublic() %} {% endif %} ✎ {{ "edit"|trans }} ⚠ {{ "delete"|trans }}

{{ calendar.description }}

{% if calendar.calendar.components|split(',')|length > 0 %} {% if constant('\\App\\Entity\\Calendar::COMPONENT_EVENTS') in calendar.calendar.components %}{{ "calendars.component.events"|trans }}{% endif %} {% if constant('\\App\\Entity\\Calendar::COMPONENT_NOTES') in calendar.calendar.components %}{{ "calendars.component.notes"|trans }}{% endif %} {% if constant('\\App\\Entity\\Calendar::COMPONENT_TODOS') in calendar.calendar.components %}{{ "calendars.component.todos"|trans }}{% endif %} {% endif %} — {{ "calendars.entries"|trans({'%count%': calendar.calendar.objects|length}) }}
{% if not calendar.isPublic() %} {% endif %} ✎ {{ "edit"|trans }} ⚠ {{ "delete"|trans }}
{% endfor %}
{% if shared|length > 0 %}

{{ "calendars.shared.with"|trans({'who': principal.displayName}) }}

{% for compoundObject in shared %} {% set calendar = compoundObject.entity %} {% set davUri = compoundObject.uri %}
{{ calendar.displayName }} {% if calendar.access == constant('Sabre\\DAV\\Sharing\\Plugin::ACCESS_READWRITE') %} {{ ('calendar.share_access.' ~ calendar.access)|trans }} {% else %} {{ ('calendar.share_access.' ~ calendar.access)|trans }} {% endif %}  

{{ calendar.description }}

{% if calendar.calendar.components|split(',')|length > 0 %} {% if constant('\\App\\Entity\\Calendar::COMPONENT_EVENTS') in calendar.calendar.components %}{{ "calendars.component.events"|trans }}{% endif %} {% if constant('\\App\\Entity\\Calendar::COMPONENT_NOTES') in calendar.calendar.components %}{{ "calendars.component.notes"|trans }}{% endif %} {% if constant('\\App\\Entity\\Calendar::COMPONENT_TODOS') in calendar.calendar.components %}{{ "calendars.component.todos"|trans }}{% endif %} {% endif %} — {{ "calendars.entries"|trans({'%count%': calendar.calendar.objects|length}) }}
{% endfor %}
{% include '_partials/delete_modal.html.twig' with {flavour: 'revoke'} %} {% endif %} {% if auto|length > 0 %}

{{ "calendars.auto"|trans }}

{% for compoundObject in auto %} {% set calendar = compoundObject.entity %} {% set davUri = compoundObject.uri %}
{{ calendar.displayName }} {{ ('calendar.auto')|trans }}  

{{ calendar.description }}

{% if calendar.calendar.components|split(',')|length > 0 %} {% if constant('\\App\\Entity\\Calendar::COMPONENT_EVENTS') in calendar.calendar.components %}{{ "calendars.component.events"|trans }}{% endif %} {% if constant('\\App\\Entity\\Calendar::COMPONENT_NOTES') in calendar.calendar.components %}{{ "calendars.component.notes"|trans }}{% endif %} {% if constant('\\App\\Entity\\Calendar::COMPONENT_TODOS') in calendar.calendar.components %}{{ "calendars.component.todos"|trans }}{% endif %} {% endif %} — {{ "calendars.entries"|trans({'%count%': calendar.calendar.objects|length}) }}
{% endfor %}
{% endif %} {% if subscriptions|length > 0 %}

{{ "calendars.subscriptions"|trans }}

{% for subscription in subscriptions %}
{{ subscription.displayName }} {{ ('calendar.subscription')|trans }}  
{{ subscription.source }}
{% endfor %}
{% endif %} {% include '_partials/share_modal.html.twig' with {principals: allPrincipals} %} {% include '_partials/delete_modal.html.twig' with {flavour: 'calendars'} %} {% endblock %} ================================================ FILE: templates/dashboard.html.twig ================================================ {% extends 'base.html.twig' %} {% set menu = 'dashboard' %} {% block body %}

{{ "title.dashboard"|trans }}

{{ "dashboard.capabilities"|trans }}

    {% if calDAVEnabled %}
  • CalDAV {{ "enabled"|trans }}
  • {% else %}
  • CalDAV {{ "disabled"|trans }}
  • {% endif %} {% if cardDAVEnabled %}
  • CardDAV {{ "enabled"|trans }}
  • {% else %}
  • CardDAV {{ "disabled"|trans }}
  • {% endif %} {% if webDAVEnabled %}
  • WebDAV {{ "enabled"|trans }}
  • {% else %}
  • WebDAV {{ "disabled"|trans }}
  • {% endif %}

{{ "dashboard.env"|trans }}

  • {{ "dashboard.version"|trans }} : {{ version }} (SabreDAV {{ sabredav_version }})
  • {{ "dashboard.auth"|trans }} : {{ authMethod }}{% if authMethod == 'Basic' %} ({{ "dashboard.auth_realm"|trans }}: {{ authRealm }}){% endif %}
  • {{ "dashboard.invite_from_address"|trans }} : {{ invite_from_address|default('Not set') }}
  • {{ "dashboard.server_timezone"|trans }} : {{ timezone.actual_default }} {% if timezone.not_set_in_app %}{{ "dashboard.no_timezone_configuration"|trans }}{% endif %} {% if timezone.bad_value %}{{ "dashboard.bad_timezone_configuration"|trans }}{% endif %}

{{ "dashboard.objects"|trans }}

  • {{ "dashboard.users"|trans }} {{ users|length }}
  • {{ "dashboard.calendars"|trans }} {{ "dashboard.calendars.help"|trans }}
    {{ calendars|length }}
  • ↳ {{ "dashboard.events"|trans }} {{ events|length }}
  • {{ "dashboard.address_books"|trans }} {{ addressbooks|length }}
  • ↳ {{ "dashboard.contacts"|trans }} {{ contacts|length }}
{% endblock %} ================================================ FILE: templates/index.html.twig ================================================ {% block title %}Davis{% endblock %}

{{ "davis"|trans }}

  • CalDAV {% if calDAVEnabled %}{{ "enabled"|trans }}{% else %}{{ "disabled"|trans }}{% endif %}
  • CardDAV {% if cardDAVEnabled %}{{ "enabled"|trans }}{% else %}{{ "disabled"|trans }}{% endif %}
  • WebDAV {% if webDAVEnabled %}{{ "enabled"|trans }}{% else %}{{ "disabled"|trans }}{% endif %}
{{ "admin.interface"|trans }}
================================================ FILE: templates/mails/scheduling.html.twig ================================================ Calendar notification from {{ senderName }}
{{ dateTime|date('j') }} {{ dateTime|date('l') }}
{{ dateTime|date('F') }}
{% if action == 'REQUEST' %} {{ senderName }} invited you to

{{ summary }}

{% elseif action == 'CANCEL' %}

{{ summary }}

has been canceled. {% elseif action == 'ACCEPTED' %} {{ senderName }} accepted your invitation to

{{ summary }}

{% elseif action == 'TENTATIVE' %} {{ senderName }} tentatively accepted your invitation to

{{ summary }}

{% elseif action == 'DECLINED' %} {{ senderName }} declined your invitation to

{{ summary }}

{% endif %}
{% if not allDay %} {% endif %} {% if action != 'CANCEL' %} {% endif %} {% if location %} {% endif %} {% if locationImageDataAsBase64 %} {% endif %} {% if url %} {% endif %} {% if description %} {% endif %}
When? {{ dateTime|date("l, F j\\<\\s\\u\\p\\>S\\<\\/\\s\\u\\p\\> Y")|raw }}
{{ dateTime|date('g:ia T') }}
Attendees: {% for attendee in attendees %} {{ attendee.cn }} {% if attendee.role == 'CHAIR' %}(organizer){% endif %}
{% endfor %}
Where? {{ location }}
URL: {{ url }}
Notes: {{ description }}
Mail sent by {{ app.request.getSchemeAndHttpHost() }}
================================================ FILE: templates/mails/scheduling.txt.twig ================================================ Calendar notification from {{ senderName }}. ----------------------------------------------------------- {% if action == 'REQUEST' %} **{{ senderName }}** invited you to “{{ summary }}”. {% elseif action == 'CANCEL' %} “{{ summary }}” has been canceled. {% elseif action == 'ACCEPTED' %} **{{ senderName }}** accepted your invitation to “{{ summary }}”. {% elseif action == 'TENTATIVE' %} **{{ senderName }}** tentatively accepted your invitation to “{{ summary }}”. {% elseif action == 'DECLINED' %} **{{ senderName }}** declined your invitation to “{{ summary }}”. {% endif %} ----------------------------------------------------------- When? {{ dateTime|date('l, F jS Y') }} {% if not allDay %} {{ dateTime|date('g:ia T') }} {% endif %} {% if action != 'CANCEL' %} Attendees: {% for attendee in attendees %} {{ attendee.cn }} <{{ attendee.email }}> {% if attendee.role == 'CHAIR' %}(organizer){% endif %} {% endfor %} {% endif %} {% if location %} Where? {{ location|replace({"\n": "\n" ~ ' '}) }} {% endif %} {% if url %} URL: {{ url }} {% endif %} {% if description %} Notes: {{ description|replace({"\n": "\n" ~ ' '}) }} {% endif %} ----------------------------------------------------------- Mail sent by {{ app.request.getSchemeAndHttpHost() }} ================================================ FILE: templates/security/login.html.twig ================================================ {% extends 'base.html.twig' %} {% set menu = null %} {% block body %} {% if app.user %}
{{ "login.already"|trans({username: app.user.username}) }}, {{ "logout"|trans }}
{% else %}
{% if error %}
{{ error.messageKey|trans(error.messageData, 'security') }}
{% endif %}

{{ "login.signin"|trans }}

{% endif %} {% endblock %} ================================================ FILE: templates/users/delegates.html.twig ================================================ {% extends 'base.html.twig' %} {% set menu = 'resources' %} {% block body %} {% include '_partials/back_button.html.twig' with { url: path('user_index'), text: "users.back"|trans } %}

{{ "calendars.delegates.for"|trans({'what': principal.displayName}) }} {% if delegation %} + {{ "calendars.delegates.add"|trans }} {% endif %}

{% if delegation %} {% for delegate in principalProxyRead.delegees %} {% include '_partials/delegate_row.html.twig' with {has_write: false} %} {% endfor %} {% for delegate in principalProxyWrite.delegees %} {% include '_partials/delegate_row.html.twig' with {has_write: true} %} {% endfor %} {% else %} {% endif %}
{% include '_partials/delete_modal.html.twig' with {flavour: 'delegates'} %} {% include '_partials/add_delegate_modal.html.twig' with {principals: allPrincipals} %} {% endblock %} ================================================ FILE: templates/users/edit.html.twig ================================================ {% extends 'base.html.twig' %} {% set menu = 'resources' %} {% block body %} {% include '_partials/back_button.html.twig' with { url: path('user_index'), text: "users.back"|trans } %} {% if username %}

{{ "users.edit"|trans({'username': username }) }}

{% else %}

{{ "users.new"|trans }}

{% endif %} {{ form(form) }} {% endblock %} ================================================ FILE: templates/users/index.html.twig ================================================ {% extends 'base.html.twig' %} {% set menu = 'resources' %} {% block body %}

{{ "title.users_and_resources"|trans }}+ {{ "users.new"|trans }}

{% for result in results %} {% set principal = result[0] %} {% set userId = result.userId %} {% else %}
{{ "no.users.yet"|trans }}
{% endfor %}
{% include '_partials/delete_modal.html.twig' with {flavour: 'users'} %} {% endblock %} ================================================ FILE: tests/.gitignore ================================================ ================================================ FILE: tests/Functional/Commands/SyncBirthdayCalendarTest.php ================================================ em = static::getContainer()->get(EntityManagerInterface::class); $birthdayService = static::getContainer()->get(BirthdayService::class); $pdo = $this->em->getConnection()->getNativeConnection(); $birthdayService->setBackend(new CalendarBackend($pdo)); $application = new Application(self::$kernel); $command = $application->find('dav:sync-birthday-calendar'); $this->commandTester = new CommandTester($command); $this->em->getConnection()->beginTransaction(); } protected function tearDown(): void { $this->em->getConnection()->rollBack(); parent::tearDown(); } private function createUser(string $username): User { $user = (new User()) ->setUsername($username) ->setPassword('hashed'); $this->em->persist($user); $principal = (new Principal()) ->setUri(Principal::PREFIX.$username) ->setEmail($username.'@example.com') ->setDisplayName($username); $this->em->persist($principal); $this->em->flush(); return $user; } private function createAddressBookWithCard(string $username, string $cardUri, string $cardData): AddressBook { $addressBook = (new AddressBook()) ->setPrincipalUri(Principal::PREFIX.$username) ->setUri('default') ->setDisplayName('Default') ->setDescription('') ->setSynctoken('1') ->setIncludedInBirthdayCalendar(true); $this->em->persist($addressBook); $card = (new Card()) ->setAddressBook($addressBook) ->setUri($cardUri) ->setCarddata($cardData) ->setLastmodified(time()) ->setSize(strlen($cardData)) ->setEtag(md5($cardData)); $this->em->persist($card); $this->em->flush(); return $addressBook; } private function assertBirthdayEventExists(string $principalUri, string $addressBookUri, string $cardUri, string $expectedNameFragment): void { $instance = $this->em->getRepository(CalendarInstance::class)->findOneBy([ 'principalUri' => $principalUri, 'uri' => Constants::BIRTHDAY_CALENDAR_URI, ]); $this->assertNotNull($instance, "Birthday calendar instance not found for $principalUri"); $objectUri = $addressBookUri.'-'.$cardUri.'.ics'; $object = $this->em->getRepository(CalendarObject::class)->findOneBy([ 'calendar' => $instance->getCalendar(), 'uri' => $objectUri, ]); $this->assertNotNull($object, "Calendar object $objectUri not found"); $this->assertStringContainsString($expectedNameFragment, $object->getCalendarData()); } public function testExecuteSyncsAllUsers(): void { $this->createUser('alice'); $this->createUser('bob'); $this->createAddressBookWithCard( 'alice', 'alice-contact.vcf', "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Alice Contact\r\nUID:alice-1\r\nBDAY:19900615\r\nEND:VCARD\r\n" ); $this->createAddressBookWithCard( 'bob', 'bob-contact.vcf', "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Bob Contact\r\nUID:bob-1\r\nBDAY:19850320\r\nEND:VCARD\r\n" ); $this->commandTester->execute([]); $this->assertSame(0, $this->commandTester->getStatusCode()); $this->assertStringContainsString('Start birthday calendar sync for all users', $this->commandTester->getDisplay()); $this->em->clear(); $this->assertBirthdayEventExists(Principal::PREFIX.'alice', 'default', 'alice-contact.vcf', 'Alice Contact'); $this->assertBirthdayEventExists(Principal::PREFIX.'bob', 'default', 'bob-contact.vcf', 'Bob Contact'); } public function testExecuteSyncsSingleUser(): void { $this->createUser('alice'); $this->createUser('bob'); $this->createAddressBookWithCard( 'alice', 'alice-contact.vcf', "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Alice Contact\r\nUID:alice-1\r\nBDAY:19900615\r\nEND:VCARD\r\n" ); $this->createAddressBookWithCard( 'bob', 'bob-contact.vcf', "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Bob Contact\r\nUID:bob-1\r\nBDAY:19850320\r\nEND:VCARD\r\n" ); $this->commandTester->execute(['username' => 'alice']); $this->assertSame(0, $this->commandTester->getStatusCode()); $this->assertStringContainsString('Start birthday calendar sync for alice', $this->commandTester->getDisplay()); $this->em->clear(); // Alice's birthday calendar should exist with the event $this->assertBirthdayEventExists(Principal::PREFIX.'alice', 'default', 'alice-contact.vcf', 'Alice Contact'); // Bob's birthday calendar should NOT have been created $bobInstance = $this->em->getRepository(CalendarInstance::class)->findOneBy([ 'principalUri' => Principal::PREFIX.'bob', 'uri' => Constants::BIRTHDAY_CALENDAR_URI, ]); $this->assertNull($bobInstance); } public function testExecuteThrowsExceptionForUnknownUser(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('User is unknown.'); $this->commandTester->execute(['username' => 'unknown']); } public function testExecuteWithNoUsersInDatabaseSucceeds(): void { $this->commandTester->execute([]); $this->assertSame(0, $this->commandTester->getStatusCode()); $instances = $this->em->getRepository(CalendarInstance::class)->findBy(['uri' => Constants::BIRTHDAY_CALENDAR_URI]); $this->assertCount(0, $instances); } } ================================================ FILE: tests/Functional/Controllers/AddressBookControllerTest.php ================================================ get('doctrine.orm.entity_manager')->getRepository(User::class); $user = $userRepository->findOneByUsername($username); return $user->getId(); } public function testAddressBookIndex(): void { $user = new AdminUser('admin', 'test'); $client = static::createClient(); $client->loginUser($user); $userId = $this->getUserId($client, 'test_user'); $client->request('GET', '/addressbooks/'.$userId); $this->assertResponseIsSuccessful(); $this->assertSelectorExists('nav.navbar'); $this->assertSelectorTextContains('h1', 'Address books for Test User'); $this->assertSelectorTextContains('a.btn', '+ New Address Book'); $this->assertSelectorTextContains('h5', 'default.addressbook.title'); } public function testAddressBookEdit(): void { $user = new AdminUser('admin', 'test'); $client = static::createClient(); $client->loginUser($user); $userId = $this->getUserId($client, 'test_user'); $addressbookRepository = static::getContainer()->get('doctrine.orm.entity_manager')->getRepository(AddressBook::class); $addressbook = $addressbookRepository->findOneByDisplayName('default.addressbook.title'); $client->request('GET', '/addressbooks/'.$userId.'/edit/'.$addressbook->getId()); $this->assertResponseIsSuccessful(); $this->assertSelectorTextContains('h1', 'Editing Address Book «default.addressbook.title»'); $this->assertSelectorTextContains('button#address_book_save', 'Save'); $client->submitForm('address_book_save'); $this->assertResponseRedirects('/addressbooks/'.$userId); $client->followRedirect(); $this->assertSelectorTextContains('h5', 'default.addressbook.title'); } public function testAddressBookNew(): void { $user = new AdminUser('admin', 'test'); $client = static::createClient(); $client->loginUser($user); $userId = $this->getUserId($client, 'test_user'); $crawler = $client->request('GET', '/addressbooks/'.$userId.'/new'); $this->assertResponseIsSuccessful(); $this->assertSelectorTextContains('h1', 'New Address Book '); $this->assertSelectorTextContains('button#address_book_save', 'Save'); $buttonCrawlerNode = $crawler->selectButton('address_book_save'); $form = $buttonCrawlerNode->form(); $client->submit($form, [ 'address_book[uri]' => 'new_test_address_book', 'address_book[displayName]' => 'New test address book', 'address_book[description]' => 'new address book', ]); $this->assertResponseRedirects('/addressbooks/'.$userId); $client->followRedirect(); $this->assertSelectorTextContains('h5', 'default.addressbook.title'); $this->assertAnySelectorTextContains('h5', 'New test address book'); } public function testAddressBookDelete(): void { $user = new AdminUser('admin', 'test'); $client = static::createClient(); $client->loginUser($user); $userId = $this->getUserId($client, 'test_user'); $addressbookRepository = static::getContainer()->get('doctrine.orm.entity_manager')->getRepository(AddressBook::class); $addressbook = $addressbookRepository->findOneByDisplayName('default.addressbook.title'); $client->request('GET', '/addressbooks/'.$userId.'/delete/'.$addressbook->getId()); $this->assertResponseRedirects('/addressbooks/'.$userId); $client->followRedirect(); $this->assertSelectorTextNotContains('h5', 'default.addressbook.title'); } } ================================================ FILE: tests/Functional/Controllers/ApiControllerTest.php ================================================ request('GET', '/api/v1/users', [], [], [ 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], ]); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); $this->assertArrayHasKey('data', $data); $this->assertStringContainsString('test_user', $data['data'][$index]['username']); return $data['data'][$index]['user_id']; } /* * Helper function to get an existing username from the user list * * @param int $index Index of the user in the list (0 - first user, 1 - second user) * @param mixed $client * * @return string Username */ private function getUserUsername($client, int $index): string { $client->request('GET', '/api/v1/users', [], [], [ 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], ]); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); $this->assertArrayHasKey('data', $data); $this->assertStringContainsString('test_user', $data['data'][$index]['username']); return $data['data'][$index]['username']; } /* * Helper function to get an existing calendar ID from the user calendar list * * @param mixed $client * @param int $userId * @param bool $default Whether to get the default calendar (true) or the second calendar (false) * * @return int Calendar ID */ private function getCalendarId($client, int $userId, bool $default = true): int { $client->request('GET', '/api/v1/calendars/'.$userId, [], [], [ 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], ]); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); if ($default) { $this->assertMatchesRegularExpression('/^\d+$/', $data['data']['user_calendars'][0]['id']); return $data['data']['user_calendars'][0]['id']; } $this->assertMatchesRegularExpression('/^\d+$/', $data['data']['user_calendars'][1]['id']); return $data['data']['user_calendars'][1]['id']; } /* * Test the health endpoint */ public function testHealth(): void { $client = static::createClient(); $client->request('GET', '/api/v1/health'); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); $this->assertEquals('OK', $data['status']); } /* * Test the API endpoint with invalid token */ public function testApiInvalidToken(): void { $client = static::createClient(); $client->request('GET', '/api/v1/users', [], [], [ 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_DAVIS_API_TOKEN' => 'invalid_token', ]); $this->assertResponseStatusCodeSame(401); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); $this->assertEquals('error', $data['status']); $this->assertEquals('Invalid X-Davis-API-Token header', $data['message']); } /* * Test the API endpoint with missing token */ public function testApiMissingToken(): void { $client = static::createClient(); $client->request('GET', '/api/v1/users', [], [], [ 'HTTP_ACCEPT' => 'application/json', ]); $this->assertResponseStatusCodeSame(401); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); $this->assertEquals('error', $data['status']); $this->assertEquals('Missing X-Davis-API-Token header', $data['message']); } /* * Test the user list endpoint */ public function testUserList(): void { $client = static::createClient(); $client->request('GET', '/api/v1/users', [], [], [ 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], ]); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); // Check if user1 is present in db $this->assertArrayHasKey('user_id', $data['data'][0]); $this->assertArrayHasKey('principal_id', $data['data'][0]); $this->assertArrayHasKey('uri', $data['data'][0]); $this->assertStringContainsString('principals/test_user', $data['data'][0]['uri']); $this->assertArrayHasKey('username', $data['data'][0]); $this->assertStringContainsString('test_user', $data['data'][0]['username']); // Check if user2 is present in db $this->assertArrayHasKey('user_id', $data['data'][1]); $this->assertArrayHasKey('principal_id', $data['data'][1]); $this->assertArrayHasKey('uri', $data['data'][1]); $this->assertStringContainsString('principals/test_user2', $data['data'][1]['uri']); $this->assertArrayHasKey('username', $data['data'][1]); $this->assertStringContainsString('test_user2', $data['data'][1]['username']); } /* * Test the user details endpoint */ public function testUserDetails(): void { // Create client once $client = static::createClient(); // Get userId and username from existing user lists $userId = $this->getUserId($client, 0); $username = $this->getUserUsername($client, 0); // Check user details endpoint $client->request('GET', '/api/v1/users/'.$userId, [], [], [ 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], ]); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); // Check if user details are correct $this->assertArrayHasKey('user_id', $data['data']); $this->assertEquals($userId, $data['data']['user_id']); $this->assertArrayHasKey('displayname', $data['data']); $this->assertStringContainsString('Test User', $data['data']['displayname']); $this->assertArrayHasKey('email', $data['data']); $this->assertStringContainsString('test@test.com', $data['data']['email']); $this->assertStringEqualsStringIgnoringLineEndings($username, $data['data']['username']); } /* * Test the user calendars list endpoint */ public function testUserCalendarsList(): void { $client = static::createClient(); $userId = $this->getUserId($client, 0); $client->request('GET', '/api/v1/calendars/'.$userId, [], [], [ 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], ]); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); // Check if calendar list is correct $this->assertArrayHasKey('status', $data); $this->assertEquals('success', $data['status']); $this->assertArrayHasKey('user_calendars', $data['data']); $this->assertStringContainsString('default', $data['data']['user_calendars'][0]['uri']); $this->assertStringContainsString('default.calendar.title', $data['data']['user_calendars'][0]['displayname']); $this->assertArrayHasKey('shared_calendars', $data['data']); $this->assertArrayHasKey('subscriptions', $data['data']); } /* * Test the user calendar details endpoint */ public function testUserCalendarDetails(): void { $client = static::createClient(); $userId = $this->getUserId($client, 0); // Get calendar list to retrieve calendar ID $client->request('GET', '/api/v1/calendars/'.$userId, [], [], [ 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], ]); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); $calendar_id = $data['data']['user_calendars'][0]['id']; // Check calendar details endpoint $client->request('GET', '/api/v1/calendars/'.$userId.'/'.$calendar_id, [], [], [ 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], ]); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); // Check if calendar details are correct $this->assertArrayHasKey('id', $data['data']); $this->assertArrayHasKey('uri', $data['data']); $this->assertStringContainsString('default', $data['data']['uri']); $this->assertArrayHasKey('displayname', $data['data']); $this->assertStringContainsString('default.calendar.title', $data['data']['displayname']); $this->assertArrayHasKey('description', $data['data']); $this->assertStringContainsString('default.calendar.description', $data['data']['description']); $this->assertArrayHasKey('events', $data['data']); $this->assertArrayHasKey('notes', $data['data']); $this->assertArrayHasKey('tasks', $data['data']); } /* * Test creating a new user calendar */ public function testCreateUserCalendar(): void { $client = static::createClient(); $userId = $this->getUserId($client, 0); // Create calendar API request with JSON body $payload = [ 'uri' => 'api_calendar', 'name' => 'api.calendar.title', 'description' => 'api.calendar.description', 'events_support' => true, 'tasks_support' => true, 'notes_support' => false, ]; $client->request('POST', '/api/v1/calendars/'.$userId.'/create', [], [], [ 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], 'CONTENT_TYPE' => 'application/json', ], json_encode($payload)); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); $this->assertArrayHasKey('status', $data); $this->assertEquals('success', $data['status']); $this->assertArrayHasKey('data', $data); $this->assertMatchesRegularExpression('/^\d+$/', $data['data']['calendar_id']); $this->assertStringContainsString('api_calendar', $data['data']['calendar_uri']); // Check if calendar is created $calendarId = $data['data']['calendar_id']; $client->request('GET', '/api/v1/calendars/'.$userId.'/'.$calendarId, [], [], [ 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], ]); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); $this->assertArrayHasKey('events', $data['data']); $this->assertTrue($data['data']['events']['enabled']); $this->assertArrayHasKey('tasks', $data['data']); $this->assertTrue($data['data']['tasks']['enabled']); $this->assertArrayHasKey('notes', $data['data']); $this->assertFalse($data['data']['notes']['enabled']); } /* * Test editing a user calendar */ public function testEditUserCalendar(): void { $client = static::createClient(); $userId = $this->getUserId($client, 0); $calendarId = $this->getCalendarId($client, $userId, true); // Edit user default calendar $payload = [ 'name' => 'api.calendar.edited.title', 'description' => 'api.calendar.edited.description', 'events_support' => true, 'tasks_support' => true, 'notes_support' => true, ]; $client->request('PATCH', '/api/v1/calendars/'.$userId.'/'.$calendarId, [], [], [ 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], 'CONTENT_TYPE' => 'application/json', ], json_encode($payload)); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); $this->assertArrayHasKey('status', $data); $this->assertEquals('success', $data['status']); // Check if edits were applied $client->request('GET', '/api/v1/calendars/'.$userId.'/'.$calendarId, [], [], [ 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], ]); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); $this->assertArrayHasKey('data', $data); $this->assertStringContainsString($payload['name'], $data['data']['displayname']); $this->assertStringContainsString($payload['description'], $data['data']['description']); $this->assertArrayHasKey('events', $data['data']); $this->assertTrue($data['data']['events']['enabled']); $this->assertArrayHasKey('tasks', $data['data']); $this->assertTrue($data['data']['tasks']['enabled']); $this->assertArrayHasKey('notes', $data['data']); $this->assertTrue($data['data']['notes']['enabled']); } /* * Test getting shares for a user calendar (should be empty initially) */ public function testGetUserCalendarSharesEmpty(): void { $client = static::createClient(); $userId = $this->getUserId($client, 0); $calendarId = $this->getCalendarId($client, $userId, true); // Get shares for user default calendar $client->request('GET', '/api/v1/calendars/'.$userId.'/shares/'.$calendarId, [], [], [ 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], ]); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); $this->assertArrayHasKey('status', $data); $this->assertEquals('success', $data['status']); $this->assertArrayHasKey('data', $data); $this->assertEmpty($data['data']); } /* * Test sharing user calendar to another user */ public function testShareUserCalendar(): void { $client = static::createClient(); $userId = $this->getUserId($client, 0); $shareeUsername = $this->getUserUsername($client, 1); $calendarId = $this->getCalendarId($client, $userId, true); // Share user default calendar to test_user2 $payload = [ 'username' => $shareeUsername, 'write_access' => false, ]; $client->request('POST', '/api/v1/calendars/'.$userId.'/share/'.$calendarId.'/add', [], [], [ 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], 'CONTENT_TYPE' => 'application/json', ], json_encode($payload)); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); $this->assertArrayHasKey('status', $data); $this->assertEquals('success', $data['status']); // Check if share was applied $client->request('GET', '/api/v1/calendars/'.$userId.'/shares/'.$calendarId, [], [], [ 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], ]); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); $this->assertEquals('success', $data['status']); $this->assertArrayHasKey('data', $data); $this->assertStringContainsString($shareeUsername, $data['data'][0]['username']); $this->assertArrayHasKey('user_id', $data['data'][0]); $this->assertIsNumeric($data['data'][0]['user_id']); $this->assertFalse($data['data'][0]['write_access']); } /* * Test removing shared access to user calendar */ public function testUnshareUserCalendar(): void { $client = static::createClient(); $userId = $this->getUserId($client, 0); $shareeUsername = $this->getUserUsername($client, 1); $calendarId = $this->getCalendarId($client, $userId, true); // Unshare user default calendar from test_user2 $payload = [ 'username' => $shareeUsername, ]; $client->request('POST', '/api/v1/calendars/'.$userId.'/share/'.$calendarId.'/remove', [], [], [ 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], 'CONTENT_TYPE' => 'application/json', ], json_encode($payload)); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); $this->assertEquals('success', $data['status']); // Check if unshare was applied $client->request('GET', '/api/v1/calendars/'.$userId.'/shares/'.$calendarId, [], [], [ 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], ]); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); $this->assertEquals('success', $data['status']); $this->assertArrayHasKey('data', $data); $this->assertEmpty($data['data']); } /* * Test creating a calendar with no components enabled should return validation error */ public function testCreateUserCalendarNoComponents(): void { $client = static::createClient(); $userId = $this->getUserId($client, 0); // Create calendar API request with no components enabled $payload = [ 'uri' => 'no_components_calendar', 'name' => 'no.components.calendar', 'description' => 'no.components.description', 'events_support' => false, 'tasks_support' => false, 'notes_support' => false, ]; $client->request('POST', '/api/v1/calendars/'.$userId.'/create', [], [], [ 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], 'CONTENT_TYPE' => 'application/json', ], json_encode($payload)); $this->assertResponseStatusCodeSame(400); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); $this->assertEquals('error', $data['status']); $this->assertStringContainsString('At least one calendar component must be enabled', $data['message']); } /* * Test editing a calendar with no components enabled should return validation error */ public function testEditUserCalendarNoComponents(): void { $client = static::createClient(); $userId = $this->getUserId($client, 0); $calendarId = $this->getCalendarId($client, $userId, true); // Edit calendar API request with no components enabled $payload = [ 'name' => 'edited.calendar.title', 'description' => 'edited.calendar.description', 'events_support' => false, 'tasks_support' => false, 'notes_support' => false, ]; $client->request('PUT', '/api/v1/calendars/'.$userId.'/'.$calendarId, [], [], [ 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], 'CONTENT_TYPE' => 'application/json', ], json_encode($payload)); $this->assertResponseStatusCodeSame(400); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); $this->assertEquals('error', $data['status']); $this->assertStringContainsString('At least one calendar component must be enabled', $data['message']); } /* * Test deleting a user calendar */ public function testDeleteUserCalendar(): void { $client = static::createClient(); $userId = $this->getUserId($client, 0); $calendarId = $this->getCalendarId($client, $userId, true); // Delete the calendar $client->request('DELETE', '/api/v1/calendars/'.$userId.'/'.$calendarId, [], [], [ 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], 'CONTENT_TYPE' => 'application/json', ]); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); $this->assertEquals('success', $data['status']); // Check if calendar is deleted $client->request('GET', '/api/v1/calendars/'.$userId.'/'.$calendarId, [], [], [ 'HTTP_ACCEPT' => 'application/json', 'HTTP_X_DAVIS_API_TOKEN' => $_ENV['API_KEY'], ]); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('Content-Type', 'application/json'); $data = json_decode($client->getResponse()->getContent(), true); $this->assertEquals('success', $data['status']); $this->assertEmpty($data['data']); } } ================================================ FILE: tests/Functional/Controllers/CalendarControllerTest.php ================================================ get('doctrine.orm.entity_manager')->getRepository(User::class); $user = $userRepository->findOneByUsername($username); return $user->getId(); } public function testCalendarIndex(): void { $user = new AdminUser('admin', 'test'); $client = static::createClient(); $client->loginUser($user); $userId = $this->getUserId($client, 'test_user'); $client->request('GET', '/calendars/'.$userId); $this->assertResponseIsSuccessful(); $this->assertSelectorExists('nav.navbar'); $this->assertSelectorTextContains('h1', 'Calendars for Test User'); $this->assertSelectorTextContains('a.btn', '+ New Calendar'); $this->assertSelectorTextContains('h5', 'default.calendar.title'); } public function testCalendarEdit(): void { $user = new AdminUser('admin', 'test'); $client = static::createClient(); $client->loginUser($user); $userId = $this->getUserId($client, 'test_user'); $calendarRepository = static::getContainer()->get(CalendarInstanceRepository::class); $calendar = $calendarRepository->findOneByDisplayName('default.calendar.title'); $client->request('GET', '/calendars/'.$userId.'/edit/'.$calendar->getId()); $this->assertResponseIsSuccessful(); $this->assertSelectorTextContains('h1', 'Editing Calendar «default.calendar.title»'); $this->assertSelectorTextContains('button#calendar_instance_save', 'Save'); $client->submitForm('calendar_instance_save'); $this->assertResponseRedirects('/calendars/'.$userId); $client->followRedirect(); $this->assertSelectorTextContains('h5', 'default.calendar.title'); } public function testCalendarNew(): void { $user = new AdminUser('admin', 'test'); $client = static::createClient(); $client->loginUser($user); $userId = $this->getUserId($client, 'test_user'); $crawler = $client->request('GET', '/calendars/'.$userId.'/new'); $this->assertResponseIsSuccessful(); $this->assertSelectorTextContains('h1', 'New Calendar '); $this->assertSelectorTextContains('button#calendar_instance_save', 'Save'); $buttonCrawlerNode = $crawler->selectButton('calendar_instance_save'); $form = $buttonCrawlerNode->form(); $client->submit($form, [ 'calendar_instance[uri]' => 'new_test_calendar', 'calendar_instance[displayName]' => 'New test calendar', 'calendar_instance[description]' => 'new calendar', 'calendar_instance[calendarColor]' => '#00112233', ]); $this->assertResponseRedirects('/calendars/'.$userId); $client->followRedirect(); $this->assertSelectorTextContains('h5', 'default.calendar.title'); $this->assertAnySelectorTextContains('h5', 'New test calendar'); } public function testCalendarDelete(): void { $user = new AdminUser('admin', 'test'); $client = static::createClient(); $client->loginUser($user); $userId = $this->getUserId($client, 'test_user'); $calendarRepository = static::getContainer()->get(CalendarInstanceRepository::class); $calendar = $calendarRepository->findOneByDisplayName('default.calendar.title'); $client->request('GET', '/calendars/'.$userId.'/delete/'.$calendar->getId()); $this->assertResponseRedirects('/calendars/'.$userId); $client->followRedirect(); $this->assertSelectorTextNotContains('h5', 'default.calendar.title'); } } ================================================ FILE: tests/Functional/Controllers/DashboardTest.php ================================================ request('GET', '/'); $this->assertResponseIsSuccessful(); $this->assertSelectorTextContains('h3', 'Davis'); $this->assertSelectorExists('li.caldav'); $this->assertSelectorExists('li.carddav'); $this->assertSelectorExists('li.webdav'); } public function testDashboardPageUnlogged(): void { $client = static::createClient(); $client->request('GET', '/dashboard'); $this->assertResponseRedirects('/login'); } public function testLoginPage(): void { $client = static::createClient(); $client->request('GET', '/login'); $this->assertResponseIsSuccessful(); $this->assertSelectorTextContains('h1', 'Please sign in'); $this->assertSelectorExists('nav.navbar'); } public function testLoginIncorrectUsername(): void { $client = static::createClient(); $crawler = $client->request('GET', '/login'); $form = $crawler->selectButton('Submit')->form(); $form['_username']->setValue('bad_'.$_ENV['ADMIN_LOGIN']); $form['_password']->setValue('bad_password'); $client->submit($form); $this->assertResponseRedirects('/login'); $crawler = $client->followRedirect(); $this->assertResponseIsSuccessful(); $this->assertSelectorTextContains('div.alert.alert-danger', 'Invalid credentials.'); } public function testLoginIncorrectPassword(): void { $client = static::createClient(); $crawler = $client->request('GET', '/login'); $form = $crawler->selectButton('Submit')->form(); $form['_username']->setValue($_ENV['ADMIN_LOGIN']); $form['_password']->setValue('bad_password'); $client->submit($form); $this->assertResponseRedirects('/login'); $crawler = $client->followRedirect(); $this->assertResponseIsSuccessful(); $this->assertSelectorTextContains('div.alert.alert-danger', 'Invalid credentials.'); } public function testLoginCorrect(): void { $client = static::createClient(); $crawler = $client->request('GET', '/login'); $form = $crawler->selectButton('Submit')->form(); $form['_username']->setValue($_ENV['ADMIN_LOGIN']); $form['_password']->setValue($_ENV['ADMIN_PASSWORD']); $client->submit($form); $this->assertResponseRedirects('/dashboard'); $crawler = $client->followRedirect(); $this->assertResponseIsSuccessful(); $this->assertSelectorTextContains('h1', 'Dashboard'); $this->assertSelectorTextContains('h3.capabilities', 'Capabilities'); $this->assertSelectorTextContains('h3.objects', 'Objects'); $this->assertSelectorTextContains('h3.environment', 'Configured environment'); $this->assertSelectorExists('nav.navbar'); } } ================================================ FILE: tests/Functional/Controllers/UserControllerTest.php ================================================ get('doctrine.orm.entity_manager')->getRepository(User::class); $user = $userRepository->findOneByUsername($username); return $user->getId(); } public function testUserIndex(): void { $user = new AdminUser('admin', 'test'); $client = static::createClient(); $client->loginUser($user); $client->request('GET', '/users/'); $this->assertResponseIsSuccessful(); $this->assertSelectorExists('nav.navbar'); $this->assertSelectorTextContains('h1', 'Users and Resources'); $this->assertSelectorTextContains('a.btn', '+ New User'); $this->assertAnySelectorTextContains('h5', 'Test User'); } public function testUserEdit(): void { $user = new AdminUser('admin', 'test'); $client = static::createClient(); $client->loginUser($user); $userId = $this->getUserId($client, 'test_user'); $client->request('GET', '/users/edit/'.$userId); $this->assertResponseIsSuccessful(); $this->assertSelectorTextContains('h1', 'Editing User «test_user»'); $this->assertSelectorTextContains('button#user_save', 'Save'); $client->submitForm('user_save'); $this->assertResponseRedirects('/users/'); $client->followRedirect(); $this->assertAnySelectorTextContains('h5', 'Test User'); } public function testUserNew(): void { $user = new AdminUser('admin', 'test'); $client = static::createClient(); $client->loginUser($user); $crawler = $client->request('GET', '/users/new'); $this->assertResponseIsSuccessful(); $this->assertSelectorTextContains('h1', 'New User'); $this->assertSelectorTextContains('button#user_save', 'Save'); $buttonCrawlerNode = $crawler->selectButton('user_save'); $form = $buttonCrawlerNode->form(); $client->submit($form, [ 'user[username]' => 'new_test_user', 'user[displayName]' => 'New test User', 'user[email]' => 'coucou@coucou.com', 'user[password][first]' => 'coucou', 'user[password][second]' => 'coucou', 'user[isAdmin]' => false, ]); $this->assertResponseRedirects('/users/'); $client->followRedirect(); $this->assertAnySelectorTextContains('h5', 'Test User'); $this->assertAnySelectorTextContains('h5', 'New test User'); } public function testUserDelete(): void { $user = new AdminUser('admin', 'test'); $client = static::createClient(); $client->loginUser($user); $userId1 = $this->getUserId($client, 'test_user'); $userId2 = $this->getUserId($client, 'test_user2'); $client->request('GET', '/users/delete/'.$userId1); $this->assertResponseRedirects('/users/'); $client->followRedirect(); $this->assertAnySelectorTextContains('h5', 'Test User 2'); $client->request('GET', '/users/delete/'.$userId2); $this->assertResponseRedirects('/users/'); $client->followRedirect(); $this->assertSelectorTextContains('div#no-user', 'No users yet.'); } public function testUserDelegates(): void { $user = new AdminUser('admin', 'test'); $client = static::createClient(); $client->loginUser($user); $userId = $this->getUserId($client, 'test_user'); $client->request('GET', '/users/delegates/'.$userId); $this->assertResponseIsSuccessful(); $this->assertSelectorExists('nav.navbar'); $this->assertSelectorTextContains('h1', 'Delegates for Test User'); $this->assertSelectorTextContains('a.btn', '+ Add a delegate'); $this->assertAnySelectorTextContains('div', 'Delegation is enabled for this account.'); $this->assertAnySelectorTextContains('a.btn', 'Disable it'); $client->clickLink('Disable it'); $this->assertResponseRedirects('/users/delegates/'.$userId); $client->followRedirect(); $this->assertSelectorExists('nav.navbar'); $this->assertSelectorTextContains('h1', 'Delegates for Test User'); $this->assertSelectorTextNotContains('a.btn', '+ Add a delegate'); $this->assertAnySelectorTextContains('div', 'Delegation is not enabled for this account.'); $this->assertAnySelectorTextContains('a.btn', 'Enable it'); } } ================================================ FILE: tests/Functional/DavTest.php ================================================ request($method, $path); return $client; } public function testUnauthorized(): void { $client = static::requestDavClient('GET', '/dav/'); $this->assertResponseStatusCodeSame(401); } } ================================================ FILE: tests/Functional/Service/BirthdayServiceTest.php ================================================ em = static::getContainer()->get(EntityManagerInterface::class); $this->service = static::getContainer()->get(BirthdayService::class); $pdo = $this->em->getConnection()->getNativeConnection(); $this->service->setBackend(new CalendarBackend($pdo)); $this->em->getConnection()->beginTransaction(); } protected function tearDown(): void { $this->em->getConnection()->rollBack(); parent::tearDown(); } private function createAddressBook( string $username = 'testuser', bool $includedInBirthdayCalendar = true, ): AddressBook { $principal = (new Principal()) ->setUri(Principal::PREFIX.$username) ->setEmail($username.'@example.com') ->setDisplayName($username); $this->em->persist($principal); $addressBook = (new AddressBook()) ->setPrincipalUri(Principal::PREFIX.$username) ->setUri('default') ->setDisplayName('Default') ->setDescription('') ->setSynctoken('1') ->setIncludedInBirthdayCalendar($includedInBirthdayCalendar); $this->em->persist($addressBook); $this->em->flush(); return $addressBook; } // ------------------------------------------------------------------------- // buildDataFromContact // ------------------------------------------------------------------------- public function testBuildDataFromContactReturnsNullForEmptyData(): void { $this->assertNull($this->service->buildDataFromContact('')); } public function testBuildDataFromContactReturnsNullIfNoBday(): void { $vcard = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nEND:VCARD\r\n"; $this->assertNull($this->service->buildDataFromContact($vcard)); } public function testBuildDataFromContactReturnsNullIfNoFn(): void { $vcard = "BEGIN:VCARD\r\nVERSION:3.0\r\nBDAY:19900101\r\nEND:VCARD\r\n"; $this->assertNull($this->service->buildDataFromContact($vcard)); } public function testBuildDataFromContactReturnsVCalendarWithBday(): void { $vcard = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n"; $result = $this->service->buildDataFromContact($vcard); $this->assertInstanceOf(VCalendar::class, $result); $this->assertStringContainsString('John Doe', (string) $result->VEVENT->SUMMARY); $this->assertStringContainsString('1990', (string) $result->VEVENT->SUMMARY); $this->assertEquals('FREQ=YEARLY', (string) $result->VEVENT->RRULE); $this->assertEquals('DATE', (string) $result->VEVENT->DTSTART['VALUE']); } public function testBuildDataFromContactHandlesLeapDay(): void { $vcard = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY:19920229\r\nEND:VCARD\r\n"; $result = $this->service->buildDataFromContact($vcard); $this->assertInstanceOf(VCalendar::class, $result); $this->assertStringContainsString('BYMONTH=2;BYMONTHDAY=-1', (string) $result->VEVENT->RRULE); } public function testBuildDataFromContactHandlesOmitYear(): void { $vcard = "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY;X-APPLE-OMIT-YEAR=1604:16040615\r\nEND:VCARD\r\n"; $result = $this->service->buildDataFromContact($vcard); $this->assertInstanceOf(VCalendar::class, $result); $this->assertStringNotContainsString('(', (string) $result->VEVENT->SUMMARY); } public function testBuildDataFromContactAddsAlarm(): void { $vcard = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n"; $result = $this->service->buildDataFromContact($vcard); $this->assertNotNull($result->VEVENT->VALARM); } // ------------------------------------------------------------------------- // birthdayEventChanged // ------------------------------------------------------------------------- public function testBirthdayEventChangedReturnsFalseWhenSame(): void { $cal = $this->service->buildDataFromContact("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n"); $this->assertFalse($this->service->birthdayEventChanged($cal->serialize(), $cal)); } public function testBirthdayEventChangedReturnsTrueWhenDifferentDate(): void { $cal1 = $this->service->buildDataFromContact("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n"); $cal2 = $this->service->buildDataFromContact("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY:19900616\r\nEND:VCARD\r\n"); $this->assertTrue($this->service->birthdayEventChanged($cal1->serialize(), $cal2)); } public function testBirthdayEventChangedReturnsTrueWhenDifferentName(): void { $cal1 = $this->service->buildDataFromContact("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n"); $cal2 = $this->service->buildDataFromContact("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Jane Doe\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n"); $this->assertTrue($this->service->birthdayEventChanged($cal1->serialize(), $cal2)); } public function testBirthdayEventChangedReturnsTrueOnInvalidExistingData(): void { $cal = $this->service->buildDataFromContact("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n"); $this->assertTrue($this->service->birthdayEventChanged('invalid-data', $cal)); } // ------------------------------------------------------------------------- // onCardChanged // ------------------------------------------------------------------------- public function testOnCardChangedSkipsIfNotIncludedInBirthdayCalendar(): void { $addressBook = $this->createAddressBook(includedInBirthdayCalendar: false); $this->service->onCardChanged( $addressBook->getId(), 'john.vcf', "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n" ); $this->em->clear(); $object = $this->em->getRepository(CalendarObject::class)->findOneBy(['uri' => 'default-john.vcf.ics']); $this->assertNull($object); } public function testOnCardChangedCreatesCalendarObject(): void { $addressBook = $this->createAddressBook(); $this->service->onCardChanged( $addressBook->getId(), 'john.vcf', "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n" ); $this->em->clear(); $object = $this->em->getRepository(CalendarObject::class)->findOneBy(['uri' => 'default-john.vcf.ics']); $this->assertNotNull($object); $this->assertStringContainsString('John Doe', $object->getCalendarData()); } public function testOnCardChangedUpdatesExistingCalendarObject(): void { $addressBook = $this->createAddressBook(); $this->service->onCardChanged( $addressBook->getId(), 'john.vcf', "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n" ); $this->service->onCardChanged( $addressBook->getId(), 'john.vcf', "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Updated\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n" ); $this->em->clear(); $object = $this->em->getRepository(CalendarObject::class)->findOneBy(['uri' => 'default-john.vcf.ics']); $this->assertNotNull($object); $this->assertStringContainsString('John Updated', $object->getCalendarData()); $this->em->clear(); $instanceBefore = $this->em->getRepository(CalendarInstance::class)->findOneBy([ 'principalUri' => 'principals/testuser', 'uri' => Constants::BIRTHDAY_CALENDAR_URI, ]); $syncTokenBefore = $instanceBefore->getCalendar()->getSynctoken(); $this->service->onCardChanged( $addressBook->getId(), 'john.vcf', "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Updated Again\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n" ); $this->em->clear(); $instanceAfter = $this->em->getRepository(CalendarInstance::class)->findOneBy([ 'principalUri' => 'principals/testuser', 'uri' => Constants::BIRTHDAY_CALENDAR_URI, ]); $this->assertGreaterThan($syncTokenBefore, $instanceAfter->getCalendar()->getSynctoken()); } public function testOnCardChangedDoesNotCreateObjectIfNoBday(): void { $addressBook = $this->createAddressBook(); $this->service->onCardChanged( $addressBook->getId(), 'john.vcf', "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nEND:VCARD\r\n" ); $this->em->clear(); $object = $this->em->getRepository(CalendarObject::class)->findOneBy(['uri' => 'default-john.vcf.ics']); $this->assertNull($object); } // ------------------------------------------------------------------------- // onCardDeleted // ------------------------------------------------------------------------- public function testOnCardDeletedRemovesCalendarObject(): void { $addressBook = $this->createAddressBook(); $this->service->onCardChanged( $addressBook->getId(), 'john.vcf', "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nUID:1234\r\nBDAY:19900615\r\nEND:VCARD\r\n" ); $this->service->onCardDeleted($addressBook->getId(), 'john.vcf'); $this->em->clear(); $object = $this->em->getRepository(CalendarObject::class)->findOneBy(['uri' => 'default-john.vcf.ics']); $this->assertNull($object); } public function testOnCardDeletedIsNoopIfNoCalendarObject(): void { $addressBook = $this->createAddressBook(); // Should not throw even if no calendar object exists $this->service->onCardDeleted($addressBook->getId(), 'nonexistent.vcf'); $this->addToAssertionCount(1); } // ------------------------------------------------------------------------- // ensureBirthdayCalendarExists // ------------------------------------------------------------------------- public function testEnsureBirthdayCalendarExistsCreatesCalendar(): void { $this->service->ensureBirthdayCalendarExists('principals/testuser'); $instance = $this->em->getRepository(CalendarInstance::class)->findOneBy([ 'principalUri' => 'principals/testuser', 'uri' => Constants::BIRTHDAY_CALENDAR_URI, ]); $this->assertNotNull($instance); $this->assertNotNull($instance->getCalendar()); } public function testEnsureBirthdayCalendarExistsIsIdempotent(): void { $this->service->ensureBirthdayCalendarExists('principals/testuser'); $this->service->ensureBirthdayCalendarExists('principals/testuser'); $instances = $this->em->getRepository(CalendarInstance::class)->findBy([ 'principalUri' => 'principals/testuser', 'uri' => Constants::BIRTHDAY_CALENDAR_URI, ]); $this->assertCount(1, $instances); } // ------------------------------------------------------------------------- // syncPrincipal // ------------------------------------------------------------------------- public function testSyncPrincipalDeletesBirthdayCalendarIfNoAddressBooksIncluded(): void { $this->service->ensureBirthdayCalendarExists('principals/testuser'); $this->createAddressBook(includedInBirthdayCalendar: false); $this->service->syncPrincipal('principals/testuser'); $instance = $this->em->getRepository(CalendarInstance::class)->findOneBy([ 'principalUri' => 'principals/testuser', 'uri' => Constants::BIRTHDAY_CALENDAR_URI, ]); $this->assertNull($instance); } } ================================================ FILE: tests/bootstrap.php ================================================ bootEnv(dirname(__DIR__).'/.env'); } // Create the test database, update the schema and resets the fixture before each test. // Note: `--quiet` is needed here for each step so that PHPUnit doesn't fail. $actions = [ 'doctrine:database:create --if-not-exists ', 'doctrine:schema:update --complete --force', 'doctrine:fixtures:load --no-interaction', ]; foreach ($actions as $action) { passthru(sprintf( 'APP_ENV=%s php "%s/../bin/console" %s --quiet', $_ENV['APP_ENV'], __DIR__, $action, )); } ================================================ FILE: translations/.gitignore ================================================ ================================================ FILE: translations/messages+intl-icu.de.xlf ================================================
davis Davis enabled aktiviert disabled deaktiviert label.success Erfolg label.warning Warnung label.error Fehler admin.interface Administrationsoberfläche close Schließen save Speichern toggle.navigation Navigation umschalten toggle.theme Theme umschalten theme.light Hell theme.dark Dunkel theme.auto Auto title.dashboard Dashboard title.users_and_resources Benutzer und Ressourcen users.back Zurück zu Benutzer addressbooks.new Neues Adressbuch calendars.new Neuer Kalender calendars.uri URI users.new Neuer Benutzer dashboard.env Konfigurierte Arbeitsumgebung dashboard.capabilities Funktionen dashboard.objects Objekte dashboard.version Version dashboard.auth Authentifizierung dashboard.auth_realm Realm dashboard.invite_from_address Aus Adressbuch einladen dashboard.server_timezone Server (PHP) Timezone dashboard.bad_timezone_configuration Falsche Konfiguration der Zeitzone env var dashboard.no_timezone_configuration Zeitzone wird vom Server nicht erzwungen dashboard.users Benutzer dashboard.calendars Kalender dashboard.calendars.help (inkl. Erinnerungslisten) dashboard.events Ereignisse dashboard.address_books Adressbücher dashboard.contacts Kontakte addressbooks.edit Adressbuch «{name}» bearbeiten edit Bearbeiten add Hinzufügen delete Löschen calendars.edit Kalender «{name}» bearbeiten calendars.entries {count, plural, =0 {Kein Eintrag} one {Ein Eintrag} other {# Einträge}} calendars.component.events Ereignisse calendars.component.todos Aufgaben calendars.setup.title Einrichtungsinformation calendars.component.notes Notizen users.edit Benutzer «{username}» bearbeiten addressbooks.for Adressbuch von {who} addressbooks.uri Adressbuch URI addressbooks.contacts {count, plural, =0 {Kein Kontakt} one {Ein Kontakt} other {# Kontakte}} calendars.for Kalender für {who} calendars.shared.with Kalender geteilt mit {who} users.username Benutzername users.uri Principal URI users.administrator Administrator users.calendars Kalender users.addressbooks Adressbücher default.calendar.title Standard-Kalender default.calendar.description Standard-Kalender für {user} default.addressbook.title Standard-Adressbuch default.addressbook.description Standard-Adressbuch für {user} user.saved Benutzer erfolgreich gespeichert user.deleted Benutzer erfolgreich gelöscht calendar.saved Kalender erfolgreich gespeichert calendar.deleted Kalender erfolgreich gelöscht calendar.shared Geteilten Kalender erfolgreich bearbeitet calendar.revoked Geteilten Kalender erfolgreich zurückgezogen calendars.delegates.member.add Diesen Kalender mit anderen Benutzern teilen: calendars.delegates.member.help Das Hinzufügen eines Benutzers, der bereits einen gemeinsamen Zugriff auf diesen Kalender hat, wirkt sich nur auf dessen Zugriffsrecht aus. calendars.delegates.member.none Es gibt keine anderen Benutzer, mit denen Sie diesen Kalender teilen können. addressbooks.saved Adressbuch erfolgreich gespeichert addressbooks.deleted Adressbuch erfolgreich gelöscht addressbooks.back Zurück zu Adressbücher für {user} calendars.back Zurück zu Kalender für {user} form.password.empty Leer lassen, wenn Sie es nicht ändern wollen. form.password.match Das Passwortfeld muss übereinstimmen. form.password Passwort form.password.repeat Passwort wiederholen form.email E-Mail form.admin Ist dieser Benutzer ein Administrator? form.admin.help Wenn diese Option aktiviert ist, wird dieser Benutzer (d. h. sein Hauptbenutzer) in jede einzelne ACL-Regel mit den Berechtigungen "{DAV:}all" aufgenommen. form.displayName Anzeigename form.username Benutzername form.username.help Kann eine E-Mail sein, aber nicht zwingend. form.events Ereignisse form.notes Notizen form.todos Aufgaben form.uri URI form.includedInBirthdayCalendar Im Geburtstagskalender enthalten? form.description Beschreibung form.color Kalenderfarbe form.public Öffentlich form.name.help.carddav Dieser Name wird in Ihrem CardDAV-Client angezeigt. form.includedInBirthdayCalendar.help Wenn diese Option ausgewählt ist, werden alle Karten mit einem gültigen Geburtstag in den Geburtstagskalender des Hauptbenutzers aufgenommen, der als freigegebener Kalender in Ihrem Konto verfügbar ist. form.name.help.caldav Dieser Name wird in Ihrem CalDAV-Client angezeigt form.uri.help.carddav Dies ist der eindeutige Bezeichner für dieses Adressbuch. Erlaubte Zeichen sind Ziffern, Kleinbuchstaben und das Bindestrichsymbol '-'. form.uri.help.caldav Dies ist der eindeutige Bezeichner für dieses Kalender. Erlaubte Zeichen sind Ziffern, Kleinbuchstaben und das Bindestrichsymbol '-'. form.public.help.caldav Wenn der Kalender öffentlich ist, ist er für jeden, der den Link kennt, zugänglich. form.color.help Das ist die Farbe, die in Ihrem CalDAV-Client angezeigt werden soll. Sie muss im Format „#RRGGBBAA“ (Alphakanal ist optional) mit Hexadezimalwerten angegeben werden. Dieser Wert ist optional. form.events.help Wenn diese Option markiert ist, werden Ereignisse in diesem Kalender aktiviert. Sie werden in den Kalender-Clients angezeigt. form.todos.help Wenn diese Option aktiviert ist, werden Aufgaben in diesem Kalender aktiviert. Sie werden in Erinnerungsprogrammen angezeigt (z. B. macOS Erinnerungen App). form.notes.help Wenn diese Option markiert ist, werden Notizen in diesem Kalender aktiviert. addressbooks.modal.title Dieses Adressbuch löschen? addressbooks.modal.text Sind Sie sicher, dass Sie dieses Adressbuch löschen möchten? Alle darin enthaltenen Kontakte werden dann ebenfalls gelöscht. calendars.modal.title Diesen Kalender löschen? calendars.modal.text Sind Sie sicher, dass Sie diesen Kalender löschen möchten? Alle darin enthaltenen Ereignisse, Aufgaben und Notizen werden ebenfalls gelöscht. revoke.modal.title Zugriff auf diesen Kalender widerrufen? revoke.modal.text Sind Sie sicher, dass Sie den Zugang zu diesem Kalender widerrufen möchten? Der Benutzer verliert den Zugriff auf alle Ereignisse, Aufgaben und Notizen. Der ursprüngliche Kalender wird nicht gelöscht. users.modal.title Diesen Benutzer löschen? users.modal.text Sind Sie sicher, dass Sie diesen Benutzer löschen möchten? Alle zugehörigen Kalender und Adressbücher werden ebenfalls gelöscht. delegates.modal.title Diesen Vertreter löschen? delegates.modal.text Sind Sie sicher, dass Sie diesen Vertreter entfernen möchten? Dieser Benutzer hat dann keinen Zugang mehr zu den Kalendern, Kontakten usw. delegates.member.help Das Hinzufügen eines Benutzers, der bereits Vertreter ist, wirkt sich nur auf sein Zugriffsrecht aus. cancel Abbrechen yes Ja no Nein login.signin Bitte anmelden login.username Benutzername login.password Passwort login.submit Senden logout Abmelden login.already Sie sind bereits als {Benutzername} eingeloggt. no.users.yet Noch keine Benutzer. remove Entfernen revoke Zurückziehen sharing Teilen calendars.sharing Gemeinsame Nutzung von Kalendern calendars.delegates.add Vertreter hinzufügen calendars.delegates.for Vertreter für {what} calendars.delegates.new Neuer Vertreter calendars.delegates.existing Dieser Kalender wird geteilt mit: calendars.delegates.none keine calendars.delegates.member Mitglied: calendars.delegates.write.give Schreibzugriff gewähren? users.delegates Vertreter delegates.enabled.text Die Vertreterfunktion für dieses Konto ist aktiviert. delegates.disable.warning ⚠ Wenn Sie diese Funktion deaktivieren, verlieren alle Ihre Vertreter den Zugang zu diesem Kalender. delegates.disable Deaktivieren delegates.disabled.text Die Vertreter-Funktion ist für dieses Konto deaktiviert. delegates.enable Aktivieren delegates.write hat Schreibzugriff delegates.readonly hat Lesezugriff calendar.share_access.2 schreibgeschützt calendar.share_access.3 lesen / schreiben calendar.public öffentlich calendar.auto auto calendar.subscription Abonnement calendars.subscriptions Abonnements calendars.auto Automatisch generiert
================================================ FILE: translations/messages+intl-icu.en.xlf ================================================
davis Davis enabled enabled disabled disabled label.success Success label.warning Warning label.error Error admin.interface Administration interface close Close save Save toggle.navigation Toggle navigation toggle.theme Toggle theme theme.light Light theme.dark Dark theme.auto Auto title.dashboard Dashboard title.users_and_resources Users and Resources users.back Back to Users addressbooks.new New Address Book calendars.new New Calendar calendars.uri URI users.new New User dashboard.env Configured environment dashboard.capabilities Capabilities dashboard.objects Objects dashboard.version Version dashboard.auth Auth dashboard.auth_realm Realm dashboard.invite_from_address Invite From Address dashboard.server_timezone App (PHP) Timezone dashboard.bad_timezone_configuration Bad timezone configuration env var dashboard.no_timezone_configuration Timezone not enforced by app dashboard.users Users dashboard.calendars Calendars dashboard.calendars.help (incl. Reminders lists) dashboard.events Events dashboard.address_books Address Books dashboard.contacts Contacts addressbooks.edit Editing Address Book «{name}» edit Edit add Add delete Delete calendars.edit Editing Calendar «{name}» calendars.entries {count, plural, =0 {No entry} one {One entry} other {# entries}} calendars.component.events Events calendars.component.todos Todos calendars.setup.title Setup information calendars.component.notes Notes users.edit Editing User «{username}» addressbooks.for Address books for {who} addressbooks.uri Address book URI addressbooks.contacts {count, plural, =0 {No contact} one {One contact} other {# contacts}} calendars.for Calendars for {who} calendars.shared.with Calendars shared with {who} users.username Username users.uri Principal URI users.administrator Administrator users.calendars Calendars users.addressbooks Address Books default.calendar.title Default Calendar default.calendar.description Default Calendar for {user} default.addressbook.title Default Address Book default.addressbook.description Default Address Book for {user} user.saved User saved successfully user.deleted User deleted successfully calendar.saved Calendar saved successfully calendar.deleted Calendar deleted successfully calendar.shared Calendar shares modified successfully calendar.revoked Calendar revoked successfully calendars.delegates.member.add Share this calendar with another user: calendars.delegates.member.help Adding a user who already has a shared access to this calendar will only affect its access right calendars.delegates.member.none There are no other user to share this calendar with addressbooks.saved Address Book saved successfully addressbooks.deleted Address Book deleted successfully addressbooks.back Back to Address Books for {user} calendars.back Back to Calendars for {user} form.password.empty Leave empty if you don't want to change it form.password.match The password fields must match. form.password Password form.password.repeat Repeat Password form.email Email form.admin Is this user an administrator ? form.admin.help If checked, this user (namely, its principal) will be injected in every single ACL rule with the '{DAV:}all' privileges. form.displayName Display name form.username Username form.username.help May be an email, but not forcibly. form.events Events form.notes Notes form.todos Todos form.uri URI form.includedInBirthdayCalendar Included in birthday calendar? form.description Description form.color Calendar color form.public Public form.name.help.carddav This name will be displayed in your CardDAV client form.includedInBirthdayCalendar.help When selected, all cards with a valid birthday will be included in the principal's birthday calendar, which will be available as a shared calendar in your account form.name.help.caldav This name will be displayed in your CalDAV client form.uri.help.carddav This is the unique identifier for this address book. Allowed characters are digits, lowercase letters and the dash symbol '-'. form.uri.help.caldav This is the unique identifier for this calendar. Allowed characters are digits, lowercase letters and the dash symbol '-'. form.public.help.caldav If the calendar is public, it will be available for anyone with the link form.color.help This is the color that will be displayed in your CalDAV client. It must be supplied in the format '#RRGGBBAA' (alpha channel is optional) with hexadecimal values. This value is optional. form.events.help If checked, events will be enabled on this calendar. It will show up in calendar clients. form.todos.help If checked, todos will be enabled on this calendar. It will show up in reminders clients (such as macOS Reminders app) form.notes.help If checked, notes will be enabled on this calendar. addressbooks.modal.title Delete this Address Book ? addressbooks.modal.text Are you sure you want to delete this address book ? All the contacts in it will be deleted too. calendars.modal.title Delete this Calendar ? calendars.modal.text Are you sure you want to delete this calendar ? All the events, todos and notes in it will be deleted too. revoke.modal.title Revoke access to this calendar ? revoke.modal.text Are you sure you want to revoke access to this calendar ? The user will lose access to all events, todos and notes. The original calendar will not be deleted. users.modal.title Delete this User ? users.modal.text Are you sure you want to delete this user ? All the associated calendars and address books will be deleted too. delegates.modal.title Remove this delegate ? delegates.modal.text Are you sure you want to remove this delegate ? This user will no longer have access to the calendars, contacts, etc. delegates.member.help Adding a user who already is a delegate will only affect its access right cancel Cancel yes Yes no No login.signin Please sign in login.username Username login.password Password login.submit Submit logout Logout login.already You are already logged in as {username} no.users.yet No users yet. remove Remove revoke Revoke sharing Sharing calendars.sharing Calendar sharing calendars.delegates.add Add a delegate calendars.delegates.for Delegates for {what} calendars.delegates.new New delegate calendars.delegates.existing This calendar is shared with: calendars.delegates.none none calendars.delegates.member Member: calendars.delegates.write.give Give write access ? users.delegates Delegates delegates.enabled.text Delegation is enabled for this account. delegates.disable.warning ⚠ If you disable it, all your delegates will lose access to this calendar delegates.disable Disable it delegates.disabled.text Delegation is not enabled for this account. delegates.enable Enable it delegates.write has write access delegates.readonly has readonly access calendar.share_access.2 readonly calendar.share_access.3 read / write calendar.public public calendar.auto auto calendar.subscription subscription calendars.subscriptions Subscriptions calendars.auto Automatically generated
================================================ FILE: translations/messages+intl-icu.fr.xliff ================================================
davis Davis enabled Activé disabled Désactivé label.success Succès label.warning Avertissement label.error Erreur admin.interface Interface d'administration close Fermer save Enregistrer toggle.navigation Basculer la navigation toggle.theme Basculer le thème theme.light Clair theme.dark Sombre theme.auto Automatique title.dashboard Tableau de bord title.users_and_resources Utilisateurs et ressources users.back Retour aux utilisateurs addressbooks.new Nouveau carnet d'adresses calendars.new Nouveau calendrier calendars.uri URI users.new Nouvel utilisateur dashboard.env Environnement dashboard.capabilities Fonctionnalités dashboard.objects Objets dashboard.version Version dashboard.auth Authentification dashboard.auth_realm Domaine dashboard.invite_from_address Adresse d'invitation dashboard.server_timezone Fuseau horaire de l'application (PHP) dashboard.bad_timezone_configuration Mauvaise configuration du fuseau horaire dashboard.no_timezone_configuration Non appliqué par l'application dashboard.users Utilisateurs dashboard.calendars Calendriers dashboard.calendars.help (incl. listes de rappels) dashboard.events Événements dashboard.address_books Carnets d'adresses dashboard.contacts Contacts addressbooks.edit Modification du carnet d'adresses « {name} » edit Modifier add Ajouter delete Supprimer calendars.edit Modification du calendrier « {name} » calendars.entries {count, plural, =0 {Aucune entrée} one {Une entrée} other {# entrées}} calendars.component.events Événements calendars.component.todos Tâches calendars.setup.title Informations de configuration calendars.component.notes Notes users.edit Modification de l'utilisateur « {username} » addressbooks.for Carnets d'adresses de {who} addressbooks.uri URI du carnet d'adresses addressbooks.contacts {count, plural, =0 {Aucun contact} one {Un contact} other {# contacts}} calendars.for Calendriers de {who} calendars.shared.with Calendriers partagés avec {who} users.username Nom d'utilisateur users.uri URI principal users.administrator Administrateur users.calendars Calendriers users.addressbooks Carnets d'adresses default.calendar.title Calendrier par défaut default.calendar.description Calendrier par défaut de {user} default.addressbook.title Carnet d'adresses par défaut default.addressbook.description Carnet d'adresses par défaut de {user} user.saved Utilisateur enregistré avec succès user.deleted Utilisateur supprimé avec succès calendar.saved Calendrier enregistré avec succès calendar.deleted Calendrier supprimé avec succès calendar.shared Partages du calendrier modifiés avec succès calendar.revoked Accès au calendrier révoqué avec succès calendars.delegates.member.add Partager ce calendrier avec un autre utilisateur : calendars.delegates.member.help L'ajout d'un utilisateur ayant déjà un accès partagé à ce calendrier n'affectera que ses droits d'accès calendars.delegates.member.none Il n'y a aucun autre utilisateur avec lequel partager ce calendrier addressbooks.saved Carnet d'adresses enregistré avec succès addressbooks.deleted Carnet d'adresses supprimé avec succès addressbooks.back Retour aux carnets d'adresses de {user} calendars.back Retour aux calendriers de {user} form.password.empty Laissez vide si vous ne souhaitez pas le modifier form.password.match Les champs de mot de passe doivent correspondre. form.password Mot de passe form.password.repeat Répéter le mot de passe form.email Email form.admin Cet utilisateur est-il administrateur ? form.admin.help Si coché, cet utilisateur (notamment son principal) sera injecté dans chaque règle ACL avec les privilèges '{DAV:}all' form.displayName Nom d'affichage form.username Nom d'utilisateur form.username.help Peut être un email, mais pas obligatoirement. form.events Événements form.notes Notes form.todos Tâches form.uri URI form.includedInBirthdayCalendar Inclus dans le calendrier des anniversaires ? form.description Description form.color Couleur du calendrier form.public Public form.name.help.carddav Ce nom s'affichera dans votre client CardDAV form.includedInBirthdayCalendar.help Lorsque cette option est sélectionnée, toutes les cartes contenant un anniversaire valide seront incluses dans le calendrier des anniversaires du compte principal, disponible comme calendrier partagé dans votre compte form.name.help.caldav Ce nom s'affichera dans votre client CalDAV form.uri.help.carddav C'est l'identifiant unique pour ce carnet d'adresses. Les caractères autorisés sont les chiffres, les lettres minuscules et le tiret « - ». form.uri.help.caldav C'est l'identifiant unique pour ce calendrier. Les caractères autorisés sont les chiffres, les lettres minuscules et le tiret « - ». form.public.help.caldav Si le calendrier est public, il sera disponible pour toute personne ayant le lien form.color.help C'est la couleur qui s'affichera dans votre client CalDAV. Elle doit être fournie au format « #RRGGBBAA » (le canal alpha est optionnel) avec des valeurs hexadécimales. Cette valeur est optionnelle. form.events.help Si coché, les événements seront activés sur ce calendrier. Il apparaîtra dans les clients calendrier. form.todos.help Si coché, les tâches seront activées sur ce calendrier. Il apparaîtra dans les clients de rappels (comme l'app Rappels de macOS) form.notes.help Si coché, les notes seront activées sur ce calendrier. addressbooks.modal.title Supprimer ce carnet d'adresses ? addressbooks.modal.text Êtes-vous sûr de vouloir supprimer ce carnet d'adresses ? Tous les contacts qu'il contient seront supprimés. calendars.modal.title Supprimer ce calendrier ? calendars.modal.text Êtes-vous sûr de vouloir supprimer ce calendrier ? Tous les événements, tâches et notes qu'il contient seront supprimés. revoke.modal.title Révoquer l'accès à ce calendrier ? revoke.modal.text Êtes-vous sûr de vouloir révoquer l'accès à ce calendrier ? L'utilisateur perdra l'accès à tous les événements, tâches et notes. Le calendrier d'origine ne sera pas supprimé. users.modal.title Supprimer cet utilisateur ? users.modal.text Êtes-vous sûr de vouloir supprimer cet utilisateur ? Tous les calendriers et carnets d'adresses associés seront supprimés. delegates.modal.title Retirer ce délégué ? delegates.modal.text Êtes-vous sûr de vouloir retirer ce délégué ? Cet utilisateur n'aura plus accès aux calendriers, contacts, etc. delegates.member.help L'ajout d'un utilisateur qui est déjà délégué n'affectera que ses droits d'accès cancel Annuler yes Oui no Non login.signin Veuillez vous connecter login.username Nom d'utilisateur login.password Mot de passe login.submit Soumettre logout Se déconnecter login.already Vous êtes déjà connecté en tant que {username} no.users.yet Aucun utilisateur pour l'instant. remove Retirer revoke Révoquer sharing Partage calendars.sharing Partage du calendrier calendars.delegates.add Ajouter un délégué calendars.delegates.for Délégués de {what} calendars.delegates.new Nouveau délégué calendars.delegates.existing Ce calendrier est partagé avec : calendars.delegates.none aucun calendars.delegates.member Membre : calendars.delegates.write.give Accorder l'accès en écriture ? users.delegates Délégués delegates.enabled.text La délégation est activée pour ce compte. delegates.disable.warning ⚠ Si vous la désactivez, tous vos délégués perdront l'accès à ce calendrier delegates.disable La désactiver delegates.disabled.text La délégation n'est pas activée pour ce compte. delegates.enable L'activer delegates.write a l'accès en écriture delegates.readonly a l'accès en lecture seule calendar.share_access.2 lecture seule calendar.share_access.3 lecture / écriture calendar.share_access.10 public calendar.auto automatique calendar.subscription abonnement calendars.subscriptions Abonnements calendars.auto Générés automatiquement
================================================ FILE: translations/security.de.xlf ================================================
An authentication exception occurred. Es ist eine Authentifizierungsausnahme aufgetreten. Authentication credentials could not be found. Die Authentifizierungsdaten konnten nicht gefunden werden. Authentication request could not be processed due to a system problem. Die Authentifizierungsanfrage konnte aufgrund eines Systemproblems nicht verarbeitet werden. Invalid credentials. Ungültige Zugangsdaten. Cookie has already been used by someone else. Der Cookie wurde bereits von jemand anderem verwendet. Not privileged to request the resource. Nicht berechtigt die Ressource anzufordern. Invalid CSRF token. Ungültiger CSRF-Token. No authentication provider found to support the authentication token. Es wurde kein Authentifizierungsanbieter gefunden, der den Authentifizierungstoken unterstützt. No session available, it either timed out or cookies are not enabled. Keine Sitzung verfügbar, entweder ist die Zeit abgelaufen oder die Cookies sind nicht aktiviert. No token could be found. Es wurde kein Token gefunden. Username could not be found. Ungültige Zugangsdaten. Account has expired. Das Konto ist abgelaufen. Credentials have expired. Die Zugangsdaten sind abgelaufen. Account is disabled. Das Konto ist deaktiviert. Account is locked. Das Konto ist gesperrt.
================================================ FILE: translations/security.en.xlf ================================================
An authentication exception occurred. An authentication exception occurred. Authentication credentials could not be found. Authentication credentials could not be found. Authentication request could not be processed due to a system problem. Authentication request could not be processed due to a system problem. Invalid credentials. Invalid credentials. Cookie has already been used by someone else. Cookie has already been used by someone else. Not privileged to request the resource. Not privileged to request the resource. Invalid CSRF token. Invalid CSRF token. No authentication provider found to support the authentication token. No authentication provider found to support the authentication token. No session available, it either timed out or cookies are not enabled. No session available, it either timed out or cookies are not enabled. No token could be found. No token could be found. Username could not be found. Invalid credentials. Account has expired. Account has expired. Credentials have expired. Credentials have expired. Account is disabled. Account is disabled. Account is locked. Account is locked.
================================================ FILE: translations/security.fr.xlf ================================================ An authentication exception occurred. Une exception d'authentification s'est produite. Authentication credentials could not be found. Les identifiants d'authentification n'ont pas pu être trouvés. Authentication request could not be processed due to a system problem. La requête d'authentification n'a pas pu être executée à cause d'un problème système. Invalid credentials. Identifiants invalides. Cookie has already been used by someone else. Le cookie a déjà été utilisé par quelqu'un d'autre. Not privileged to request the resource. Privilèges insuffisants pour accéder à la ressource. Invalid CSRF token. Jeton CSRF invalide. No authentication provider found to support the authentication token. Aucun fournisseur d'authentification n'a été trouvé pour supporter le jeton d'authentification. No session available, it either timed out or cookies are not enabled. Aucune session disponible, celle-ci a expiré ou les cookies ne sont pas activés. No token could be found. Aucun jeton n'a pu être trouvé. Username could not be found. Identifiants invalides. Account has expired. Le compte a expiré. Credentials have expired. Les identifiants ont expiré. Account is disabled. Le compte est désactivé. Account is locked. Le compte est bloqué. Too many failed login attempts, please try again later. Plusieurs tentatives de connexion ont échoué, veuillez réessayer plus tard. Invalid or expired login link. Lien de connexion invalide ou expiré. Too many failed login attempts, please try again in %minutes% minute. Plusieurs tentatives de connexion ont échoué, veuillez réessayer dans %minutes% minute. Too many failed login attempts, please try again in %minutes% minutes. Trop de tentatives de connexion échouées, veuillez réessayer dans %minutes% minutes. ================================================ FILE: translations/validators.de.xlf ================================================
This value should be false. Dieser Wert solle "false" sein. This value should be true. Dieser Wert sollte "true" sein. This value should be of type {{ type }}. Dieser Wert sollte vom Typ {{ type }} sein. This value should be blank. Dieser Wert sollte leer sein. The value you selected is not a valid choice. Der von Ihnen gewählte Wert ist keine gültige Auswahl. You must select at least {{ limit }} choice.|You must select at least {{ limit }} choices. Sie müssen mindestens {{ Limit }} Auswahlmöglichkeiten wählen.|Sie müssen mindestens {{ Limit }} Auswahlmöglichkeiten wählen. You must select at most {{ limit }} choice.|You must select at most {{ limit }} choices. Sie dürfen höchstens {{ Limit }} Auswahlmöglichkeiten wählen.|Sie dürfen höchstens {{ Limit }} Auswahlmöglichkeiten wählen. One or more of the given values is invalid. Einer oder mehrere der angegebenen Werte sind ungültig. This field was not expected. Dieses Feld wurde nicht erwartet. This field is missing. Dieses Feld fehlt. This value is not a valid date. Dieser Wert ist kein gültiges Datum. This value is not a valid datetime. Dieser Wert ist keine gültige Zeitangabe. This value is not a valid email address. Dieser Wert ist keine gültige E-Mail-Adresse. The file could not be found. Die Datei wurde nicht gefunden. The file is not readable. Die Datei konnte nicht gelesen werden. The file is too large ({{ size }} {{ suffix }}). Allowed maximum size is {{ limit }} {{ suffix }}. Die Datei ist zu groß ({{ size }} {{ suffix }}). Die zulässige Höchstgröße ist {{ limit }} {{ Suffix }}. The mime type of the file is invalid ({{ type }}). Allowed mime types are {{ types }}. Der Mime-Typ der Datei ist ungültig ({{ type }}). Erlaubte Mime-Typen sind {{ types }}. This value should be {{ limit }} or less. Dieser Wert sollte {{ limit }} oder weniger betragen. This value is too long. It should have {{ limit }} character or less.|This value is too long. It should have {{ limit }} characters or less. Dieser Wert ist zu lang. Er sollte {{ limit }} Zeichen oder weniger haben.|Dieser Wert ist zu lang. Er sollte {{ limit }} Zeichen oder weniger haben. This value should be {{ limit }} or more. Dieser Wert sollte {{ limit }} oder mehr betragen. This value is too short. It should have {{ limit }} character or more.|This value is too short. It should have {{ limit }} characters or more. Dieser Wert ist zu kurz. Er sollte {{ limit }} Zeichen oder mehr haben.|Dieser Wert ist zu kurz. Er sollte {{ limit }} Zeichen oder mehr haben. This value should not be blank. Dieser Wert darf nicht leer sein. This value should not be null. Dieser Wert sollte nicht "null" sein. This value should be null. Dieser Wert sollte "null" sein. This value is not valid. Dieser Wert ist ungültig. This value is not a valid time. Dieser Wert ist keine gültige Uhrzeit. This value is not a valid URL. Dieser Wert ist keine gültige URL. The two values should be equal. Die beiden Werte sollten gleich sein. The file is too large. Allowed maximum size is {{ limit }} {{ suffix }}. Die Datei ist zu groß. Erlaubte maximale Größe ist {{ limit }} {{ suffix }}. The file is too large. Die Datei ist zu groß. The file could not be uploaded. Die Datei konnte nicht hochgeladen werden. This value should be a valid number. Dieser Wert sollte eine gültige Zahl sein. This file is not a valid image. Diese Datei ist kein gültiges Bild. This is not a valid IP address. Das ist keine gültige IP-Adresse. This value is not a valid language. Dieser Wert ist keine gültige Sprache. This value is not a valid locale. Dieser Wert ist kein gültiges Gebietsschema. This value is not a valid country. Dieser Wert ist kein gültiges Land. This value is already used. Dieser Wert wird bereits verwendet. The size of the image could not be detected. Die Größe des Bildes konnte nicht ermittelt werden. The image width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. Die Bildbreite ist zu groß ({{ width }}px). Erlaubte maximale Breite ist {{ max_width }}px. The image width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. Die Bildbreite ist zu klein ({{ width }}px). Die erwartete Mindestbreite ist {{ min_width }}px. The image height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. Die Bildhöhe ist zu groß ({{ height }}px). Erlaubte maximale Höhe ist {{ max_height }}px. The image height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. Die Bildhöhe ist zu klein ({{ height }}px). Die erwartete Mindesthöhe ist {{ min_height }}px. This value should be the user's current password. Dieser Wert sollte das aktuelle Passwort des Benutzers sein. This value should have exactly {{ limit }} character.|This value should have exactly {{ limit }} characters. Dieser Wert sollte genau {{ Grenzwert }} Zeichen haben.|Dieser Wert sollte genau {{ Grenzwert }} Zeichen haben. The file was only partially uploaded. Die Datei wurde nur teilweise hochgeladen. No file was uploaded. Es wurde keine Datei hochgeladen. No temporary folder was configured in php.ini. In der php.ini wurde kein temporärer Ordner konfiguriert, oder der konfigurierte Ordner existiert nicht. Cannot write temporary file to disk. Temporäre Datei kann nicht auf die Festplatte geschrieben werden. A PHP extension caused the upload to fail. Wegen einer PHP-Erweiterung ist das Hochladen gescheitert. This collection should contain {{ limit }} element or more.|This collection should contain {{ limit }} elements or more. Diese Sammlung sollte {{ limit }} Elemente oder mehr enthalten.|Diese Sammlung sollte {{ limit }} Elemente oder mehr enthalten. This collection should contain {{ limit }} element or less.|This collection should contain {{ limit }} elements or less. Diese Sammlung sollte {{ limit }} Element oder weniger enthalten.|Diese Sammlung sollte {{ limit }} Elemente oder weniger enthalten. This collection should contain exactly {{ limit }} element.|This collection should contain exactly {{ limit }} elements. Diese Sammlung sollte genau {{ Limit }} Elemente enthalten.|Diese Sammlung sollte genau {{ Limit }} Elemente enthalten. Invalid card number. Ungültige Kartennummer. Unsupported card type or invalid card number. Nicht unterstützter Kartentyp oder ungültige Kartennummer. This is not a valid International Bank Account Number (IBAN). Das ist keine gültige internationale Bankkontonummer (IBAN). This value is not a valid ISBN-10. Dieser Wert ist keine gültige ISBN-10. This value is not a valid ISBN-13. Dieser Wert ist keine gültige ISBN-13. This value is neither a valid ISBN-10 nor a valid ISBN-13. Dieser Wert ist weder eine gültige ISBN-10 noch eine gültige ISBN-13. This value is not a valid ISSN. Dieser Wert ist keine gültige ISSN. This value is not a valid currency. Dieser Wert ist keine gültige Währung. This value should be equal to {{ compared_value }}. Dieser Wert sollte gleich {{ compared_value }} sein. This value should be greater than {{ compared_value }}. Dieser Wert sollte größer {{ compared_value }} sein. This value should be greater than or equal to {{ compared_value }}. Dieser Wert sollte größer oder gleich {{ compared_value }} sein. This value should be identical to {{ compared_value_type }} {{ compared_value }}. Dieser Wert sollte identisch sein mit {{ compared_value_type }} {{ compared_value }}. This value should be less than {{ compared_value }}. Dieser Wert sollte kleiner als {{ compared_value }} sein. This value should be less than or equal to {{ compared_value }}. Dieser Wert sollte gleich oder kleiner als {{ compared_value }} sein. This value should not be equal to {{ compared_value }}. Dieser Wert sollte nicht gleich {{ compared_value }} sein. This value should not be identical to {{ compared_value_type }} {{ compared_value }}. Dieser Wert sollte nicht identisch sein mit {{ compared_value_type }} {{ compared_value }}. The image ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}. Das Bildverhältnis ist zu groß ({{ ratio }}). Das zulässige maximale Verhältnis ist {{ max_ratio }}. The image ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}. Das Bildverhältnis ist zu klein ({{ ratio }}). Das erwartete Mindestverhältnis ist {{ min_ratio }}. The image is square ({{ width }}x{{ height }}px). Square images are not allowed. Das Bild ist quadratisch ({{ width }}x{{ height }}px). Quadratische Bilder sind nicht erlaubt. The image is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented images are not allowed. Das Bild ist im Querformat ({{ width }}x{{ height }}px). Bilder im Querformat sind nicht erlaubt. The image is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented images are not allowed. Das Bild ist im Hochformat ({{ width }}x{{ height }}px). Bilder im Hochformat sind nicht erlaubt. An empty file is not allowed. Eine leere Datei ist nicht erlaubt. The host could not be resolved. Der Host konnte nicht aufgelöst werden. This value does not match the expected {{ charset }} charset. Dieser Wert stimmt nicht mit dem erwarteten {{ charset }} Zeichensatz überein. This is not a valid Business Identifier Code (BIC). Dies ist kein gültiger Business Identifier Code (BIC). Error Fehler This is not a valid UUID. Dies ist keine gültige UUID. This value should be a multiple of {{ compared_value }}. Dieser Wert sollte ein Vielfaches von {{ compared_value }} sein. This Business Identifier Code (BIC) is not associated with IBAN {{ iban }}. Dieser Business Identifier Code (BIC) ist nicht mit der IBAN {{ iban }} verbunden. This value should be valid JSON. Dieser Wert sollte gültiges JSON sein. This collection should contain only unique elements. Diese Sammlung sollte nur eindeutige Elemente enthalten. This value should be positive. Dieser Wert sollte positiv sein. This value should be either positive or zero. Dieser Wert sollte entweder positiv oder Null sein. This value should be negative. Dieser Wert sollte negativ sein. This value should be either negative or zero. Dieser Wert sollte entweder negativ oder Null sein. This value is not a valid timezone. Dieser Wert ist keine gültige Zeitzone. This password has been leaked in a data breach, it must not be used. Please use another password. Dieses Passwort ist durch eine Datenpanne bekannt geworden und darf nicht mehr verwendet werden. Bitte verwenden Sie ein anderes Passwort. This value should be between {{ min }} and {{ max }}. Dieser Wert sollte zwischen {{ min }} und {{ max }} liegen. This form should not contain extra fields. Dieses Formular sollte keine zusätzlichen Felder enthalten. The uploaded file was too large. Please try to upload a smaller file. Die hochgeladene Datei war zu groß. Bitte versuchen Sie, eine kleinere Datei hochzuladen. The CSRF token is invalid. Please try to resubmit the form. Das CSRF-Token ist ungültig. Bitte versuchen Sie, das Formular erneut abzuschicken. form.uri.unique Diese URI wird bereits für diesen Auftraggeber verwendet. Bitte wählen Sie einen anderen.
================================================ FILE: translations/validators.en.xlf ================================================
This value should be false. This value should be false. This value should be true. This value should be true. This value should be of type {{ type }}. This value should be of type {{ type }}. This value should be blank. This value should be blank. The value you selected is not a valid choice. The value you selected is not a valid choice. You must select at least {{ limit }} choice.|You must select at least {{ limit }} choices. You must select at least {{ limit }} choice.|You must select at least {{ limit }} choices. You must select at most {{ limit }} choice.|You must select at most {{ limit }} choices. You must select at most {{ limit }} choice.|You must select at most {{ limit }} choices. One or more of the given values is invalid. One or more of the given values is invalid. This field was not expected. This field was not expected. This field is missing. This field is missing. This value is not a valid date. This value is not a valid date. This value is not a valid datetime. This value is not a valid datetime. This value is not a valid email address. This value is not a valid email address. The file could not be found. The file could not be found. The file is not readable. The file is not readable. The file is too large ({{ size }} {{ suffix }}). Allowed maximum size is {{ limit }} {{ suffix }}. The file is too large ({{ size }} {{ suffix }}). Allowed maximum size is {{ limit }} {{ suffix }}. The mime type of the file is invalid ({{ type }}). Allowed mime types are {{ types }}. The mime type of the file is invalid ({{ type }}). Allowed mime types are {{ types }}. This value should be {{ limit }} or less. This value should be {{ limit }} or less. This value is too long. It should have {{ limit }} character or less.|This value is too long. It should have {{ limit }} characters or less. This value is too long. It should have {{ limit }} character or less.|This value is too long. It should have {{ limit }} characters or less. This value should be {{ limit }} or more. This value should be {{ limit }} or more. This value is too short. It should have {{ limit }} character or more.|This value is too short. It should have {{ limit }} characters or more. This value is too short. It should have {{ limit }} character or more.|This value is too short. It should have {{ limit }} characters or more. This value should not be blank. This value should not be blank. This value should not be null. This value should not be null. This value should be null. This value should be null. This value is not valid. This value is not valid. This value is not a valid time. This value is not a valid time. This value is not a valid URL. This value is not a valid URL. The two values should be equal. The two values should be equal. The file is too large. Allowed maximum size is {{ limit }} {{ suffix }}. The file is too large. Allowed maximum size is {{ limit }} {{ suffix }}. The file is too large. The file is too large. The file could not be uploaded. The file could not be uploaded. This value should be a valid number. This value should be a valid number. This file is not a valid image. This file is not a valid image. This is not a valid IP address. This is not a valid IP address. This value is not a valid language. This value is not a valid language. This value is not a valid locale. This value is not a valid locale. This value is not a valid country. This value is not a valid country. This value is already used. This value is already used. The size of the image could not be detected. The size of the image could not be detected. The image width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. The image width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. The image width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. The image width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. The image height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. The image height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. The image height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. The image height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. This value should be the user's current password. This value should be the user's current password. This value should have exactly {{ limit }} character.|This value should have exactly {{ limit }} characters. This value should have exactly {{ limit }} character.|This value should have exactly {{ limit }} characters. The file was only partially uploaded. The file was only partially uploaded. No file was uploaded. No file was uploaded. No temporary folder was configured in php.ini. No temporary folder was configured in php.ini, or the configured folder does not exist. Cannot write temporary file to disk. Cannot write temporary file to disk. A PHP extension caused the upload to fail. A PHP extension caused the upload to fail. This collection should contain {{ limit }} element or more.|This collection should contain {{ limit }} elements or more. This collection should contain {{ limit }} element or more.|This collection should contain {{ limit }} elements or more. This collection should contain {{ limit }} element or less.|This collection should contain {{ limit }} elements or less. This collection should contain {{ limit }} element or less.|This collection should contain {{ limit }} elements or less. This collection should contain exactly {{ limit }} element.|This collection should contain exactly {{ limit }} elements. This collection should contain exactly {{ limit }} element.|This collection should contain exactly {{ limit }} elements. Invalid card number. Invalid card number. Unsupported card type or invalid card number. Unsupported card type or invalid card number. This is not a valid International Bank Account Number (IBAN). This is not a valid International Bank Account Number (IBAN). This value is not a valid ISBN-10. This value is not a valid ISBN-10. This value is not a valid ISBN-13. This value is not a valid ISBN-13. This value is neither a valid ISBN-10 nor a valid ISBN-13. This value is neither a valid ISBN-10 nor a valid ISBN-13. This value is not a valid ISSN. This value is not a valid ISSN. This value is not a valid currency. This value is not a valid currency. This value should be equal to {{ compared_value }}. This value should be equal to {{ compared_value }}. This value should be greater than {{ compared_value }}. This value should be greater than {{ compared_value }}. This value should be greater than or equal to {{ compared_value }}. This value should be greater than or equal to {{ compared_value }}. This value should be identical to {{ compared_value_type }} {{ compared_value }}. This value should be identical to {{ compared_value_type }} {{ compared_value }}. This value should be less than {{ compared_value }}. This value should be less than {{ compared_value }}. This value should be less than or equal to {{ compared_value }}. This value should be less than or equal to {{ compared_value }}. This value should not be equal to {{ compared_value }}. This value should not be equal to {{ compared_value }}. This value should not be identical to {{ compared_value_type }} {{ compared_value }}. This value should not be identical to {{ compared_value_type }} {{ compared_value }}. The image ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}. The image ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}. The image ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}. The image ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}. The image is square ({{ width }}x{{ height }}px). Square images are not allowed. The image is square ({{ width }}x{{ height }}px). Square images are not allowed. The image is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented images are not allowed. The image is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented images are not allowed. The image is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented images are not allowed. The image is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented images are not allowed. An empty file is not allowed. An empty file is not allowed. The host could not be resolved. The host could not be resolved. This value does not match the expected {{ charset }} charset. This value does not match the expected {{ charset }} charset. This is not a valid Business Identifier Code (BIC). This is not a valid Business Identifier Code (BIC). Error Error This is not a valid UUID. This is not a valid UUID. This value should be a multiple of {{ compared_value }}. This value should be a multiple of {{ compared_value }}. This Business Identifier Code (BIC) is not associated with IBAN {{ iban }}. This Business Identifier Code (BIC) is not associated with IBAN {{ iban }}. This value should be valid JSON. This value should be valid JSON. This collection should contain only unique elements. This collection should contain only unique elements. This value should be positive. This value should be positive. This value should be either positive or zero. This value should be either positive or zero. This value should be negative. This value should be negative. This value should be either negative or zero. This value should be either negative or zero. This value is not a valid timezone. This value is not a valid timezone. This password has been leaked in a data breach, it must not be used. Please use another password. This password has been leaked in a data breach, it must not be used. Please use another password. This value should be between {{ min }} and {{ max }}. This value should be between {{ min }} and {{ max }}. This form should not contain extra fields. This form should not contain extra fields. The uploaded file was too large. Please try to upload a smaller file. The uploaded file was too large. Please try to upload a smaller file. The CSRF token is invalid. Please try to resubmit the form. The CSRF token is invalid. Please try to resubmit the form. form.uri.unique This URI is already used with this principal. Please choose another one.
================================================ FILE: translations/validators.fr.xlf ================================================ This value should be false. Cette valeur doit être fausse. This value should be true. Cette valeur doit être vraie. This value should be of type {{ type }}. Cette valeur doit être de type {{ type }}. This value should be blank. Cette valeur doit être vide. The value you selected is not a valid choice. Cette valeur doit être l'un des choix proposés. You must select at least {{ limit }} choice.|You must select at least {{ limit }} choices. Vous devez sélectionner au moins {{ limit }} choix.|Vous devez sélectionner au moins {{ limit }} choix. You must select at most {{ limit }} choice.|You must select at most {{ limit }} choices. Vous devez sélectionner au maximum {{ limit }} choix.|Vous devez sélectionner au maximum {{ limit }} choix. One or more of the given values is invalid. Une ou plusieurs des valeurs soumises sont invalides. This field was not expected. Ce champ n'a pas été prévu. This field is missing. Ce champ est manquant. This value is not a valid date. Cette valeur n'est pas une date valide. This value is not a valid datetime. Cette valeur n'est pas une date valide. This value is not a valid email address. Cette valeur n'est pas une adresse email valide. The file could not be found. Le fichier n'a pas été trouvé. The file is not readable. Le fichier n'est pas lisible. The file is too large ({{ size }} {{ suffix }}). Allowed maximum size is {{ limit }} {{ suffix }}. Le fichier est trop volumineux ({{ size }} {{ suffix }}). Sa taille ne doit pas dépasser {{ limit }} {{ suffix }}. The mime type of the file is invalid ({{ type }}). Allowed mime types are {{ types }}. Le type du fichier est invalide ({{ type }}). Les types autorisés sont {{ types }}. This value should be {{ limit }} or less. Cette valeur doit être inférieure ou égale à {{ limit }}. This value is too long. It should have {{ limit }} character or less.|This value is too long. It should have {{ limit }} characters or less. Cette chaîne est trop longue. Elle doit avoir au maximum {{ limit }} caractère.|Cette chaîne est trop longue. Elle doit avoir au maximum {{ limit }} caractères. This value should be {{ limit }} or more. Cette valeur doit être supérieure ou égale à {{ limit }}. This value is too short. It should have {{ limit }} character or more.|This value is too short. It should have {{ limit }} characters or more. Cette chaîne est trop courte. Elle doit avoir au minimum {{ limit }} caractère.|Cette chaîne est trop courte. Elle doit avoir au minimum {{ limit }} caractères. This value should not be blank. Cette valeur ne doit pas être vide. This value should not be null. Cette valeur ne doit pas être nulle. This value should be null. Cette valeur doit être nulle. This value is not valid. Cette valeur n'est pas valide. This value is not a valid time. Cette valeur n'est pas une heure valide. This value is not a valid URL. Cette valeur n'est pas une URL valide. The two values should be equal. Les deux valeurs doivent être identiques. The file is too large. Allowed maximum size is {{ limit }} {{ suffix }}. Le fichier est trop volumineux. Sa taille ne doit pas dépasser {{ limit }} {{ suffix }}. The file is too large. Le fichier est trop volumineux. The file could not be uploaded. Le téléchargement de ce fichier est impossible. This value should be a valid number. Cette valeur doit être un nombre. This file is not a valid image. Ce fichier n'est pas une image valide. This value is not a valid IP address. Cette valeur n'est pas une adresse IP valide. This value is not a valid language. Cette langue n'est pas valide. This value is not a valid locale. Ce paramètre régional n'est pas valide. This value is not a valid country. Ce pays n'est pas valide. This value is already used. Cette valeur est déjà utilisée. The size of the image could not be detected. La taille de l'image n'a pas pu être détectée. The image width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. La largeur de l'image est trop grande ({{ width }}px). La largeur maximale autorisée est de {{ max_width }}px. The image width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. La largeur de l'image est trop petite ({{ width }}px). La largeur minimale attendue est de {{ min_width }}px. The image height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. La hauteur de l'image est trop grande ({{ height }}px). La hauteur maximale autorisée est de {{ max_height }}px. The image height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. La hauteur de l'image est trop petite ({{ height }}px). La hauteur minimale attendue est de {{ min_height }}px. This value should be the user's current password. Cette valeur doit être le mot de passe actuel de l'utilisateur. This value should have exactly {{ limit }} character.|This value should have exactly {{ limit }} characters. Cette chaîne doit avoir exactement {{ limit }} caractère.|Cette chaîne doit avoir exactement {{ limit }} caractères. The file was only partially uploaded. Le fichier a été partiellement transféré. No file was uploaded. Aucun fichier n'a été transféré. No temporary folder was configured in php.ini, or the configured folder does not exist. Aucun répertoire temporaire n'a été configuré dans le php.ini, ou le répertoire configuré n'existe pas. Cannot write temporary file to disk. Impossible d'écrire le fichier temporaire sur le disque. A PHP extension caused the upload to fail. Une extension PHP a empêché le transfert du fichier. This collection should contain {{ limit }} element or more.|This collection should contain {{ limit }} elements or more. Cette collection doit contenir {{ limit }} élément ou plus.|Cette collection doit contenir {{ limit }} éléments ou plus. This collection should contain {{ limit }} element or less.|This collection should contain {{ limit }} elements or less. Cette collection doit contenir {{ limit }} élément ou moins.|Cette collection doit contenir {{ limit }} éléments ou moins. This collection should contain exactly {{ limit }} element.|This collection should contain exactly {{ limit }} elements. Cette collection doit contenir exactement {{ limit }} élément.|Cette collection doit contenir exactement {{ limit }} éléments. Invalid card number. Numéro de carte invalide. Unsupported card type or invalid card number. Type de carte non supporté ou numéro invalide. This value is not a valid International Bank Account Number (IBAN). Cette valeur n'est pas un Numéro de Compte Bancaire International (IBAN) valide. This value is not a valid ISBN-10. Cette valeur n'est pas un code ISBN-10 valide. This value is not a valid ISBN-13. Cette valeur n'est pas un code ISBN-13 valide. This value is neither a valid ISBN-10 nor a valid ISBN-13. Cette valeur n'est ni un code ISBN-10, ni un code ISBN-13 valide. This value is not a valid ISSN. Cette valeur n'est pas un code ISSN valide. This value is not a valid currency. Cette valeur n'est pas une devise valide. This value should be equal to {{ compared_value }}. Cette valeur doit être égale à {{ compared_value }}. This value should be greater than {{ compared_value }}. Cette valeur doit être supérieure à {{ compared_value }}. This value should be greater than or equal to {{ compared_value }}. Cette valeur doit être supérieure ou égale à {{ compared_value }}. This value should be identical to {{ compared_value_type }} {{ compared_value }}. Cette valeur doit être identique à {{ compared_value_type }} {{ compared_value }}. This value should be less than {{ compared_value }}. Cette valeur doit être inférieure à {{ compared_value }}. This value should be less than or equal to {{ compared_value }}. Cette valeur doit être inférieure ou égale à {{ compared_value }}. This value should not be equal to {{ compared_value }}. Cette valeur ne doit pas être égale à {{ compared_value }}. This value should not be identical to {{ compared_value_type }} {{ compared_value }}. Cette valeur ne doit pas être identique à {{ compared_value_type }} {{ compared_value }}. The image ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}. Le rapport largeur/hauteur de l'image est trop grand ({{ ratio }}). Le rapport maximal autorisé est {{ max_ratio }}. The image ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}. Le rapport largeur/hauteur de l'image est trop petit ({{ ratio }}). Le rapport minimal attendu est {{ min_ratio }}. The image is square ({{ width }}x{{ height }}px). Square images are not allowed. L'image est carrée ({{ width }}x{{ height }}px). Les images carrées ne sont pas autorisées. The image is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented images are not allowed. L'image est au format paysage ({{ width }}x{{ height }}px). Les images au format paysage ne sont pas autorisées. The image is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented images are not allowed. L'image est au format portrait ({{ width }}x{{ height }}px). Les images au format portrait ne sont pas autorisées. An empty file is not allowed. Un fichier vide n'est pas autorisé. The host could not be resolved. Le nom de domaine n'a pas pu être résolu. This value does not match the expected {{ charset }} charset. Cette valeur ne correspond pas au jeu de caractères {{ charset }} attendu. This value is not a valid Business Identifier Code (BIC). Cette valeur n'est pas un Code Identifiant de Business (BIC) valide. Error Erreur This value is not a valid UUID. Cette valeur n'est pas un UUID valide. This value should be a multiple of {{ compared_value }}. Cette valeur doit être un multiple de {{ compared_value }}. This Business Identifier Code (BIC) is not associated with IBAN {{ iban }}. Ce code d'identification d'entreprise (BIC) n'est pas associé à l'IBAN {{ iban }}. This value should be valid JSON. Cette valeur doit être un JSON valide. This collection should contain only unique elements. Cette collection ne doit pas comporter de doublons. This value should be positive. Cette valeur doit être strictement positive. This value should be either positive or zero. Cette valeur doit être supérieure ou égale à zéro. This value should be negative. Cette valeur doit être strictement négative. This value should be either negative or zero. Cette valeur doit être inférieure ou égale à zéro. This value is not a valid timezone. Cette valeur n'est pas un fuseau horaire valide. This password has been leaked in a data breach, it must not be used. Please use another password. Ce mot de passe a été divulgué lors d'une fuite de données, il ne doit plus être utilisé. Veuillez utiliser un autre mot de passe. This value should be between {{ min }} and {{ max }}. Cette valeur doit être comprise entre {{ min }} et {{ max }}. This value is not a valid hostname. Cette valeur n'est pas un nom d'hôte valide. The number of elements in this collection should be a multiple of {{ compared_value }}. Le nombre d'éléments de cette collection doit être un multiple de {{ compared_value }}. This value should satisfy at least one of the following constraints: Cette valeur doit satisfaire à au moins une des contraintes suivantes : Each element of this collection should satisfy its own set of constraints. Chaque élément de cette collection doit satisfaire à son propre jeu de contraintes. This value is not a valid International Securities Identification Number (ISIN). Cette valeur n'est pas un code international de sécurité valide (ISIN). This value should be a valid expression. Cette valeur doit être une expression valide. This value is not a valid CSS color. Cette valeur n'est pas une couleur CSS valide. This value is not a valid CIDR notation. Cette valeur n'est pas une notation CIDR valide. The value of the netmask should be between {{ min }} and {{ max }}. La valeur du masque de réseau doit être comprise entre {{ min }} et {{ max }}. The filename is too long. It should have {{ filename_max_length }} character or less.|The filename is too long. It should have {{ filename_max_length }} characters or less. Le nom du fichier est trop long. Il doit contenir au maximum {{ filename_max_length }} caractère.|Le nom de fichier est trop long. Il doit contenir au maximum {{ filename_max_length }} caractères. The password strength is too low. Please use a stronger password. La force du mot de passe est trop faible. Veuillez utiliser un mot de passe plus fort. This value contains characters that are not allowed by the current restriction-level. Cette valeur contient des caractères qui ne sont pas autorisés par le niveau de restriction actuel. Using invisible characters is not allowed. Utiliser des caractères invisibles n'est pas autorisé. Mixing numbers from different scripts is not allowed. Mélanger des chiffres provenant de différents scripts n'est pas autorisé. Using hidden overlay characters is not allowed. Utiliser des caractères de superposition cachés n'est pas autorisé. The extension of the file is invalid ({{ extension }}). Allowed extensions are {{ extensions }}. L'extension du fichier est invalide ({{ extension }}). Les extensions autorisées sont {{ extensions }}. The detected character encoding is invalid ({{ detected }}). Allowed encodings are {{ encodings }}. L'encodage de caractères détecté est invalide ({{ detected }}). Les encodages autorisés sont {{ encodings }}. This value is not a valid MAC address. Cette valeur n'est pas une adresse MAC valide. This URL is missing a top-level domain. Cette URL doit contenir un domaine de premier niveau. This value is too short. It should contain at least one word.|This value is too short. It should contain at least {{ min }} words. Cette valeur est trop courte. Elle doit contenir au moins un mot.|Cette valeur est trop courte. Elle doit contenir au moins {{ min }} mots. This value is too long. It should contain one word.|This value is too long. It should contain {{ max }} words or less. Cette valeur est trop longue. Elle doit contenir au maximum un mot.|Cette valeur est trop longue. Elle doit contenir au maximum {{ max }} mots. This value does not represent a valid week in the ISO 8601 format. Cette valeur ne représente pas une semaine valide au format ISO 8601. This value is not a valid week. Cette valeur n'est pas une semaine valide. This value should not be before week "{{ min }}". Cette valeur ne doit pas être antérieure à la semaine "{{ min }}". This value should not be after week "{{ max }}". Cette valeur ne doit pas être postérieure à la semaine "{{ max }}". This value is not a valid Twig template. Cette valeur n'est pas un modèle Twig valide. This file is not a valid video. Ce fichier n’est pas une vidéo valide. The size of the video could not be detected. La taille de la vidéo n’a pas pu être détectée. The video width is too big ({{ width }}px). Allowed maximum width is {{ max_width }}px. La largeur de la vidéo est trop grande ({{ width }}px). La largeur maximale autorisée est de {{ max_width }}px. The video width is too small ({{ width }}px). Minimum width expected is {{ min_width }}px. La largeur de la vidéo est trop petite ({{ width }}px). La largeur minimale attendue est de {{ min_width }}px. The video height is too big ({{ height }}px). Allowed maximum height is {{ max_height }}px. La hauteur de la vidéo est trop grande ({{ height }}px). La hauteur maximale autorisée est de {{ max_height }}px. The video height is too small ({{ height }}px). Minimum height expected is {{ min_height }}px. La hauteur de la vidéo est trop petite ({{ height }}px). La hauteur minimale attendue est de {{ min_height }}px. The video has too few pixels ({{ pixels }} pixels). Minimum amount expected is {{ min_pixels }} pixels. La vidéo a trop peu de pixels ({{ pixels }}). La quantité minimale attendue est de {{ min_pixels }} pixels. The video has too many pixels ({{ pixels }} pixels). Maximum amount expected is {{ max_pixels }} pixels. La vidéo contient trop de pixels ({{ pixels }}). La quantité maximale attendue est de {{ max_pixels }} pixels. The video ratio is too big ({{ ratio }}). Allowed maximum ratio is {{ max_ratio }}. Le ratio de la vidéo est trop élevé ({{ ratio }}). Le ratio maximal autorisé est de {{ max_ratio }}. The video ratio is too small ({{ ratio }}). Minimum ratio expected is {{ min_ratio }}. Le ratio de la vidéo est trop petit ({{ ratio }}). Le ratio minimum attendu est de {{ min_ratio }}. The video is square ({{ width }}x{{ height }}px). Square videos are not allowed. La vidéo est carrée ({{ width }}x{{ height }}px). Les vidéos carrées ne sont pas autorisées. The video is landscape oriented ({{ width }}x{{ height }}px). Landscape oriented videos are not allowed. La vidéo est au format paysage ({{ width }}x{{ height }} px). Les vidéos au format paysage ne sont pas autorisées. The video is portrait oriented ({{ width }}x{{ height }}px). Portrait oriented videos are not allowed. La vidéo est orientée en portrait ({{ width }}x{{ height }} px). Les vidéos en orientation portrait ne sont pas autorisées. The video file is corrupted. Le fichier vidéo est corrompu. The video contains multiple streams. Only one stream is allowed. La vidéo contient plusieurs flux. Un seul flux est autorisé. Unsupported video codec "{{ codec }}". Le codec vidéo «{{ codec }}» est non pris en charge. Unsupported video container "{{ container }}". Le conteneur vidéo «{{ container }}» est non pris en charge. The image file is corrupted. Le fichier image est corrompu. The image has too few pixels ({{ pixels }} pixels). Minimum amount expected is {{ min_pixels }} pixels. L’image comporte trop peu de pixels ({{ pixels }}). La quantité minimale attendue est de {{ min_pixels }} pixels. The image has too many pixels ({{ pixels }} pixels). Maximum amount expected is {{ max_pixels }} pixels. L’image contient trop de pixels ({{ pixels }}). La quantité maximale attendue est de {{ max_pixels }} pixels. This filename does not match the expected charset. Le nom de fichier ne correspond pas au jeu de caractères attendu. form.uri.unique Cette URI est déjà utilisée avec ce principal. Veuillez en choisir une autre.