├── tests ├── .gitignore ├── bootstrap.php └── Functional │ ├── DavTest.php │ ├── DashboardTest.php │ ├── AddressBookControllerTest.php │ ├── CalendarControllerTest.php │ └── UserControllerTest.php ├── translations ├── .gitignore ├── security.en.xlf └── security.de.xlf ├── public ├── robots.txt ├── favicon.ico ├── images │ ├── logo.png │ ├── marker.png │ ├── github-mark.png │ └── github-mark-white.png ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── site.webmanifest ├── index.php ├── css │ └── style.css ├── js │ └── color.mode.toggler.js └── .htaccess ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── config ├── packages │ ├── test │ │ ├── twig.yaml │ │ ├── validator.yaml │ │ ├── framework.yaml │ │ ├── web_profiler.yaml │ │ └── monolog.yaml │ ├── mailer.yaml │ ├── prod │ │ ├── routing.yaml │ │ ├── deprecations.yaml │ │ ├── monolog.yaml │ │ └── doctrine.yaml │ ├── dev │ │ ├── web_profiler.yaml │ │ ├── debug.yaml │ │ └── monolog.yaml │ ├── translation.yaml │ ├── doctrine_migrations.yaml │ ├── routing.yaml │ ├── validator.yaml │ ├── twig.yaml │ ├── cache.yaml │ ├── framework.yaml │ ├── doctrine.yaml │ └── security.yaml ├── routes.yaml ├── routes │ ├── dev │ │ ├── framework.yaml │ │ └── web_profiler.yaml │ └── attributes.yaml ├── bundles.php └── services.yaml ├── _screenshots ├── mode.png ├── user.png ├── sharing.png ├── status.png ├── dashboard.png ├── setup_info.png ├── PSD_files_for_screenshots.zip └── bad_timezone_configuration_env_var.png ├── src ├── Version.php ├── Constants.php ├── Kernel.php ├── Controller │ ├── SecurityController.php │ └── Admin │ │ └── DashboardController.php ├── Repository │ ├── PrincipalRepository.php │ └── CalendarInstanceRepository.php ├── Services │ ├── BasicAuth.php │ └── Utils.php ├── Entity │ ├── User.php │ ├── PropertyStorage.php │ ├── CalendarChange.php │ ├── AddressBookChange.php │ ├── Card.php │ ├── Lock.php │ ├── SchedulingObject.php │ ├── Calendar.php │ ├── Principal.php │ └── CalendarObject.php ├── Logging │ └── Monolog │ │ └── PasswordFilterProcessor.php ├── Security │ ├── AdminUser.php │ ├── AdminUserProvider.php │ └── LoginFormAuthenticator.php ├── Command │ └── SyncBirthdayCalendars.php ├── Form │ ├── AddressBookType.php │ ├── UserType.php │ └── CalendarInstanceType.php ├── DataFixtures │ └── AppFixtures.php └── Plugins │ ├── BirthdayCalendarPlugin.php │ └── PublicAwareDAVACLPlugin.php ├── templates ├── _partials │ ├── back_button.html.twig │ ├── flashes.html.twig │ ├── delete_modal.html.twig │ ├── delegate_row.html.twig │ ├── add_delegate_modal.html.twig │ ├── share_modal.html.twig │ └── navigation.html.twig ├── users │ ├── edit.html.twig │ ├── delegates.html.twig │ └── index.html.twig ├── calendars │ └── edit.html.twig ├── addressbooks │ ├── edit.html.twig │ └── index.html.twig ├── base.html.twig ├── mails │ └── scheduling.txt.twig ├── security │ └── login.html.twig ├── index.html.twig └── dashboard.html.twig ├── .dockerignore ├── docker ├── configurations │ ├── opcache.ini │ ├── supervisord.conf │ ├── nginx.conf │ └── Caddyfile ├── docker-compose-standalone.yml ├── docker-compose-sqlite.yml ├── docker-compose-postgresql.yml ├── .env ├── docker-compose.yml └── Dockerfile ├── .env.test ├── .gitignore ├── bin ├── phpunit └── console ├── .php-cs-fixer.php ├── phpunit.xml.dist ├── migrations ├── Version20191125093508.php ├── Version20210928132307.php ├── Version20191203111729.php ├── Version20250421163214.php ├── Version20231001214111.php ├── Version20191202091507.php ├── Version20231001214112.php ├── Version20191113170650.php ├── Version20231229203515.php ├── Version20250409193948.php ├── Version20231001214113.php └── Version20230209142217.php ├── LICENSE ├── composer.json └── .env /tests/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /translations/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ['https://www.paypal.me/tchap'] 2 | -------------------------------------------------------------------------------- /config/packages/test/twig.yaml: -------------------------------------------------------------------------------- 1 | twig: 2 | strict_variables: true 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/davis/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /_screenshots/mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/davis/HEAD/_screenshots/mode.png -------------------------------------------------------------------------------- /_screenshots/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/davis/HEAD/_screenshots/user.png -------------------------------------------------------------------------------- /public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/davis/HEAD/public/images/logo.png -------------------------------------------------------------------------------- /_screenshots/sharing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/davis/HEAD/_screenshots/sharing.png -------------------------------------------------------------------------------- /_screenshots/status.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/davis/HEAD/_screenshots/status.png -------------------------------------------------------------------------------- /config/packages/mailer.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | mailer: 3 | dsn: '%env(MAILER_DSN)%' 4 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/davis/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/davis/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/images/marker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/davis/HEAD/public/images/marker.png -------------------------------------------------------------------------------- /_screenshots/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/davis/HEAD/_screenshots/dashboard.png -------------------------------------------------------------------------------- /_screenshots/setup_info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/davis/HEAD/_screenshots/setup_info.png -------------------------------------------------------------------------------- /config/packages/prod/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | strict_requirements: null 4 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/davis/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /config/routes.yaml: -------------------------------------------------------------------------------- 1 | #index: 2 | # path: / 3 | # controller: App\Controller\DefaultController::index 4 | -------------------------------------------------------------------------------- /public/images/github-mark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/davis/HEAD/public/images/github-mark.png -------------------------------------------------------------------------------- /config/packages/test/validator.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | validation: 3 | not_compromised_password: false 4 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/davis/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/davis/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/images/github-mark-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/davis/HEAD/public/images/github-mark-white.png -------------------------------------------------------------------------------- /_screenshots/PSD_files_for_screenshots.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/davis/HEAD/_screenshots/PSD_files_for_screenshots.zip -------------------------------------------------------------------------------- /src/Version.php: -------------------------------------------------------------------------------- 1 | « {{ text }} -------------------------------------------------------------------------------- /config/packages/test/framework.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | test: true 3 | session: 4 | storage_factory_id: session.storage.factory.mock_file -------------------------------------------------------------------------------- /_screenshots/bad_timezone_configuration_env_var.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchapi/davis/HEAD/_screenshots/bad_timezone_configuration_env_var.png -------------------------------------------------------------------------------- /config/packages/test/web_profiler.yaml: -------------------------------------------------------------------------------- 1 | web_profiler: 2 | toolbar: false 3 | intercept_redirects: false 4 | 5 | framework: 6 | profiler: { collect: false } 7 | -------------------------------------------------------------------------------- /config/packages/dev/web_profiler.yaml: -------------------------------------------------------------------------------- 1 | web_profiler: 2 | toolbar: true 3 | intercept_redirects: false 4 | 5 | framework: 6 | profiler: { only_exceptions: false } 7 | -------------------------------------------------------------------------------- /config/routes/attributes.yaml: -------------------------------------------------------------------------------- 1 | controllers: 2 | resource: ../../src/Controller/ 3 | type: attribute 4 | 5 | kernel: 6 | resource: App\Kernel 7 | type: attribute 8 | -------------------------------------------------------------------------------- /config/packages/translation.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | default_locale: en 3 | translator: 4 | default_path: '%kernel.project_dir%/translations' 5 | fallbacks: 6 | - en 7 | -------------------------------------------------------------------------------- /src/Constants.php: -------------------------------------------------------------------------------- 1 | symfony/framework-bundle ### 2 | /.env.local 3 | /.env.local.php 4 | /.env.*.local 5 | /config/secrets/prod/prod.decrypt.private.php 6 | /public/bundles/ 7 | /var/ 8 | /vendor/ 9 | ###< symfony/framework-bundle ### 10 | 11 | ###> symfony/phpunit-bridge ### 12 | .phpunit 13 | .phpunit.result.cache 14 | /phpunit.xml 15 | ###< symfony/phpunit-bridge ### 16 | ###> friendsofphp/php-cs-fixer ### 17 | /.php_cs.cache 18 | ###< friendsofphp/php-cs-fixer ### 19 | 20 | .DS_Store 21 | TODO.todo 22 | webdav_* -------------------------------------------------------------------------------- /templates/users/edit.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | {% set menu = 'resources' %} 3 | 4 | {% block body %} 5 | 6 | {% include '_partials/back_button.html.twig' with { url: path('user_index'), text: "users.back"|trans } %} 7 | 8 | {% if username %} 9 |

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

10 | {% else %} 11 |

{{ "users.new"|trans }}

12 | {% endif %} 13 | 14 | {{ form(form) }} 15 | 16 | {% endblock %} -------------------------------------------------------------------------------- /config/packages/twig.yaml: -------------------------------------------------------------------------------- 1 | twig: 2 | default_path: '%kernel.project_dir%/templates' 3 | debug: '%kernel.debug%' 4 | strict_variables: '%kernel.debug%' 5 | form_themes: ['bootstrap_5_horizontal_layout.html.twig'] 6 | exception_controller: null 7 | globals: 8 | invite_from_address: '%env(INVITE_FROM_ADDRESS)%' 9 | calDAVEnabled: '%env(bool:CALDAV_ENABLED)%' 10 | cardDAVEnabled: '%env(bool:CARDDAV_ENABLED)%' 11 | webDAVEnabled: '%env(bool:WEBDAV_ENABLED)%' 12 | authRealm: '%env(AUTH_REALM)%' 13 | authMethod: '%env(AUTH_METHOD)%' -------------------------------------------------------------------------------- /config/packages/prod/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | handlers: 3 | main: 4 | type: fingers_crossed 5 | action_level: error 6 | handler: nested 7 | excluded_http_codes: [404, 405] 8 | buffer_size: 50 # How many messages should be saved? Prevent memory leaks 9 | nested: 10 | type: stream 11 | path: "%env(resolve:LOG_FILE_PATH)%" 12 | level: debug 13 | console: 14 | type: console 15 | process_psr_3_messages: false 16 | channels: ["!event", "!doctrine"] 17 | -------------------------------------------------------------------------------- /config/packages/prod/doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | orm: 3 | auto_generate_proxy_classes: false 4 | metadata_cache_driver: 5 | type: pool 6 | pool: doctrine.system_cache_pool 7 | query_cache_driver: 8 | type: pool 9 | pool: doctrine.system_cache_pool 10 | result_cache_driver: 11 | type: pool 12 | pool: doctrine.result_cache_pool 13 | 14 | framework: 15 | cache: 16 | pools: 17 | doctrine.result_cache_pool: 18 | adapter: cache.app 19 | doctrine.system_cache_pool: 20 | adapter: cache.system 21 | -------------------------------------------------------------------------------- /templates/calendars/edit.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | {% set menu = 'resources' %} 3 | 4 | {% block body %} 5 | 6 | {% include '_partials/back_button.html.twig' with { url: path('calendar_index', {username: username}), text: "calendars.back"|trans({'user': principal.displayName }) } %} 7 | 8 | {% if calendar.id %} 9 |

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

10 | {% else %} 11 |

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

12 | {% endif %} 13 | 14 | {{ form(form) }} 15 | 16 | {% endblock %} -------------------------------------------------------------------------------- /templates/addressbooks/edit.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | {% set menu = 'resources' %} 3 | 4 | {% block body %} 5 | 6 | {% include '_partials/back_button.html.twig' with { url: path('addressbook_index', {username: username}), text: "addressbooks.back"|trans({'user': principal.displayName }) } %} 7 | 8 | {% if addressbook.id %} 9 |

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

10 | {% else %} 11 |

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

12 | {% endif %} 13 | 14 | {{ form(form) }} 15 | 16 | {% endblock %} -------------------------------------------------------------------------------- /templates/_partials/flashes.html.twig: -------------------------------------------------------------------------------- 1 |
2 |
3 | {% for label, messages in app.flashes %} 4 | {% for message in messages %} 5 | 11 | {% endfor %} 12 | {% endfor %} 13 |
14 |
15 | -------------------------------------------------------------------------------- /config/packages/dev/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | handlers: 3 | main: 4 | type: stream 5 | path: "%kernel.logs_dir%/%kernel.environment%.log" 6 | level: debug 7 | channels: ["!event"] 8 | # uncomment to get logging in your browser 9 | # you may have to allow bigger header sizes in your Web server configuration 10 | #firephp: 11 | # type: firephp 12 | # level: info 13 | #chromephp: 14 | # type: chromephp 15 | # level: info 16 | console: 17 | type: console 18 | process_psr_3_messages: false 19 | channels: ["!event", "!doctrine", "!console"] 20 | -------------------------------------------------------------------------------- /src/Kernel.php: -------------------------------------------------------------------------------- 1 | getContainer()->getParameter('timezone'); 16 | if ('' === $timezone) { 17 | return; 18 | } 19 | try { 20 | date_default_timezone_set($timezone); 21 | } catch (\Exception $e) { 22 | // We don't crash the app, the setting will be flagged as incorrect in the dashboard 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /bin/phpunit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | bootEnv($overridenEnvDir.'/.env'); 19 | } 20 | 21 | return function (array $context) { 22 | return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']); 23 | }; 24 | -------------------------------------------------------------------------------- /config/packages/cache.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | cache: 3 | # Unique name of your app: used to compute stable namespaces for cache keys. 4 | #prefix_seed: your_vendor_name/app_name 5 | 6 | # The "app" cache stores to the filesystem by default. 7 | # The data in this cache should persist between deploys. 8 | # Other options include: 9 | 10 | # Redis 11 | #app: cache.adapter.redis 12 | #default_redis_provider: redis://localhost 13 | 14 | # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) 15 | #app: cache.adapter.apcu 16 | 17 | # Namespaced pools use the above "app" backend by default 18 | #pools: 19 | #my.dedicated.cache: null 20 | -------------------------------------------------------------------------------- /templates/base.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block title %}Davis{% endblock %} 6 | 7 | 8 | 9 | 10 | 11 | {% include '_partials/navigation.html.twig' %} 12 | {% include '_partials/flashes.html.twig' %} 13 |
14 | {% block body %}{% endblock %} 15 |
16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /config/packages/framework.yaml: -------------------------------------------------------------------------------- 1 | # see https://symfony.com/doc/current/reference/configuration/framework.html 2 | framework: 3 | secret: '%env(APP_SECRET)%' 4 | handle_all_throwables: true 5 | #csrf_protection: true 6 | http_method_override: false 7 | 8 | # Enables session support. Note that the session will ONLY be started if you read or write from it. 9 | # Remove or comment this section to explicitly disable session support. 10 | session: 11 | handler_id: null 12 | cookie_secure: auto 13 | cookie_samesite: lax 14 | name: 'DAVIS_SESSION' 15 | storage_factory_id: session.storage.factory.native 16 | 17 | profiler: 18 | collect_serializer_data: true 19 | 20 | property_info: 21 | with_constructor_extractor: false 22 | 23 | php_errors: 24 | log: true 25 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | in(__DIR__) 5 | ->exclude('var') 6 | ; 7 | 8 | return (new PhpCsFixer\Config()) 9 | ->setRules([ 10 | '@Symfony' => true, 11 | 'ordered_imports' => true, // Order "use" alphabetically 12 | 'array_syntax' => ['syntax' => 'short'], // Replace array() by [] 13 | 'no_useless_return' => true, // Keep return null; 14 | 'phpdoc_order' => true, // Clean up the /** php doc */ 15 | 'linebreak_after_opening_tag' => true, 16 | 'multiline_whitespace_before_semicolons' => false, 17 | 'phpdoc_add_missing_param_annotation' => true, 18 | 'single_trait_insert_per_statement' => false 19 | ]) 20 | ->setUsingCache(false) 21 | ->setFinder($finder) 22 | ; 23 | -------------------------------------------------------------------------------- /config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 5 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], 6 | Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], 7 | Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], 8 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], 9 | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], 10 | Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], 11 | Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true], 12 | Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], 13 | Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], 14 | ]; 15 | -------------------------------------------------------------------------------- /config/packages/doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | dbal: 3 | # The server_version must be configured directly in the 4 | # DATABASE_URL to allow different drivers without adding 5 | # too many env vars 6 | driver: 'pdo_%env(string:default:default_database_driver:DATABASE_DRIVER)%' 7 | 8 | url: '%env(resolve:DATABASE_URL)%' 9 | 10 | orm: 11 | auto_generate_proxy_classes: true 12 | report_fields_where_declared: true 13 | enable_lazy_ghost_objects: true 14 | naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware 15 | controller_resolver: 16 | auto_mapping: false 17 | mappings: 18 | App: 19 | is_bundle: false 20 | type: attribute 21 | dir: '%kernel.project_dir%/src/Entity' 22 | prefix: 'App\Entity' 23 | alias: App 24 | -------------------------------------------------------------------------------- /docker/configurations/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | user=root 4 | pidfile=/run/supervisord.pid 5 | logfile=/dev/null 6 | logfile_maxbytes=0 7 | 8 | [unix_http_server] 9 | file=/run/supervisord.sock ; the path to the socket file 10 | 11 | [supervisorctl] 12 | serverurl=unix:///run/supervisord.sock ; use a unix:// URL for a unix socket 13 | 14 | [rpcinterface:supervisor] 15 | supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface 16 | 17 | [program:caddy] 18 | command=/usr/sbin/caddy run -c /etc/caddy/Caddyfile 19 | autostart=true 20 | autorestart=true 21 | redirect_stderr=true 22 | stdout_logfile=/var/log/caddy/access.log 23 | stdout_logfile_maxbytes = 0 24 | 25 | [program:php-fpm] 26 | command=/usr/local/sbin/php-fpm --nodaemonize 27 | autostart=true 28 | autorestart=true 29 | redirect_stderr=true 30 | stdout_logfile=/var/log/php-fpm/access.log 31 | stdout_logfile_maxbytes = 0 -------------------------------------------------------------------------------- /templates/_partials/delete_modal.html.twig: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | bootEnv(dirname(__DIR__).'/.env'); 11 | } 12 | 13 | // Create the test database, update the schema and resets the fixture before each test. 14 | // Note: `--quiet` is needed here for each step so that PHPUnit doesn't fail. 15 | $actions = [ 16 | 'doctrine:database:create --if-not-exists ', 17 | 'doctrine:schema:update --complete --force', 18 | 'doctrine:fixtures:load --no-interaction', 19 | ]; 20 | 21 | foreach ($actions as $action) { 22 | passthru(sprintf( 23 | 'APP_ENV=%s php "%s/../bin/console" %s --quiet', 24 | $_ENV['APP_ENV'], 25 | __DIR__, 26 | $action, 27 | )); 28 | } 29 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | tests 15 | 16 | 17 | 18 | 19 | src 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /docker/configurations/nginx.conf: -------------------------------------------------------------------------------- 1 | # This is a very simple / naive configuration for nginx + Davis 2 | # 3 | # USE HTTPS IN PRODUCTION 4 | # 5 | 6 | upstream docker-davis { 7 | server davis:9000; 8 | } 9 | 10 | server { 11 | listen 80; 12 | access_log off; 13 | 14 | root /var/www/davis/public/; 15 | index index.php; 16 | 17 | rewrite ^/.well-known/caldav /dav/ redirect; 18 | rewrite ^/.well-known/carddav /dav/ redirect; 19 | 20 | charset utf-8; 21 | 22 | location ~ /(\.ht) { 23 | deny all; 24 | return 404; 25 | } 26 | 27 | location / { 28 | try_files $uri $uri/ /index.php$is_args$args; 29 | } 30 | 31 | location ~ ^(.+\.php)(.*)$ { 32 | try_files $fastcgi_script_name =404; 33 | include fastcgi_params; 34 | fastcgi_pass docker-davis; 35 | fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; 36 | fastcgi_param PATH_INFO $fastcgi_path_info; 37 | fastcgi_split_path_info ^(.+\.php)(.*)$; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Functional/DavTest.php: -------------------------------------------------------------------------------- 1 | request($method, $path); 22 | 23 | return $client; 24 | } 25 | 26 | public function testUnauthorized(): void 27 | { 28 | $client = static::requestDavClient('GET', '/dav/'); 29 | 30 | $this->assertResponseStatusCodeSame(401); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /migrations/Version20191125093508.php: -------------------------------------------------------------------------------- 1 | skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); 23 | 24 | $this->addSql('ALTER TABLE principals ADD is_admin TINYINT(1) NOT NULL'); 25 | } 26 | 27 | public function down(Schema $schema): void 28 | { 29 | $this->skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); 30 | 31 | $this->addSql('ALTER TABLE principals DROP is_admin'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 tchap 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docker/docker-compose-standalone.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | name: "davis-docker" 3 | 4 | services: 5 | 6 | mysql: 7 | image: mariadb:10.6.10 8 | container_name: mysql 9 | environment: 10 | - MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD} 11 | - MYSQL_DATABASE=${DB_DATABASE} 12 | - MYSQL_USER=${DB_USER} 13 | - MYSQL_PASSWORD=${DB_PASSWORD} 14 | volumes: 15 | - database:/var/lib/mysql 16 | 17 | davis: 18 | build: 19 | context: ../ 20 | dockerfile: ./docker/Dockerfile-standalone 21 | image: davis:latest 22 | # If you want to use a prebuilt image from Github 23 | # image: ghcr.io/tchapi/davis-standalone:edge 24 | container_name: davis-standalone 25 | env_file: .env 26 | environment: 27 | - DATABASE_DRIVER=mysql 28 | - DATABASE_URL=mysql://${DB_USER}:${DB_PASSWORD}@mysql:3306/${DB_DATABASE}?serverVersion=mariadb-10.6.10&charset=utf8mb4 29 | - MAILER_DSN=smtp://${MAIL_USERNAME}:${MAIL_PASSWORD}@${MAIL_HOST}:${MAIL_PORT} 30 | depends_on: 31 | - mysql 32 | ports: 33 | - 9000:9000 34 | 35 | volumes: 36 | database: 37 | name: database 38 | -------------------------------------------------------------------------------- /migrations/Version20210928132307.php: -------------------------------------------------------------------------------- 1 | skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); 23 | 24 | $this->addSql('ALTER TABLE calendarinstances CHANGE calendarorder calendarorder INT DEFAULT 0 NOT NULL'); 25 | } 26 | 27 | public function down(Schema $schema): void 28 | { 29 | $this->skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); 30 | 31 | $this->addSql('ALTER TABLE calendarinstances CHANGE calendarorder calendarorder INT NOT NULL'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /migrations/Version20191203111729.php: -------------------------------------------------------------------------------- 1 | skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); 23 | 24 | $this->addSql('ALTER TABLE addressbooks CHANGE description description LONGTEXT DEFAULT NULL'); 25 | } 26 | 27 | public function down(Schema $schema): void 28 | { 29 | $this->skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); 30 | 31 | $this->addSql('ALTER TABLE addressbooks CHANGE description description LONGTEXT CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | bootEnv($overridenEnvDir.'/.env'); 29 | } 30 | 31 | return function (array $context) { 32 | $kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']); 33 | 34 | return new Application($kernel); 35 | }; 36 | -------------------------------------------------------------------------------- /src/Controller/SecurityController.php: -------------------------------------------------------------------------------- 1 | getUser()) { 16 | // return $this->redirectToRoute('target_path'); 17 | // } 18 | 19 | // get the login error if there is one 20 | $error = $authenticationUtils->getLastAuthenticationError(); 21 | // last username entered by the user 22 | $lastUsername = $authenticationUtils->getLastUsername(); 23 | 24 | return $this->render('security/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]); 25 | } 26 | 27 | #[Route('/logout', name: 'app_logout')] 28 | public function logout() 29 | { 30 | throw new \Exception('This method can be blank - it will be intercepted by the logout key on your firewall'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /docker/configurations/Caddyfile: -------------------------------------------------------------------------------- 1 | { 2 | auto_https off 3 | } 4 | 5 | :9000 { 6 | # Redirect .well-known 7 | redir /.well-known/caldav /dav/ 8 | redir /.well-known/carddav /dav/ 9 | 10 | root * /var/www/davis/public 11 | php_fastcgi unix//var/run/php-fpm/php-fpm.sock { 12 | # Preserve the original X-Forwarded-Proto from upstream, as it might be HTTPS 13 | header_up X-Forwarded-Proto {http.request.header.X-Forwarded-Proto} 14 | header_up X-Forwarded-Host {http.request.header.X-Forwarded-Host} 15 | header_up X-Forwarded-For {http.request.header.X-Forwarded-For} 16 | } 17 | 18 | file_server { 19 | # Safety net, just in case 20 | hide .git .gitignore 21 | } 22 | 23 | # enable compression 24 | encode zstd gzip 25 | 26 | # Remove leaky headers 27 | header { 28 | -Server 29 | -X-Powered-By 30 | 31 | # keep referrer data off of HTTP connections 32 | Referrer-Policy no-referrer-when-downgrade 33 | 34 | # disable clients from sniffing the media type 35 | X-Content-Type-Options nosniff 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /src/Repository/PrincipalRepository.php: -------------------------------------------------------------------------------- 1 | createQueryBuilder('p') 28 | ->andWhere('p.isMain = :isMain') 29 | ->andWhere('p.uri <> :val') 30 | ->setParameter('isMain', true) 31 | ->setParameter('val', $principalUri) 32 | ->getQuery() 33 | ->getResult(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /docker/docker-compose-sqlite.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | name: "davis-docker" 3 | 4 | services: 5 | 6 | nginx: 7 | image: nginx:1.25-alpine 8 | container_name: nginx 9 | command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" 10 | depends_on: 11 | - davis 12 | volumes: 13 | - davis_www:/var/www/davis 14 | - type: bind 15 | source: ./configurations/nginx.conf 16 | target: /etc/nginx/conf.d/default.conf 17 | ports: 18 | - 9000:80 19 | 20 | davis: 21 | build: 22 | context: ../ 23 | dockerfile: ./docker/Dockerfile 24 | image: davis:latest 25 | # If you want to use a prebuilt image from Github 26 | # image: ghcr.io/tchapi/davis:edge 27 | container_name: davis 28 | env_file: .env 29 | environment: 30 | - DATABASE_DRIVER=sqlite 31 | - DATABASE_URL=sqlite:////data/davis-database.db # ⚠️ 4 slashes for an absolute path ⚠️ + no quotes (so Symfony can resolve it) 32 | - MAILER_DSN=smtp://${MAIL_USERNAME}:${MAIL_PASSWORD}@${MAIL_HOST}:${MAIL_PORT} 33 | volumes: 34 | - davis_www:/var/www/davis 35 | - davis_data:/data 36 | 37 | volumes: 38 | davis_www: 39 | name: davis_www 40 | davis_data: 41 | name: davis_data 42 | -------------------------------------------------------------------------------- /config/packages/security.yaml: -------------------------------------------------------------------------------- 1 | security: 2 | password_hashers: 3 | Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' 4 | providers: 5 | admin_user_provider: 6 | id: App\Security\AdminUserProvider 7 | firewalls: 8 | dev: 9 | pattern: ^/(_(profiler|wdt)|css|images|js)/ 10 | security: false 11 | main: 12 | lazy: true 13 | custom_authenticators: 14 | - App\Security\LoginFormAuthenticator 15 | provider: admin_user_provider 16 | logout: 17 | path: app_logout 18 | target: dashboard 19 | 20 | access_control: 21 | - { path: ^/$, roles: PUBLIC_ACCESS } 22 | - { path: ^/dav, roles: PUBLIC_ACCESS } 23 | - { path: ^/dashboard, roles: ROLE_ADMIN, allow_if: "'%env(default:default_admin_auth_bypass:ADMIN_AUTH_BYPASS)%' === 'true'" } 24 | - { path: ^/users, roles: ROLE_ADMIN, allow_if: "'%env(default:default_admin_auth_bypass:ADMIN_AUTH_BYPASS)%' === 'true'" } 25 | - { path: ^/calendars, roles: ROLE_ADMIN, allow_if: "'%env(default:default_admin_auth_bypass:ADMIN_AUTH_BYPASS)%' === 'true'" } 26 | - { path: ^/adressbooks, roles: ROLE_ADMIN, allow_if: "'%env(default:default_admin_auth_bypass:ADMIN_AUTH_BYPASS)%' === 'true'" } 27 | -------------------------------------------------------------------------------- /src/Services/BasicAuth.php: -------------------------------------------------------------------------------- 1 | utils = $utils; 28 | $this->doctrine = $doctrine; 29 | } 30 | 31 | protected function validateUserPass($username, $password): bool 32 | { 33 | $user = $this->doctrine->getRepository(User::class)->findOneByUsername($username); 34 | 35 | if (!$user) { 36 | return false; 37 | } 38 | 39 | if ('$2y$' === substr($user->getPassword(), 0, 4)) { 40 | // Use password_verify with secure passwords 41 | return password_verify($password, $user->getPassword()); 42 | } else { 43 | // Use unsecure legacy password hashing (from legacy sabre/dav implementation) 44 | return $user->getPassword() === $this->utils->hashPassword($username, $password); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Entity/User.php: -------------------------------------------------------------------------------- 1 | id; 31 | } 32 | 33 | public function getUsername(): ?string 34 | { 35 | return $this->username; 36 | } 37 | 38 | public function setUsername(string $username): self 39 | { 40 | $this->username = $username; 41 | 42 | return $this; 43 | } 44 | 45 | public function getPassword(): ?string 46 | { 47 | return $this->password; 48 | } 49 | 50 | // $password _can_ be NULL here, in the case when we edit a user 51 | // and do not change its password 52 | public function setPassword(?string $password): self 53 | { 54 | $this->password = $password; 55 | 56 | return $this; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /migrations/Version20250421163214.php: -------------------------------------------------------------------------------- 1 | connection->getDatabasePlatform()->getName(); 23 | 24 | if ('mysql' === $engine) { 25 | $this->addSql('ALTER TABLE addressbooks ADD included_in_birthday_calendar TINYINT(1) DEFAULT 0'); 26 | } elseif ('postgresql' === $engine) { 27 | $this->addSql('ALTER TABLE addressbooks ADD COLUMN included_in_birthday_calendar BOOLEAN DEFAULT FALSE;'); 28 | } elseif ('sqlite' === $engine) { 29 | $this->addSql('ALTER TABLE addressbooks ADD COLUMN included_in_birthday_calendar INTEGER DEFAULT 0;'); 30 | } 31 | } 32 | 33 | public function down(Schema $schema): void 34 | { 35 | if ('mysql' === $this->connection->getDatabasePlatform()->getName()) { 36 | $this->addSql('ALTER TABLE addressbooks DROP included_in_birthday_calendar'); 37 | } else { 38 | $this->addSql('ALTER TABLE addressbooks DROP COLUMN included_in_birthday_calendar'); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Logging/Monolog/PasswordFilterProcessor.php: -------------------------------------------------------------------------------- 1 | $item) { 20 | if (in_array(strtolower($key), self::PASSWORD_KEYS) || ('args' === $key && $shouldRedactArgs)) { 21 | $context[$key] = self::REDACTED; 22 | } elseif (is_array($item)) { 23 | $context[$key] = static::redactContextRecursive($item); 24 | } 25 | } 26 | 27 | return $context; 28 | } 29 | 30 | public function __invoke(LogRecord $record): LogRecord 31 | { 32 | $context = $record->context; 33 | 34 | $redactedContext = static::redactContextRecursive($context); 35 | 36 | return $record->with(context: $redactedContext); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /migrations/Version20231001214111.php: -------------------------------------------------------------------------------- 1 | skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); 23 | 24 | $this->addSql('ALTER TABLE calendarobjects CHANGE calendardata calendardata MEDIUMTEXT DEFAULT NULL'); 25 | $this->addSql('ALTER TABLE cards CHANGE carddata carddata MEDIUMTEXT DEFAULT NULL'); 26 | $this->addSql('ALTER TABLE schedulingobjects CHANGE calendardata calendardata MEDIUMTEXT DEFAULT NULL'); 27 | } 28 | 29 | public function down(Schema $schema): void 30 | { 31 | $this->skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); 32 | 33 | $this->addSql('ALTER TABLE schedulingobjects CHANGE calendardata calendardata LONGBLOB DEFAULT NULL'); 34 | $this->addSql('ALTER TABLE calendarobjects CHANGE calendardata calendardata LONGBLOB DEFAULT NULL'); 35 | $this->addSql('ALTER TABLE cards CHANGE carddata carddata LONGBLOB DEFAULT NULL'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /docker/docker-compose-postgresql.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | name: "davis-docker" 3 | 4 | services: 5 | 6 | nginx: 7 | image: nginx:1.25-alpine 8 | container_name: nginx 9 | command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" 10 | depends_on: 11 | - davis 12 | volumes: 13 | - davis_www:/var/www/davis 14 | - type: bind 15 | source: ./configurations/nginx.conf 16 | target: /etc/nginx/conf.d/default.conf 17 | ports: 18 | - 9000:80 19 | 20 | postgresql: 21 | image: postgres:16-alpine 22 | container_name: postgresql 23 | environment: 24 | - POSTGRES_PASSWORD=${DB_PASSWORD} 25 | - POSTGRES_DB=${DB_DATABASE} 26 | - POSTGRES_USER=${DB_USER} 27 | volumes: 28 | - database_pg:/var/lib/postgresql/data 29 | 30 | davis: 31 | build: 32 | context: ../ 33 | dockerfile: ./docker/Dockerfile 34 | image: davis:latest 35 | # If you want to use a prebuilt image from Github 36 | # image: ghcr.io/tchapi/davis:edge 37 | container_name: davis 38 | env_file: .env 39 | environment: 40 | - DATABASE_DRIVER=postgresql 41 | - DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@postgresql:5432/${DB_DATABASE}?serverVersion=15&charset=UTF-8 42 | - MAILER_DSN=smtp://${MAIL_USERNAME}:${MAIL_PASSWORD}@${MAIL_HOST}:${MAIL_PORT} 43 | depends_on: 44 | - postgresql 45 | volumes: 46 | - davis_www:/var/www/davis 47 | 48 | volumes: 49 | davis_www: 50 | name: davis_www 51 | database_pg: 52 | name: database_pg 53 | -------------------------------------------------------------------------------- /templates/mails/scheduling.txt.twig: -------------------------------------------------------------------------------- 1 | Calendar notification from {{ senderName }}. 2 | 3 | ----------------------------------------------------------- 4 | 5 | {% if action == 'REQUEST' %} 6 | 7 | **{{ senderName }}** invited you to “{{ summary }}”. 8 | 9 | {% elseif action == 'CANCEL' %} 10 | 11 | “{{ summary }}” has been canceled. 12 | 13 | {% elseif action == 'ACCEPTED' %} 14 | 15 | **{{ senderName }}** accepted your invitation to “{{ summary }}”. 16 | 17 | {% elseif action == 'TENTATIVE' %} 18 | 19 | **{{ senderName }}** tentatively accepted your invitation to “{{ summary }}”. 20 | 21 | {% elseif action == 'DECLINED' %} 22 | 23 | **{{ senderName }}** declined your invitation to “{{ summary }}”. 24 | 25 | {% endif %} 26 | 27 | ----------------------------------------------------------- 28 | 29 | When? {{ dateTime|date('l, F jS Y') }} 30 | {% if not allDay %} 31 | {{ dateTime|date('g:ia T') }} 32 | {% endif %} 33 | 34 | {% if action != 'CANCEL' %} 35 | Attendees: 36 | {% for attendee in attendees %} 37 | {{ attendee.cn }} <{{ attendee.email }}> {% if attendee.role == 'CHAIR' %}(organizer){% endif %} 38 | {% endfor %} 39 | {% endif %} 40 | 41 | 42 | {% if location %} 43 | Where? {{ location|replace({"\n": "\n" ~ ' '}) }} 44 | {% endif %} 45 | 46 | {% if url %} 47 | URL: {{ url }} 48 | {% endif %} 49 | 50 | 51 | {% if description %} 52 | Notes: {{ description|replace({"\n": "\n" ~ ' '}) }} 53 | {% endif %} 54 | 55 | ----------------------------------------------------------- 56 | 57 | Mail sent by {{ app.request.getSchemeAndHttpHost() }} 58 | -------------------------------------------------------------------------------- /docker/.env: -------------------------------------------------------------------------------- 1 | # General settings 2 | APP_ENV=prod # or dev 3 | 4 | CALDAV_ENABLED=true 5 | CARDDAV_ENABLED=true 6 | WEBDAV_ENABLED=false 7 | PUBLIC_CALENDARS_ENABLED=true 8 | 9 | BIRTHDAY_REMINDER_OFFSET=PT9H 10 | 11 | APP_TIMEZONE=Europe/Paris 12 | 13 | LOG_FILE_PATH="%kernel.logs_dir%/%kernel.environment%.log" 14 | 15 | # For the MariaDB container mainly 16 | DB_ROOT_PASSWORD=notSoSecure 17 | 18 | # The Davis database, user and password 19 | DB_DATABASE=davis 20 | DB_USER=davis_user 21 | DB_PASSWORD=davis_password 22 | 23 | # For the Davis admin interface 24 | ADMIN_LOGIN=admin 25 | ADMIN_PASSWORD=admin 26 | ADMIN_AUTH_BYPASS=false 27 | 28 | # DAV auth settings 29 | AUTH_METHOD=Basic # Basic or IMAP or LDAP 30 | 31 | # Basic HTTP auth settings 32 | AUTH_REALM=SabreDAV 33 | 34 | # IMAP auth settings 35 | IMAP_AUTH_URL=imap.mydomain.com:993 36 | IMAP_ENCRYPTION_METHOD=ssl 37 | IMAP_CERTIFICATE_VALIDATION=true 38 | IMAP_AUTH_USER_AUTOCREATE=false 39 | 40 | # LDAP auth settings 41 | LDAP_AUTH_URL=ldap://127.0.0.1:3890 42 | LDAP_DN_PATTERN=uid=%u,ou=users,dc=domain,dc=com 43 | LDAP_MAIL_ATTRIBUTE=mail 44 | LDAP_AUTH_USER_AUTOCREATE=false 45 | LDAP_CERTIFICATE_CHECKING_STRATEGY=try # never, hard, demand, try, or allow 46 | 47 | # WebDAV settings 48 | WEBDAV_TMP_DIR=/webdav/tmp 49 | WEBDAV_PUBLIC_DIR=/webdav/public 50 | WEBDAV_HOMES_DIR= 51 | 52 | # Mail settings 53 | INVITE_FROM_ADDRESS=no-reply@example.org 54 | MAIL_HOST=smtp.myprovider.com 55 | MAIL_PORT=587 56 | MAIL_USERNAME=userdav 57 | MAIL_PASSWORD=test 58 | 59 | # Trust the immediate proxy for X-Forwarded-* headers including HTTPS detection 60 | SYMFONY_TRUSTED_PROXIES=REMOTE_ADDR -------------------------------------------------------------------------------- /migrations/Version20191202091507.php: -------------------------------------------------------------------------------- 1 | skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); 23 | 24 | $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'); 25 | } 26 | 27 | public function down(Schema $schema): void 28 | { 29 | $this->skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); 30 | 31 | $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`'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | name: "davis-docker" 3 | 4 | services: 5 | 6 | nginx: 7 | image: nginx:1.25-alpine 8 | container_name: nginx 9 | command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" 10 | depends_on: 11 | - davis 12 | volumes: 13 | - davis_www:/var/www/davis 14 | - type: bind 15 | source: ./configurations/nginx.conf 16 | target: /etc/nginx/conf.d/default.conf 17 | ports: 18 | - 9000:80 19 | 20 | mysql: 21 | image: mariadb:10.11 22 | container_name: mysql 23 | environment: 24 | - MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD} 25 | - MYSQL_DATABASE=${DB_DATABASE} 26 | - MYSQL_USER=${DB_USER} 27 | - MYSQL_PASSWORD=${DB_PASSWORD} 28 | volumes: 29 | - database:/var/lib/mysql 30 | 31 | davis: 32 | build: 33 | context: ../ 34 | dockerfile: ./docker/Dockerfile 35 | args: 36 | fpm_user: 101:101 37 | image: davis:latest 38 | # If you want to use a prebuilt image from Github 39 | # image: ghcr.io/tchapi/davis:edge 40 | container_name: davis 41 | env_file: .env 42 | environment: 43 | - DATABASE_DRIVER=mysql 44 | - DATABASE_URL=mysql://${DB_USER}:${DB_PASSWORD}@mysql:3306/${DB_DATABASE}?serverVersion=mariadb-10.6.10&charset=utf8mb4 45 | - MAILER_DSN=smtp://${MAIL_USERNAME}:${MAIL_PASSWORD}@${MAIL_HOST}:${MAIL_PORT} 46 | depends_on: 47 | - mysql 48 | volumes: 49 | - davis_www:/var/www/davis 50 | 51 | volumes: 52 | davis_www: 53 | name: davis_www 54 | database: 55 | name: database 56 | -------------------------------------------------------------------------------- /templates/_partials/delegate_row.html.twig: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | {{ delegate.displayName }} 5 | ‹{{ delegate.email }}› 6 | {% if has_write %} 7 | {{ "delegates.write"|trans }} 8 | {% else %} 9 | {{ "delegates.readonly"|trans }} 10 | {% endif %} 11 |
12 | 20 |
21 |

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

22 | {{ "users.uri"|trans }} : {{ delegate.uri }} 23 |
24 | ⚠ {{ "remove"|trans }} 25 |
26 |
-------------------------------------------------------------------------------- /src/Entity/PropertyStorage.php: -------------------------------------------------------------------------------- 1 | id; 31 | } 32 | 33 | public function getPath(): ?string 34 | { 35 | return $this->path; 36 | } 37 | 38 | public function setPath(string $path): self 39 | { 40 | $this->path = $path; 41 | 42 | return $this; 43 | } 44 | 45 | public function getName(): ?string 46 | { 47 | return $this->name; 48 | } 49 | 50 | public function setName(string $name): self 51 | { 52 | $this->name = $name; 53 | 54 | return $this; 55 | } 56 | 57 | public function getValueType(): ?int 58 | { 59 | return $this->valueType; 60 | } 61 | 62 | public function setValueType(?int $valueType): self 63 | { 64 | $this->valueType = $valueType; 65 | 66 | return $this; 67 | } 68 | 69 | public function getValue(): ?string 70 | { 71 | return $this->value; 72 | } 73 | 74 | public function setValue(?string $value): self 75 | { 76 | $this->value = $value; 77 | 78 | return $this; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /templates/security/login.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | {% set menu = null %} 3 | 4 | {% block body %} 5 | 6 | {% if app.user %} 7 |
8 | {{ "login.already"|trans({username: app.user.username}) }}, {{ "logout"|trans }} 9 |
10 | {% else %} 11 |
12 |
13 |
14 | {% if error %} 15 |
{{ error.messageKey|trans(error.messageData, 'security') }}
16 | {% endif %} 17 | 18 |

{{ "login.signin"|trans }}

19 | 20 |
21 | 22 | 23 |
24 |
25 | 26 | 27 |
28 | 29 | 30 | 31 |
32 |
33 |
34 | {% endif %} 35 | 36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /migrations/Version20231001214112.php: -------------------------------------------------------------------------------- 1 | skipIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'postgresql\'. Skipping it is fine.'); 23 | 24 | $this->addSql("ALTER TABLE calendarobjects ALTER COLUMN calendardata TYPE TEXT USING convert_from(calendardata, 'utf8')"); 25 | $this->addSql("ALTER TABLE cards ALTER COLUMN carddata TYPE TEXT USING convert_from(carddata, 'utf8')"); 26 | $this->addSql("ALTER TABLE schedulingobjects ALTER COLUMN calendardata TYPE TEXT USING convert_from(calendardata, 'utf8')"); 27 | } 28 | 29 | public function down(Schema $schema): void 30 | { 31 | $this->skipIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'postgresql\'. Skipping it is fine.'); 32 | 33 | $this->addSql("ALTER TABLE calendarobjects ALTER COLUMN calendardata TYPE BYTEA DEFAULT NULL USING convert_from(calendardata, 'utf8')"); 34 | $this->addSql("ALTER TABLE cards ALTER COLUMN carddata TYPE BYTEA DEFAULT NULL USING convert_from(carddata, 'utf8')"); 35 | $this->addSql("ALTER TABLE schedulingobjects ALTER COLUMN calendardata TYPE BYTEA DEFAULT NULL USING convert_from(calendardata, 'utf8')"); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /migrations/Version20191113170650.php: -------------------------------------------------------------------------------- 1 | skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); 23 | 24 | $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'); 25 | $this->addSql('ALTER TABLE groupmembers ADD CONSTRAINT FK_6F15EDAC474870EE FOREIGN KEY (principal_id) REFERENCES principals (id)'); 26 | $this->addSql('ALTER TABLE groupmembers ADD CONSTRAINT FK_6F15EDAC7597D3FE FOREIGN KEY (member_id) REFERENCES principals (id)'); 27 | $this->addSql('ALTER TABLE principals ADD is_main TINYINT(1) NOT NULL'); 28 | } 29 | 30 | public function down(Schema $schema): void 31 | { 32 | $this->skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); 33 | 34 | $this->addSql('DROP TABLE groupmembers'); 35 | $this->addSql('ALTER TABLE principals DROP is_main'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /templates/_partials/add_delegate_modal.html.twig: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/Controller/Admin/DashboardController.php: -------------------------------------------------------------------------------- 1 | getRepository(User::class)->findAll(); 21 | $calendars = $doctrine->getRepository(CalendarInstance::class)->findAll(); 22 | $addressbooks = $doctrine->getRepository(AddressBook::class)->findAll(); 23 | $events = $doctrine->getRepository(CalendarObject::class)->findAll(); 24 | $contacts = $doctrine->getRepository(Card::class)->findAll(); 25 | 26 | $timezoneParameter = $this->getParameter('timezone'); 27 | 28 | return $this->render('dashboard.html.twig', [ 29 | 'users' => $users, 30 | 'calendars' => $calendars, 31 | 'addressbooks' => $addressbooks, 32 | 'events' => $events, 33 | 'contacts' => $contacts, 34 | 'timezone' => [ 35 | 'actual_default' => date_default_timezone_get(), 36 | 'not_set_in_app' => '' === $timezoneParameter, 37 | 'bad_value' => '' !== $timezoneParameter && !in_array($timezoneParameter, \DateTimeZone::listIdentifiers()), 38 | ], 39 | 'version' => \App\Version::VERSION, 40 | 'sabredav_version' => \Sabre\DAV\Version::VERSION, 41 | ]); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /migrations/Version20231229203515.php: -------------------------------------------------------------------------------- 1 | skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); 25 | 26 | $this->addSql('ALTER TABLE calendarobjects CHANGE calendardata calendardata MEDIUMTEXT DEFAULT NULL'); 27 | $this->addSql('ALTER TABLE cards CHANGE carddata carddata MEDIUMTEXT DEFAULT NULL'); 28 | $this->addSql('ALTER TABLE schedulingobjects CHANGE calendardata calendardata MEDIUMTEXT DEFAULT NULL'); 29 | } 30 | 31 | public function down(Schema $schema): void 32 | { 33 | $this->skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.'); 34 | 35 | $this->addSql('ALTER TABLE schedulingobjects CHANGE calendardata calendardata TEXT DEFAULT NULL'); 36 | $this->addSql('ALTER TABLE calendarobjects CHANGE calendardata calendardata TEXT DEFAULT NULL'); 37 | $this->addSql('ALTER TABLE cards CHANGE carddata carddata TEXT DEFAULT NULL'); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Entity/CalendarChange.php: -------------------------------------------------------------------------------- 1 | id; 32 | } 33 | 34 | public function getUri(): ?string 35 | { 36 | return $this->uri; 37 | } 38 | 39 | public function setUri(string $uri): self 40 | { 41 | $this->uri = $uri; 42 | 43 | return $this; 44 | } 45 | 46 | public function getSynctoken(): ?int 47 | { 48 | return $this->synctoken; 49 | } 50 | 51 | public function setSynctoken(int $synctoken): self 52 | { 53 | $this->synctoken = $synctoken; 54 | 55 | return $this; 56 | } 57 | 58 | public function getCalendar(): ?Calendar 59 | { 60 | return $this->calendar; 61 | } 62 | 63 | public function setCalendar(?Calendar $calendar): self 64 | { 65 | $this->calendar = $calendar; 66 | 67 | return $this; 68 | } 69 | 70 | public function getOperation(): ?int 71 | { 72 | return $this->operation; 73 | } 74 | 75 | public function setOperation(int $operation): self 76 | { 77 | $this->operation = $operation; 78 | 79 | return $this; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Security/AdminUser.php: -------------------------------------------------------------------------------- 1 | username = $username; 16 | $this->password = $password; 17 | } 18 | 19 | /** 20 | * @return (Role|string)[] The user roles 21 | */ 22 | public function getRoles(): array 23 | { 24 | return ['ROLE_ADMIN']; 25 | } 26 | 27 | /** 28 | * Returns the password used to authenticate the user. 29 | */ 30 | public function getPassword(): string 31 | { 32 | return $this->password; 33 | } 34 | 35 | /** 36 | * Returns the salt that was originally used to encode the password. 37 | * 38 | * This can return null if the password was not encoded using a salt. 39 | * 40 | * @return string|null The salt 41 | */ 42 | public function getSalt() 43 | { 44 | return null; 45 | } 46 | 47 | /** 48 | * Returns the username used to authenticate the user. 49 | * 50 | * @return string The username 51 | */ 52 | public function getUsername() 53 | { 54 | return $this->username; 55 | } 56 | 57 | public function getUserIdentifier(): string 58 | { 59 | return $this->username; 60 | } 61 | 62 | /** 63 | * Removes sensitive data from the user. 64 | * 65 | * This is important if, at any given point, sensitive information like 66 | * the plain-text password is stored on this object. 67 | */ 68 | public function eraseCredentials(): void 69 | { 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Entity/AddressBookChange.php: -------------------------------------------------------------------------------- 1 | id; 32 | } 33 | 34 | public function getUri(): ?string 35 | { 36 | return $this->uri; 37 | } 38 | 39 | public function setUri(string $uri): self 40 | { 41 | $this->uri = $uri; 42 | 43 | return $this; 44 | } 45 | 46 | public function getSynctoken(): ?string 47 | { 48 | return $this->synctoken; 49 | } 50 | 51 | public function setSynctoken(string $synctoken): self 52 | { 53 | $this->synctoken = $synctoken; 54 | 55 | return $this; 56 | } 57 | 58 | public function getAddressBook(): ?AddressBook 59 | { 60 | return $this->addressBook; 61 | } 62 | 63 | public function setAddressBook(?AddressBook $addressBook): self 64 | { 65 | $this->addressBook = $addressBook; 66 | 67 | return $this; 68 | } 69 | 70 | public function getOperation(): ?int 71 | { 72 | return $this->operation; 73 | } 74 | 75 | public function setOperation(int $operation): self 76 | { 77 | $this->operation = $operation; 78 | 79 | return $this; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /templates/users/delegates.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | {% set menu = 'resources' %} 3 | 4 | {% block body %} 5 | 6 | {% include '_partials/back_button.html.twig' with { url: path('user_index'), text: "users.back"|trans } %} 7 | 8 |

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

14 | 15 |
16 | 17 | {% if delegation %} 18 | 22 | {% for delegate in principalProxyRead.delegees %} 23 | {% include '_partials/delegate_row.html.twig' with {has_write: false} %} 24 | {% endfor %} 25 | {% for delegate in principalProxyWrite.delegees %} 26 | {% include '_partials/delegate_row.html.twig' with {has_write: true} %} 27 | {% endfor %} 28 | 29 | {% else %} 30 | 33 | {% endif %} 34 | 35 |
36 | 37 | {% include '_partials/delete_modal.html.twig' with {flavour: 'delegates'} %} 38 | {% include '_partials/add_delegate_modal.html.twig' with {principals: allPrincipals} %} 39 | 40 | {% endblock %} -------------------------------------------------------------------------------- /public/css/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-top: calc(56px + 30px); 3 | -webkit-touch-callout: none; /* iOS Safari */ 4 | -webkit-user-select: none; /* Safari */ 5 | -khtml-user-select: none; /* Konqueror HTML */ 6 | -moz-user-select: none; /* Firefox */ 7 | -ms-user-select: none; /* Internet Explorer/Edge */ 8 | user-select: none; /* Non-prefixed version, currently supported by Chrome and Opera */ 9 | } 10 | 11 | /* Add a simple line below display headings */ 12 | .display-4 { 13 | font-size: 2.5rem; 14 | border-bottom: 1px solid var(--bs-border-color); 15 | } 16 | 17 | /* Flashes (messages) styles */ 18 | .flashes { 19 | position: fixed; 20 | top: 20px; 21 | right: 0; 22 | z-index: 1050; 23 | } 24 | .flashes .inner { 25 | position: absolute; 26 | top: 0; 27 | right: 20px; 28 | } 29 | .flashes .toast { 30 | min-width: 270px; 31 | } 32 | 33 | /* Little color swatch to show calendar color */ 34 | #calendar_instance_calendarColor_help { 35 | position: relative; 36 | } 37 | #calendar_instance_calendarColor_help::after { 38 | content: ""; 39 | width: 30px; 40 | height: 30px; 41 | position: absolute; 42 | top: -38px; 43 | right: 5px; 44 | background: var(--calendar-color); 45 | border-radius: 3px; 46 | border: 1px solid #AAA; 47 | } 48 | 49 | /* Special indicator badge */ 50 | .badge.badge-indicator { 51 | height: 15px; 52 | width: 15px; 53 | padding: 0; 54 | border-radius: 50%; 55 | text-decoration: none; 56 | color: var(--bs-heading-color); /* Comes from Bootstrap */ 57 | } 58 | 59 | /* Allow selection in the Bootstrap popover */ 60 | .popover .popover-body { 61 | user-select: text; 62 | } 63 | 64 | /* Github link icon */ 65 | .github-link { 66 | background-image: url("/images/github-mark.png"); 67 | background-repeat: no-repeat; 68 | background-size: 23px; 69 | height: 23px; 70 | width: 23px; 71 | } 72 | [data-bs-theme=dark] .github-link { 73 | background-image: url("/images/github-mark-white.png"); 74 | } 75 | -------------------------------------------------------------------------------- /src/Security/AdminUserProvider.php: -------------------------------------------------------------------------------- 1 | setName('dav:sync-birthday-calendar') 27 | ->setDescription('Synchronizes the birthday calendar') 28 | ->addArgument('username', 29 | InputArgument::OPTIONAL, 30 | 'Username for whom the birthday calendar will be synchronized'); 31 | } 32 | 33 | protected function execute(InputInterface $input, OutputInterface $output): int 34 | { 35 | $username = $input->getArgument('username'); 36 | 37 | if (!is_null($username)) { 38 | if (!$this->doctrine->getRepository(User::class)->findOneByUsername($username)) { 39 | throw new \InvalidArgumentException("User <$username> is unknown."); 40 | } 41 | 42 | $output->writeln("Start birthday calendar sync for $username"); 43 | $this->birthdayService->syncUser($username); 44 | 45 | return self::SUCCESS; 46 | } 47 | 48 | $output->writeln('Start birthday calendar sync for all users ...'); 49 | $p = new ProgressBar($output); 50 | $p->start(); 51 | 52 | $users = $this->doctrine->getRepository(User::class)->findAll(); 53 | 54 | foreach ($users as $user) { 55 | $p->advance(); 56 | $this->birthdayService->syncUser($user->getUsername()); 57 | } 58 | 59 | $p->finish(); 60 | $output->writeln(''); 61 | 62 | return self::SUCCESS; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /migrations/Version20250409193948.php: -------------------------------------------------------------------------------- 1 | skipIf('sqlite' === $this->connection->getDatabasePlatform()->getName(), 'This migration is not needed on \'sqlite\'. Skipping it is fine.'); 23 | 24 | // MySQL 25 | if ('mysql' === $this->connection->getDatabasePlatform()->getName()) { 26 | $this->addSql('ALTER TABLE calendarobjects CHANGE lastmodified lastmodified BIGINT DEFAULT NULL, CHANGE firstoccurence firstoccurence BIGINT DEFAULT NULL, CHANGE lastoccurence lastoccurence BIGINT DEFAULT NULL'); 27 | $this->addSql('ALTER TABLE calendarsubscriptions CHANGE lastmodified lastmodified BIGINT DEFAULT NULL'); 28 | $this->addSql('ALTER TABLE locks CHANGE created created BIGINT DEFAULT NULL'); 29 | $this->addSql('ALTER TABLE schedulingobjects CHANGE lastmodified lastmodified BIGINT DEFAULT NULL'); 30 | } 31 | 32 | // Posgres 33 | if ('postgresql' === $this->connection->getDatabasePlatform()->getName()) { 34 | $this->addSql('ALTER TABLE calendarobjects ALTER COLUMN lastmodified TYPE BIGINT'); 35 | $this->addSql('ALTER TABLE calendarobjects ALTER COLUMN firstoccurence TYPE BIGINT'); 36 | $this->addSql('ALTER TABLE calendarobjects ALTER COLUMN lastoccurence TYPE BIGINT'); 37 | $this->addSql('ALTER TABLE calendarsubscriptions ALTER COLUMN lastmodified TYPE BIGINT'); 38 | $this->addSql('ALTER TABLE locks ALTER COLUMN created TYPE BIGINT'); 39 | $this->addSql('ALTER TABLE schedulingobjects ALTER COLUMN lastmodified TYPE BIGINT'); 40 | } 41 | } 42 | 43 | public function down(Schema $schema): void 44 | { 45 | // No need for a down here, it's fine 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Form/AddressBookType.php: -------------------------------------------------------------------------------- 1 | add('principalUri', HiddenType::class, [ 21 | 'required' => true, 22 | ]) 23 | ->add('uri', TextType::class, [ 24 | 'label' => 'form.uri', 25 | 'disabled' => !$options['new'], 26 | 'help' => 'form.uri.help.carddav', 27 | ]) 28 | ->add('displayName', TextType::class, [ 29 | 'label' => 'form.displayName', 30 | 'help' => 'form.name.help.carddav', 31 | ]) 32 | ->add('includedInBirthdayCalendar', ChoiceType::class, [ 33 | 'label' => 'form.includedInBirthdayCalendar', 34 | 'help' => 'form.includedInBirthdayCalendar.help', 35 | 'required' => true, 36 | 'choices' => ['yes' => true, 'no' => false], 37 | ]) 38 | ->add('description', TextareaType::class, [ 39 | 'label' => 'form.description', 40 | 'required' => false, 41 | ]) 42 | ->add('save', SubmitType::class, [ 43 | 'label' => 'save', 44 | ]); 45 | 46 | if (!$options['birthday_calendar_enabled']) { 47 | $builder->remove('includedInBirthdayCalendar'); 48 | } 49 | } 50 | 51 | public function configureOptions(OptionsResolver $resolver): void 52 | { 53 | $resolver->setDefaults([ 54 | 'new' => false, 55 | 'data_class' => AddressBook::class, 56 | 'birthday_calendar_enabled' => true, 57 | ]); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/DataFixtures/AppFixtures.php: -------------------------------------------------------------------------------- 1 | setUsername('test_user') 20 | ->setPassword($hash); 21 | $manager->persist($user); 22 | 23 | $principal = (new Principal()) 24 | ->setUri(Principal::PREFIX.$user->getUsername()) 25 | ->setEmail('test@test.com') 26 | ->setDisplayName('Test User') 27 | ->setIsAdmin(true); 28 | $manager->persist($principal); 29 | 30 | // Create all the default calendar / addressbook 31 | $calendarInstance = new CalendarInstance(); 32 | $calendar = new Calendar(); 33 | $calendarInstance->setPrincipalUri(Principal::PREFIX.$user->getUsername()) 34 | ->setUri('default') 35 | ->setDisplayName('default.calendar.title') 36 | ->setDescription('default.calendar.description') 37 | ->setCalendar($calendar); 38 | $manager->persist($calendarInstance); 39 | 40 | // Enable delegation by default 41 | $principalProxyRead = new Principal(); 42 | $principalProxyRead->setUri($principal->getUri().Principal::READ_PROXY_SUFFIX) 43 | ->setIsMain(false); 44 | $manager->persist($principalProxyRead); 45 | 46 | $principalProxyWrite = new Principal(); 47 | $principalProxyWrite->setUri($principal->getUri().Principal::WRITE_PROXY_SUFFIX) 48 | ->setIsMain(false); 49 | $manager->persist($principalProxyWrite); 50 | 51 | $addressbook = new AddressBook(); 52 | $addressbook->setPrincipalUri(Principal::PREFIX.$user->getUsername()) 53 | ->setUri('default') 54 | ->setDisplayName('default.addressbook.title') 55 | ->setDescription('default.addressbook.description'); 56 | $manager->persist($addressbook); 57 | 58 | $manager->flush(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Form/UserType.php: -------------------------------------------------------------------------------- 1 | add('username', TextType::class, [ 22 | 'label' => 'form.username', 23 | 'disabled' => !$options['new'], 24 | 'help' => 'form.username.help', 25 | ]) 26 | ->add('displayName', TextType::class, [ 27 | 'label' => 'form.displayName', 28 | 'mapped' => false, 29 | ]) 30 | ->add('email', EmailType::class, [ 31 | 'label' => 'form.email', 32 | 'mapped' => false, 33 | ]) 34 | ->add('password', RepeatedType::class, [ 35 | 'type' => PasswordType::class, 36 | 'invalid_message' => 'form.password.match', 37 | 'options' => ['attr' => ['class' => 'password-field', 'placeholder' => $options['new'] ? '' : 'form.password.empty']], 38 | 'required' => $options['new'], 39 | 'first_options' => ['label' => 'form.password'], 40 | 'second_options' => ['label' => 'form.password.repeat'], 41 | ]) 42 | ->add('isAdmin', CheckboxType::class, [ 43 | 'label' => 'form.admin', 44 | 'help' => 'form.admin.help', 45 | 'required' => false, 46 | 'mapped' => false, 47 | ]) 48 | ->add('save', SubmitType::class, [ 49 | 'label' => 'save', 50 | ]); 51 | } 52 | 53 | public function configureOptions(OptionsResolver $resolver): void 54 | { 55 | $resolver->setDefaults([ 56 | 'new' => false, 57 | 'data_class' => User::class, 58 | ]); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /templates/addressbooks/index.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | {% set menu = 'resources' %} 3 | 4 | {% block body %} 5 | 6 | {% include '_partials/back_button.html.twig' with { url: path('user_index'), text: "users.back"|trans } %} 7 | 8 |

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

9 | 10 |
11 | {% for addressbook in addressbooks %} 12 |
13 |
14 |
{{ addressbook.displayName }}
15 | 24 |
25 |

{{ addressbook.description }}

26 | {{ "addressbooks.uri"|trans }} : {{ addressbook.uri }} — {{ "addressbooks.contacts"|trans({'%count%': addressbook.cards|length}) }} 27 | 36 |
37 | {% endfor %} 38 |
39 | 40 | {% include '_partials/delete_modal.html.twig' with {flavour: 'addressbooks'} %} 41 | 42 | {% endblock %} -------------------------------------------------------------------------------- /src/Plugins/BirthdayCalendarPlugin.php: -------------------------------------------------------------------------------- 1 | birthdayService = $birthdayService; 24 | } 25 | 26 | public function initialize(DAV\Server $server) 27 | { 28 | $this->server = $server; 29 | 30 | // Hook into card creation 31 | $server->on('afterCreateFile', [$this, 'afterCardCreate']); 32 | 33 | // Hook into card updates 34 | $server->on('afterWriteContent', [$this, 'afterCardUpdate']); 35 | 36 | // Hook into card deletion 37 | // Note: The node no longer exists at afterCardDelete so we 38 | // use beforeCardDelete for simplicity 39 | $server->on('beforeUnbind', [$this, 'beforeCardDelete']); 40 | } 41 | 42 | private function resyncCurrentPrincipal() 43 | { 44 | $authPlugin = $this->server->getPlugin('auth'); 45 | 46 | if (!$authPlugin) { 47 | return null; 48 | } 49 | 50 | $principal = $authPlugin->getCurrentPrincipal(); 51 | 52 | if ($principal) { 53 | $this->birthdayService->syncPrincipal($principal); 54 | } 55 | } 56 | 57 | public function afterCardCreate($path, DAV\ICollection $parentNode) 58 | { 59 | if (!$parentNode instanceof CardDAV\IAddressBook) { 60 | return; 61 | } 62 | 63 | $principal = $this->resyncCurrentPrincipal(); 64 | } 65 | 66 | public function afterCardUpdate($path, DAV\IFile $node) 67 | { 68 | if (!$node instanceof CardDAV\ICard) { 69 | return; 70 | } 71 | 72 | $principal = $this->resyncCurrentPrincipal(); 73 | } 74 | 75 | public function beforeCardDelete($path) 76 | { 77 | $node = $this->server->tree->getNodeForPath($path); 78 | 79 | if (!$node instanceof CardDAV\ICard) { 80 | return; 81 | } 82 | 83 | $principal = $this->resyncCurrentPrincipal(); 84 | } 85 | 86 | public function getPluginName(): string 87 | { 88 | return 'birthday-calendar'; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /templates/index.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% block title %}Davis{% endblock %} 7 | 8 | 9 | 10 | 11 | 12 | 25 | 26 | 27 | 28 |
29 | 30 |

{{ "davis"|trans }}

31 | 45 | {{ "admin.interface"|trans }} 46 |
47 | 48 | 49 | -------------------------------------------------------------------------------- /src/Entity/Card.php: -------------------------------------------------------------------------------- 1 | id; 41 | } 42 | 43 | public function getAddressBook(): ?AddressBook 44 | { 45 | return $this->addressBook; 46 | } 47 | 48 | public function setAddressBook(?AddressBook $addressBook): self 49 | { 50 | $this->addressBook = $addressBook; 51 | 52 | return $this; 53 | } 54 | 55 | public function getCardData(): ?string 56 | { 57 | return $this->cardData; 58 | } 59 | 60 | public function setCardData(?string $cardData): self 61 | { 62 | $this->cardData = $cardData; 63 | 64 | return $this; 65 | } 66 | 67 | public function getUri(): ?string 68 | { 69 | return $this->uri; 70 | } 71 | 72 | public function setUri(?string $uri): self 73 | { 74 | $this->uri = $uri; 75 | 76 | return $this; 77 | } 78 | 79 | public function getLastModified(): ?int 80 | { 81 | return $this->lastModified; 82 | } 83 | 84 | public function setLastModified(?int $lastModified): self 85 | { 86 | $this->lastModified = $lastModified; 87 | 88 | return $this; 89 | } 90 | 91 | public function getEtag(): ?string 92 | { 93 | return $this->etag; 94 | } 95 | 96 | public function setEtag(?string $etag): self 97 | { 98 | $this->etag = $etag; 99 | 100 | return $this; 101 | } 102 | 103 | public function getSize(): ?int 104 | { 105 | return $this->size; 106 | } 107 | 108 | public function setSize(int $size): self 109 | { 110 | $this->size = $size; 111 | 112 | return $this; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Plugins/PublicAwareDAVACLPlugin.php: -------------------------------------------------------------------------------- 1 | em = $entityManager; 25 | $this->public_calendars_enabled = $public_calendars_enabled; 26 | } 27 | 28 | /** 29 | * We override this method so that public objects can be seen correctly in the browser, 30 | * with the assets (css, images). 31 | */ 32 | public function beforeMethod(RequestInterface $request, ResponseInterface $response) 33 | { 34 | $params = $request->getQueryParameters(); 35 | if (isset($params['sabreAction']) && 'asset' === $params['sabreAction']) { 36 | return; 37 | } 38 | 39 | return parent::beforeMethod($request, $response); 40 | } 41 | 42 | public function getAcl($node): array 43 | { 44 | $acl = parent::getAcl($node); 45 | 46 | if ($node instanceof \Sabre\CalDAV\Calendar) { 47 | if (CalendarInstance::ACCESS_PUBLIC === $node->getShareAccess() && $this->public_calendars_enabled) { 48 | // We must add the ACL on the calendar itself 49 | $acl[] = [ 50 | 'principal' => '{DAV:}unauthenticated', 51 | 'privilege' => '{DAV:}read', 52 | 'protected' => false, 53 | ]; 54 | } 55 | } elseif ($node instanceof \Sabre\CalDAV\CalendarObject) { 56 | // The property is private in \Sabre\CalDAV\CalendarObject and we don't want to create 57 | // a new class just to access it, so we use a closure. 58 | $calendarInfo = (fn () => $this->calendarInfo)->call($node); 59 | // [0] is the calendarId, [1] is the calendarInstanceId 60 | $calendarInstanceId = $calendarInfo['id'][1]; 61 | 62 | $calendar = $this->em->getRepository(CalendarInstance::class)->findOneById($calendarInstanceId); 63 | 64 | if ($calendar && $calendar->isPublic() && $this->public_calendars_enabled) { 65 | // We must add the ACL on the object itself 66 | $acl[] = [ 67 | 'principal' => '{DAV:}unauthenticated', 68 | 'privilege' => '{DAV:}read', 69 | 'protected' => false, 70 | ]; 71 | } 72 | } 73 | 74 | return $acl; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /templates/_partials/share_modal.html.twig: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/Entity/Lock.php: -------------------------------------------------------------------------------- 1 | id; 40 | } 41 | 42 | public function getOwner(): ?string 43 | { 44 | return $this->owner; 45 | } 46 | 47 | public function setOwner(?string $owner): self 48 | { 49 | $this->owner = $owner; 50 | 51 | return $this; 52 | } 53 | 54 | public function getTimeout(): ?int 55 | { 56 | return $this->timeout; 57 | } 58 | 59 | public function setTimeout(?int $timeout): self 60 | { 61 | $this->timeout = $timeout; 62 | 63 | return $this; 64 | } 65 | 66 | public function getCreated(): ?int 67 | { 68 | return $this->created; 69 | } 70 | 71 | public function setCreated(?int $created): self 72 | { 73 | $this->created = $created; 74 | 75 | return $this; 76 | } 77 | 78 | public function getToken(): ?string 79 | { 80 | return $this->token; 81 | } 82 | 83 | public function setToken(?string $token): self 84 | { 85 | $this->token = $token; 86 | 87 | return $this; 88 | } 89 | 90 | public function getScope(): ?int 91 | { 92 | return $this->scope; 93 | } 94 | 95 | public function setScope(?int $scope): self 96 | { 97 | $this->scope = $scope; 98 | 99 | return $this; 100 | } 101 | 102 | public function getDepth(): ?int 103 | { 104 | return $this->depth; 105 | } 106 | 107 | public function setDepth(?int $depth): self 108 | { 109 | $this->depth = $depth; 110 | 111 | return $this; 112 | } 113 | 114 | public function getUri(): ?string 115 | { 116 | return $this->uri; 117 | } 118 | 119 | public function setUri(?string $uri): self 120 | { 121 | $this->uri = $uri; 122 | 123 | return $this; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Entity/SchedulingObject.php: -------------------------------------------------------------------------------- 1 | id; 42 | } 43 | 44 | public function getPrincipalUri(): ?string 45 | { 46 | return $this->principalUri; 47 | } 48 | 49 | public function setPrincipalUri(?string $principalUri): self 50 | { 51 | $this->principalUri = $principalUri; 52 | 53 | return $this; 54 | } 55 | 56 | public function getCalendarData(): ?string 57 | { 58 | return $this->calendarData; 59 | } 60 | 61 | public function setCalendarData(?string $calendarData): self 62 | { 63 | $this->calendarData = $calendarData; 64 | 65 | return $this; 66 | } 67 | 68 | public function getUri(): ?string 69 | { 70 | return $this->uri; 71 | } 72 | 73 | public function setUri(?string $uri): self 74 | { 75 | $this->uri = $uri; 76 | 77 | return $this; 78 | } 79 | 80 | public function getLastModified(): ?int 81 | { 82 | return $this->lastModified; 83 | } 84 | 85 | public function setLastModified(?int $lastModified): self 86 | { 87 | $this->lastModified = $lastModified; 88 | 89 | return $this; 90 | } 91 | 92 | public function getEtag(): ?string 93 | { 94 | return $this->etag; 95 | } 96 | 97 | public function setEtag(?string $etag): self 98 | { 99 | $this->etag = $etag; 100 | 101 | return $this; 102 | } 103 | 104 | public function getSize(): ?int 105 | { 106 | return $this->size; 107 | } 108 | 109 | public function setSize(int $size): self 110 | { 111 | $this->size = $size; 112 | 113 | return $this; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /public/js/color.mode.toggler.js: -------------------------------------------------------------------------------- 1 | /* Based on Bootstrap' color mode toggler */ 2 | /*! 3 | * Color mode toggler for Bootstrap's docs (https://getbootstrap.com/) 4 | * Copyright 2011-2023 The Bootstrap Authors 5 | * Licensed under the Creative Commons Attribution 3.0 Unported License. 6 | */ 7 | 8 | (() => { 9 | 'use strict' 10 | 11 | const getStoredTheme = () => localStorage.getItem('theme') 12 | const setStoredTheme = theme => localStorage.setItem('theme', theme) 13 | 14 | const getPreferredTheme = () => { 15 | const storedTheme = getStoredTheme() 16 | if (storedTheme) { 17 | return storedTheme 18 | } 19 | 20 | return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' 21 | } 22 | 23 | const setTheme = theme => { 24 | if (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) { 25 | document.documentElement.setAttribute('data-bs-theme', 'dark') 26 | } else { 27 | document.documentElement.setAttribute('data-bs-theme', theme) 28 | } 29 | } 30 | 31 | setTheme(getPreferredTheme()) 32 | 33 | const showActiveTheme = (theme, focus = false) => { 34 | const themeSwitcher = document.querySelector('#bd-theme') 35 | 36 | if (!themeSwitcher) { 37 | return 38 | } 39 | 40 | const themeSwitcherText = document.querySelector('#bd-theme-text') 41 | const activeThemeIcon = document.querySelector('.theme-icon-active') 42 | const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"] .theme-icon`) 43 | 44 | document.querySelectorAll('[data-bs-theme-value]').forEach(element => { 45 | element.classList.remove('active') 46 | element.setAttribute('aria-pressed', 'false') 47 | }) 48 | 49 | btnToActive.classList.add('active') 50 | btnToActive.setAttribute('aria-pressed', 'true') 51 | activeThemeIcon.innerHTML = btnToActive.innerHTML 52 | const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})` 53 | themeSwitcher.setAttribute('aria-label', themeSwitcherLabel) 54 | 55 | if (focus) { 56 | themeSwitcher.focus() 57 | } 58 | } 59 | 60 | window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { 61 | const storedTheme = getStoredTheme() 62 | if (storedTheme !== 'light' && storedTheme !== 'dark') { 63 | setTheme(getPreferredTheme()) 64 | } 65 | }) 66 | 67 | window.addEventListener('DOMContentLoaded', () => { 68 | showActiveTheme(getPreferredTheme()) 69 | 70 | document.querySelectorAll('[data-bs-theme-value]') 71 | .forEach(toggle => { 72 | toggle.addEventListener('click', () => { 73 | const theme = toggle.getAttribute('data-bs-theme-value') 74 | setStoredTheme(theme) 75 | setTheme(theme) 76 | showActiveTheme(theme, true) 77 | }) 78 | }) 79 | }) 80 | })() -------------------------------------------------------------------------------- /templates/_partials/navigation.html.twig: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/Repository/CalendarInstanceRepository.php: -------------------------------------------------------------------------------- 1 | createQueryBuilder('c') 29 | ->leftJoin(Principal::class, 'p', \Doctrine\ORM\Query\Expr\Join::WITH, 'c.principalUri = p.uri') 30 | ->where('c.calendar = :id') 31 | ->setParameter('id', $calendarId) 32 | ->andWhere('c.access NOT IN (:ownerAccess)') 33 | ->setParameter('ownerAccess', CalendarInstance::getOwnerAccesses()); 34 | 35 | if ($withCalendar) { 36 | // Returns CalendarInstances as arrays, with displayName and email of the owner 37 | return $query->addSelect('p.displayName', 'p.email') 38 | ->getQuery() 39 | ->getArrayResult(); 40 | } else { 41 | // Returns CalendarInstances as objects 42 | return $query->getQuery() 43 | ->getResult(); 44 | } 45 | } 46 | 47 | /** 48 | * @return CalendarInstance Returns a CalendarInstance object 49 | */ 50 | public function findSharedInstanceOfInstanceFor(int $calendarId, string $principalUri) 51 | { 52 | return $this->createQueryBuilder('c') 53 | ->where('c.calendar = :id') 54 | ->setParameter('id', $calendarId) 55 | ->andWhere('c.access NOT IN (:ownerAccess)') 56 | ->setParameter('ownerAccess', CalendarInstance::getOwnerAccesses()) 57 | ->andWhere('c.principalUri = :principalUri') 58 | ->setParameter('principalUri', $principalUri) 59 | ->getQuery() 60 | ->getOneOrNullResult(); 61 | } 62 | 63 | public function hasDifferentOwner(int $calendarId, string $principalUri): bool 64 | { 65 | return $this->createQueryBuilder('c') 66 | ->select('COUNT(c.id)') 67 | ->where('c.calendar = :id') 68 | ->setParameter('id', $calendarId) 69 | ->andWhere('c.access IN (:ownerAccess)') 70 | ->setParameter('ownerAccess', CalendarInstance::getOwnerAccesses()) 71 | ->andWhere('c.principalUri != :principalUri') 72 | ->setParameter('principalUri', $principalUri) 73 | ->getQuery() 74 | ->getSingleScalarResult() > 0; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tchapi/davis", 3 | "description": "A simple, fully translatable admin interface and frontend for sabre/dav based on Symfony", 4 | "type": "project", 5 | "license": "MIT", 6 | "require": { 7 | "php": "^8.2", 8 | "ext-ctype": "*", 9 | "ext-gd": "*", 10 | "ext-iconv": "*", 11 | "ext-zip": "*", 12 | "composer-runtime-api": "^2", 13 | "dantsu/php-osm-static-api": "^0.6.4", 14 | "doctrine/doctrine-bundle": "^2.15.1", 15 | "doctrine/doctrine-migrations-bundle": "^3.4.2", 16 | "doctrine/orm": "^2.20.6", 17 | "sabre/dav": "^4.7.0", 18 | "symfony/apache-pack": "^1.0.1", 19 | "symfony/asset": "^7.3", 20 | "symfony/console": "^7.3", 21 | "symfony/dotenv": "^7.3", 22 | "symfony/expression-language": "^7.3", 23 | "symfony/flex": "^2.7.1", 24 | "symfony/form": "^7.3", 25 | "symfony/framework-bundle": "^7.3", 26 | "symfony/http-client": "^7.3", 27 | "symfony/intl": "^7.3", 28 | "symfony/mailer": "^7.3", 29 | "symfony/monolog-bundle": "^3.10.0", 30 | "symfony/polyfill-intl-messageformatter": "^1.31", 31 | "symfony/process": "^7.3", 32 | "symfony/property-access": "^7.3", 33 | "symfony/property-info": "^7.3", 34 | "symfony/runtime": "^7.3", 35 | "symfony/security-bundle": "^7.3", 36 | "symfony/serializer": "^7.3", 37 | "symfony/translation": "^7.3", 38 | "symfony/twig-bundle": "^7.3", 39 | "symfony/validator": "^7.3", 40 | "symfony/web-link": "^7.3", 41 | "symfony/yaml": "^7.3", 42 | "webklex/php-imap": "^6.2" 43 | }, 44 | "require-dev": { 45 | "doctrine/doctrine-fixtures-bundle": "^3.5", 46 | "friendsofphp/php-cs-fixer": "^3.49.0", 47 | "phpunit/phpunit": "^10.5.10", 48 | "symfony/browser-kit": "^7.3", 49 | "symfony/css-selector": "^7.3", 50 | "symfony/debug-bundle": "^7.3", 51 | "symfony/maker-bundle": "^1.54", 52 | "symfony/phpunit-bridge": "^7.3", 53 | "symfony/stopwatch": "^7.3", 54 | "symfony/web-profiler-bundle": "^7.3" 55 | }, 56 | "config": { 57 | "preferred-install": { 58 | "*": "dist" 59 | }, 60 | "sort-packages": true, 61 | "platform": { 62 | "php": "8.2.15" 63 | }, 64 | "allow-plugins": { 65 | "composer/package-versions-deprecated": true, 66 | "symfony/flex": true, 67 | "symfony/runtime": true 68 | } 69 | }, 70 | "autoload": { 71 | "psr-4": { 72 | "App\\": "src/" 73 | } 74 | }, 75 | "autoload-dev": { 76 | "psr-4": { 77 | "App\\Tests\\": "tests/" 78 | } 79 | }, 80 | "scripts": { 81 | "auto-scripts": { 82 | "cache:clear": "symfony-cmd", 83 | "assets:install %PUBLIC_DIR%": "symfony-cmd" 84 | }, 85 | "post-install-cmd": [ 86 | "@auto-scripts" 87 | ], 88 | "post-update-cmd": [ 89 | "@auto-scripts" 90 | ] 91 | }, 92 | "conflict": { 93 | "symfony/symfony": "*" 94 | }, 95 | "extra": { 96 | "symfony": { 97 | "allow-contrib": false, 98 | "require": "7.3.*" 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /tests/Functional/DashboardTest.php: -------------------------------------------------------------------------------- 1 | request('GET', '/'); 13 | 14 | $this->assertResponseIsSuccessful(); 15 | $this->assertSelectorTextContains('h3', 'Davis'); 16 | 17 | $this->assertSelectorExists('li.caldav'); 18 | $this->assertSelectorExists('li.carddav'); 19 | $this->assertSelectorExists('li.webdav'); 20 | } 21 | 22 | public function testDashboardPageUnlogged(): void 23 | { 24 | $client = static::createClient(); 25 | $client->request('GET', '/dashboard'); 26 | 27 | $this->assertResponseRedirects('/login'); 28 | } 29 | 30 | public function testLoginPage(): void 31 | { 32 | $client = static::createClient(); 33 | $client->request('GET', '/login'); 34 | 35 | $this->assertResponseIsSuccessful(); 36 | $this->assertSelectorTextContains('h1', 'Please sign in'); 37 | $this->assertSelectorExists('nav.navbar'); 38 | } 39 | 40 | public function testLoginIncorrectUsername(): void 41 | { 42 | $client = static::createClient(); 43 | $crawler = $client->request('GET', '/login'); 44 | 45 | $form = $crawler->selectButton('Submit')->form(); 46 | $form['_username']->setValue('bad_'.$_ENV['ADMIN_LOGIN']); 47 | $form['_password']->setValue('bad_password'); 48 | 49 | $client->submit($form); 50 | $this->assertResponseRedirects('/login'); 51 | $crawler = $client->followRedirect(); 52 | $this->assertResponseIsSuccessful(); 53 | 54 | $this->assertSelectorTextContains('div.alert.alert-danger', 'Invalid credentials.'); 55 | } 56 | 57 | public function testLoginIncorrectPassword(): void 58 | { 59 | $client = static::createClient(); 60 | $crawler = $client->request('GET', '/login'); 61 | 62 | $form = $crawler->selectButton('Submit')->form(); 63 | $form['_username']->setValue($_ENV['ADMIN_LOGIN']); 64 | $form['_password']->setValue('bad_password'); 65 | 66 | $client->submit($form); 67 | $this->assertResponseRedirects('/login'); 68 | $crawler = $client->followRedirect(); 69 | $this->assertResponseIsSuccessful(); 70 | 71 | $this->assertSelectorTextContains('div.alert.alert-danger', 'Invalid credentials.'); 72 | } 73 | 74 | public function testLoginCorrect(): void 75 | { 76 | $client = static::createClient(); 77 | $crawler = $client->request('GET', '/login'); 78 | 79 | $form = $crawler->selectButton('Submit')->form(); 80 | $form['_username']->setValue($_ENV['ADMIN_LOGIN']); 81 | $form['_password']->setValue($_ENV['ADMIN_PASSWORD']); 82 | 83 | $client->submit($form); 84 | $this->assertResponseRedirects('/dashboard'); 85 | $crawler = $client->followRedirect(); 86 | $this->assertResponseIsSuccessful(); 87 | 88 | $this->assertSelectorTextContains('h1', 'Dashboard'); 89 | $this->assertSelectorTextContains('h3.capabilities', 'Capabilities'); 90 | $this->assertSelectorTextContains('h3.objects', 'Objects'); 91 | $this->assertSelectorTextContains('h3.environment', 'Configured environment'); 92 | $this->assertSelectorExists('nav.navbar'); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - 'docker/**' 7 | pull_request: 8 | paths-ignore: 9 | - 'docker/**' 10 | 11 | env: 12 | COMPOSER_ALLOW_SUPERUSER: '1' 13 | SYMFONY_DEPRECATIONS_HELPER: max[self]=0 14 | ADMIN_LOGIN: admin 15 | ADMIN_PASSWORD: test 16 | DATABASE_URL: mysql://davis:davis@mysql:3306/davis_test 17 | 18 | jobs: 19 | analyze: 20 | name: Analyze 21 | runs-on: ubuntu-latest 22 | container: 23 | image: php:8.3-alpine 24 | options: >- 25 | --tmpfs /tmp:exec 26 | --tmpfs /var/tmp:exec 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | - name: Install GD / ZIP PHP extension 31 | run: | 32 | apk add $PHPIZE_DEPS libpng-dev libzip-dev 33 | docker-php-ext-configure gd 34 | docker-php-ext-configure zip 35 | docker-php-ext-install gd zip 36 | - name: Install Composer 37 | run: wget -qO - https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet 38 | - name: Validate Composer 39 | run: composer validate 40 | - name: Update to highest dependencies with Composer 41 | run: composer update --no-interaction --no-progress --ansi 42 | - name: Analyze 43 | run: PHP_CS_FIXER_IGNORE_ENV=True vendor/bin/php-cs-fixer fix --ansi 44 | 45 | phpunit: 46 | name: PHPUnit (PHP ${{ matrix.php }}) 47 | runs-on: ubuntu-latest 48 | container: 49 | image: php:${{ matrix.php }}-alpine 50 | options: >- 51 | --tmpfs /tmp:exec 52 | --tmpfs /var/tmp:exec 53 | services: 54 | mysql: 55 | image: mariadb:10.11 56 | env: 57 | # Corresponds to what is in .env.test 58 | MYSQL_DATABASE: davis_test 59 | MYSQL_USER: davis 60 | MYSQL_PASSWORD: davis 61 | MYSQL_ROOT_PASSWORD: root 62 | options: >- 63 | --health-cmd "mysqladmin ping" 64 | --health-interval 10s 65 | --health-timeout 5s 66 | --health-retries 5 67 | ports: 68 | - 3306:3306 69 | strategy: 70 | matrix: 71 | php: 72 | - '8.2' 73 | - '8.3' 74 | - '8.4' 75 | fail-fast: false 76 | steps: 77 | - name: Checkout 78 | uses: actions/checkout@v4 79 | - name: Install MySQL / GD / ZIP PHP extensions 80 | run: | 81 | apk add $PHPIZE_DEPS icu-libs icu-dev libpng-dev libzip-dev 82 | docker-php-ext-configure intl 83 | docker-php-ext-configure gd 84 | docker-php-ext-configure zip 85 | docker-php-ext-install pdo pdo_mysql intl gd zip 86 | - name: Install Composer 87 | run: wget -qO - https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet 88 | - name: Install dependencies with Composer 89 | run: composer install --no-progress --no-interaction --ansi 90 | - name: Run tests with PHPUnit 91 | run: vendor/bin/phpunit --process-isolation --colors=always 92 | -------------------------------------------------------------------------------- /src/Security/LoginFormAuthenticator.php: -------------------------------------------------------------------------------- 1 | urlGenerator = $urlGenerator; 33 | $this->csrfTokenManager = $csrfTokenManager; 34 | $this->adminLogin = $adminLogin; 35 | $this->adminPassword = $adminPassword; 36 | } 37 | 38 | protected function getLoginUrl(Request $request): string 39 | { 40 | return $this->urlGenerator->generate('app_login'); 41 | } 42 | 43 | public function supports(Request $request): bool 44 | { 45 | return 'app_login' === $request->attributes->get('_route') 46 | && $request->isMethod('POST'); 47 | } 48 | 49 | public function authenticate(Request $request): Passport 50 | { 51 | $credentials = [ 52 | 'username' => $request->request->get('_username'), 53 | 'password' => $request->request->get('_password'), 54 | 'csrf_token' => $request->request->get('_csrf_token'), 55 | ]; 56 | $request->getSession()->set( 57 | SecurityRequestAttributes::LAST_USERNAME, 58 | $credentials['username'] 59 | ); 60 | 61 | if ($credentials['username'] !== $this->adminLogin) { 62 | // fail authentication with a custom error 63 | throw new CustomUserMessageAuthenticationException('Username could not be found.'); 64 | } 65 | 66 | if ($credentials['password'] !== $this->adminPassword) { 67 | // fail authentication with a custom error 68 | throw new CustomUserMessageAuthenticationException('Invalid credentials.'); 69 | } 70 | 71 | return new SelfValidatingPassport( 72 | new UserBadge($this->adminLogin), 73 | [new CsrfTokenBadge('authenticate', $credentials['csrf_token'])] 74 | ); 75 | } 76 | 77 | public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey): ?Response 78 | { 79 | if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) { 80 | return new RedirectResponse($targetPath); 81 | } 82 | 83 | return new RedirectResponse($this->urlGenerator->generate('dashboard')); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Form/CalendarInstanceType.php: -------------------------------------------------------------------------------- 1 | add('principalUri', HiddenType::class, [ 22 | 'required' => true, 23 | ]) 24 | ->add('uri', TextType::class, [ 25 | 'label' => 'form.uri', 26 | 'disabled' => !$options['new'], 27 | 'help' => 'form.uri.help.caldav', 28 | 'required' => true, 29 | ]) 30 | ->add('public', ChoiceType::class, [ 31 | 'label' => 'form.public', 32 | 'mapped' => false, 33 | 'disabled' => $options['shared'], 34 | 'help' => 'form.public.help.caldav', 35 | 'required' => true, 36 | 'choices' => ['yes' => true, 'no' => false], 37 | ]) 38 | ->add('displayName', TextType::class, [ 39 | 'label' => 'form.displayName', 40 | 'help' => 'form.name.help.caldav', 41 | ]) 42 | ->add('description', TextareaType::class, [ 43 | 'label' => 'form.description', 44 | 'required' => false, 45 | ]) 46 | ->add('calendarColor', TextType::class, [ 47 | 'label' => 'form.color', 48 | 'required' => false, 49 | 'help' => 'form.color.help', 50 | 'attr' => ['placeholder' => '#RRGGBBAA'], 51 | ]) 52 | ->add('events', CheckboxType::class, [ 53 | 'label' => 'form.events', 54 | 'mapped' => false, 55 | 'disabled' => $options['shared'], 56 | 'help' => 'form.events.help', 57 | 'required' => false, 58 | ]) 59 | ->add('todos', CheckboxType::class, [ 60 | 'label' => 'form.todos', 61 | 'mapped' => false, 62 | 'disabled' => $options['shared'], 63 | 'help' => 'form.todos.help', 64 | 'required' => false, 65 | ]) 66 | ->add('notes', CheckboxType::class, [ 67 | 'label' => 'form.notes', 68 | 'mapped' => false, 69 | 'disabled' => $options['shared'], 70 | 'help' => 'form.notes.help', 71 | 'required' => false, 72 | ]) 73 | ->add('save', SubmitType::class, [ 74 | 'label' => 'save', 75 | ]); 76 | 77 | if (!$options['public_calendars_enabled']) { 78 | $builder->remove('public'); 79 | } 80 | } 81 | 82 | public function configureOptions(OptionsResolver $resolver): void 83 | { 84 | $resolver->setDefaults([ 85 | 'new' => false, 86 | 'shared' => false, 87 | 'data_class' => CalendarInstance::class, 88 | 'public_calendars_enabled' => true, 89 | ]); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | # Use the front controller as index file. It serves as a fallback solution when 2 | # every other rewrite/redirect fails (e.g. in an aliased environment without 3 | # mod_rewrite). Additionally, this reduces the matching process for the 4 | # start page (path "/") because otherwise Apache will apply the rewriting rules 5 | # to each configured DirectoryIndex file (e.g. index.php, index.html, index.pl). 6 | DirectoryIndex index.php 7 | 8 | # By default, Apache does not evaluate symbolic links if you did not enable this 9 | # feature in your server configuration. Uncomment the following line if you 10 | # install assets as symlinks or if you experience problems related to symlinks 11 | # when compiling LESS/Sass/CoffeScript assets. 12 | # Options +FollowSymlinks 13 | 14 | # Disabling MultiViews prevents unwanted negotiation, e.g. "/index" should not resolve 15 | # to the front controller "/index.php" but be rewritten to "/index.php/index". 16 | 17 | Options -MultiViews 18 | 19 | 20 | 21 | RewriteEngine On 22 | 23 | # Add .well-known redirections 24 | RewriteRule ^\.well-known/carddav /dav/ [R=301,L] 25 | RewriteRule ^\.well-known/caldav /dav/ [R=301,L] 26 | 27 | # Determine the RewriteBase automatically and set it as environment variable. 28 | # If you are using Apache aliases to do mass virtual hosting or installed the 29 | # project in a subdirectory, the base path will be prepended to allow proper 30 | # resolution of the index.php file and to redirect to the correct URI. It will 31 | # work in environments without path prefix as well, providing a safe, one-size 32 | # fits all solution. But as you do not need it in this case, you can comment 33 | # the following 2 lines to eliminate the overhead. 34 | RewriteCond %{REQUEST_URI}::$0 ^(/.+)/(.*)::\2$ 35 | RewriteRule .* - [E=BASE:%1] 36 | 37 | # Sets the HTTP_AUTHORIZATION header removed by Apache 38 | RewriteCond %{HTTP:Authorization} .+ 39 | RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0] 40 | 41 | # Redirect to URI without front controller to prevent duplicate content 42 | # (with and without `/index.php`). Only do this redirect on the initial 43 | # rewrite by Apache and not on subsequent cycles. Otherwise we would get an 44 | # endless redirect loop (request -> rewrite to front controller -> 45 | # redirect -> request -> ...). 46 | # So in case you get a "too many redirects" error or you always get redirected 47 | # to the start page because your Apache does not expose the REDIRECT_STATUS 48 | # environment variable, you have 2 choices: 49 | # - disable this feature by commenting the following 2 lines or 50 | # - use Apache >= 2.3.9 and replace all L flags by END flags and remove the 51 | # following RewriteCond (best solution) 52 | RewriteCond %{ENV:REDIRECT_STATUS} ="" 53 | RewriteRule ^index\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L] 54 | 55 | # If the requested filename exists, simply serve it. 56 | # We only want to let Apache serve files and not directories. 57 | # Rewrite all other queries to the front controller. 58 | RewriteCond %{REQUEST_FILENAME} !-f 59 | RewriteRule ^ %{ENV:BASE}/index.php [L] 60 | 61 | 62 | 63 | 64 | # When mod_rewrite is not available, we instruct a temporary redirect of 65 | # the start page to the front controller explicitly so that the website 66 | # and the generated links can still be used. 67 | RedirectMatch 307 ^/$ /index.php/ 68 | # RedirectTemp cannot be used instead 69 | 70 | 71 | -------------------------------------------------------------------------------- /templates/users/index.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | {% set menu = 'resources' %} 3 | 4 | {% block body %} 5 | 6 |

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

7 | 8 |
9 | {% for principal in principals %} 10 |
11 | 27 |

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

28 | {{ "users.uri"|trans }} : {{ principal.uri }}{% if principal.isAdmin %} — {{ "users.administrator"|trans }}{% endif %} 29 | 41 |
42 | {% else %} 43 |
{{ "no.users.yet"|trans }}
44 | {% endfor %} 45 |
46 | 47 | {% include '_partials/delete_modal.html.twig' with {flavour: 'users'} %} 48 | 49 | {% endblock %} -------------------------------------------------------------------------------- /migrations/Version20231001214113.php: -------------------------------------------------------------------------------- 1 | skipIf('sqlite' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'sqlite\'. Skipping it is fine.'); 23 | 24 | $this->addSql('ALTER TABLE calendarobjects ADD COLUMN new_calendardata TEXT DEFAULT NULL;'); 25 | $this->addSql('UPDATE calendarobjects SET new_calendardata = CAST(calendardata as TEXT);'); 26 | $this->addSql('ALTER TABLE calendarobjects RENAME COLUMN calendardata TO old_calendardata;'); 27 | $this->addSql('ALTER TABLE calendarobjects RENAME COLUMN new_calendardata TO calendardata;'); 28 | $this->addSql('ALTER TABLE calendarobjects DROP COLUMN old_calendardata;'); 29 | 30 | $this->addSql('ALTER TABLE cards ADD COLUMN new_carddata TEXT DEFAULT NULL;'); 31 | $this->addSql('UPDATE cards SET new_carddata = CAST(carddata as TEXT);'); 32 | $this->addSql('ALTER TABLE cards RENAME COLUMN carddata TO old_carddata;'); 33 | $this->addSql('ALTER TABLE cards RENAME COLUMN new_carddata TO carddata;'); 34 | $this->addSql('ALTER TABLE cards DROP COLUMN old_carddata;'); 35 | 36 | $this->addSql('ALTER TABLE schedulingobjects ADD COLUMN new_calendardata TEXT DEFAULT NULL;'); 37 | $this->addSql('UPDATE schedulingobjects SET new_calendardata = CAST(calendardata as TEXT);'); 38 | $this->addSql('ALTER TABLE schedulingobjects RENAME COLUMN calendardata TO old_calendardata;'); 39 | $this->addSql('ALTER TABLE schedulingobjects RENAME COLUMN new_calendardata TO calendardata;'); 40 | $this->addSql('ALTER TABLE schedulingobjects DROP COLUMN old_calendardata;'); 41 | } 42 | 43 | public function down(Schema $schema): void 44 | { 45 | $this->skipIf('sqlite' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'sqlite\'. Skipping it is fine.'); 46 | 47 | $this->addSql('ALTER TABLE calendarobjects ADD COLUMN new_calendardata BLOB DEFAULT NULL;'); 48 | $this->addSql('UPDATE calendarobjects SET new_calendardata = CAST(calendardata as BLOB);'); 49 | $this->addSql('ALTER TABLE calendarobjects RENAME COLUMN calendardata TO old_calendardata;'); 50 | $this->addSql('ALTER TABLE calendarobjects RENAME COLUMN new_calendardata TO calendardata;'); 51 | $this->addSql('ALTER TABLE calendarobjects DROP COLUMN old_calendardata;'); 52 | 53 | $this->addSql('ALTER TABLE cards ADD COLUMN new_carddata BLOB DEFAULT NULL;'); 54 | $this->addSql('UPDATE cards SET new_carddata = CAST(carddata as BLOB);'); 55 | $this->addSql('ALTER TABLE cards RENAME COLUMN carddata TO old_carddata;'); 56 | $this->addSql('ALTER TABLE cards RENAME COLUMN new_carddata TO carddata;'); 57 | $this->addSql('ALTER TABLE cards DROP COLUMN old_carddata;'); 58 | 59 | $this->addSql('ALTER TABLE schedulingobjects ADD COLUMN new_calendardata BLOB DEFAULT NULL;'); 60 | $this->addSql('UPDATE schedulingobjects SET new_calendardata = CAST(calendardata as BLOB);'); 61 | $this->addSql('ALTER TABLE schedulingobjects RENAME COLUMN calendardata TO old_calendardata;'); 62 | $this->addSql('ALTER TABLE schedulingobjects RENAME COLUMN new_calendardata TO calendardata;'); 63 | $this->addSql('ALTER TABLE schedulingobjects DROP COLUMN old_calendardata;'); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /tests/Functional/AddressBookControllerTest.php: -------------------------------------------------------------------------------- 1 | loginUser($user); 17 | $client->request('GET', '/addressbooks/test_user'); 18 | 19 | $this->assertResponseIsSuccessful(); 20 | 21 | $this->assertSelectorExists('nav.navbar'); 22 | $this->assertSelectorTextContains('h1', 'Address books for Test User'); 23 | $this->assertSelectorTextContains('a.btn', '+ New Address Book'); 24 | $this->assertSelectorTextContains('h5', 'default.addressbook.title'); 25 | } 26 | 27 | public function testAddressBookEdit(): void 28 | { 29 | $user = new AdminUser('admin', 'test'); 30 | 31 | $client = static::createClient(); 32 | $client->loginUser($user); 33 | 34 | $addressbookRepository = static::getContainer()->get('doctrine.orm.entity_manager')->getRepository(AddressBook::class); 35 | $addressbook = $addressbookRepository->findOneByDisplayName('default.addressbook.title'); 36 | 37 | $client->request('GET', '/addressbooks/test_user/edit/'.$addressbook->getId()); 38 | 39 | $this->assertResponseIsSuccessful(); 40 | 41 | $this->assertSelectorTextContains('h1', 'Editing Address Book «default.addressbook.title»'); 42 | $this->assertSelectorTextContains('button#address_book_save', 'Save'); 43 | 44 | $client->submitForm('address_book_save'); 45 | 46 | $this->assertResponseRedirects('/addressbooks/test_user'); 47 | $client->followRedirect(); 48 | 49 | $this->assertSelectorTextContains('h5', 'default.addressbook.title'); 50 | } 51 | 52 | public function testAddressBookNew(): void 53 | { 54 | $user = new AdminUser('admin', 'test'); 55 | 56 | $client = static::createClient(); 57 | $client->loginUser($user); 58 | $crawler = $client->request('GET', '/addressbooks/test_user/new'); 59 | 60 | $this->assertResponseIsSuccessful(); 61 | 62 | $this->assertSelectorTextContains('h1', 'New Address Book '); 63 | $this->assertSelectorTextContains('button#address_book_save', 'Save'); 64 | 65 | $buttonCrawlerNode = $crawler->selectButton('address_book_save'); 66 | 67 | $form = $buttonCrawlerNode->form(); 68 | $client->submit($form, [ 69 | 'address_book[uri]' => 'new_test_address_book', 70 | 'address_book[displayName]' => 'New test address book', 71 | 'address_book[description]' => 'new address book', 72 | ]); 73 | 74 | $this->assertResponseRedirects('/addressbooks/test_user'); 75 | $client->followRedirect(); 76 | 77 | $this->assertSelectorTextContains('h5', 'default.addressbook.title'); 78 | $this->assertAnySelectorTextContains('h5', 'New test address book'); 79 | } 80 | 81 | public function testAddressBookDelete(): void 82 | { 83 | $user = new AdminUser('admin', 'test'); 84 | 85 | $client = static::createClient(); 86 | $client->loginUser($user); 87 | 88 | $addressbookRepository = static::getContainer()->get('doctrine.orm.entity_manager')->getRepository(AddressBook::class); 89 | $addressbook = $addressbookRepository->findOneByDisplayName('default.addressbook.title'); 90 | 91 | $client->request('GET', '/addressbooks/test_user/delete/'.$addressbook->getId()); 92 | 93 | $this->assertResponseRedirects('/addressbooks/test_user'); 94 | $client->followRedirect(); 95 | 96 | $this->assertSelectorTextNotContains('h5', 'default.addressbook.title'); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /translations/security.en.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 |
7 | 8 | 9 | An authentication exception occurred. 10 | An authentication exception occurred. 11 | 12 | 13 | Authentication credentials could not be found. 14 | Authentication credentials could not be found. 15 | 16 | 17 | Authentication request could not be processed due to a system problem. 18 | Authentication request could not be processed due to a system problem. 19 | 20 | 21 | Invalid credentials. 22 | Invalid credentials. 23 | 24 | 25 | Cookie has already been used by someone else. 26 | Cookie has already been used by someone else. 27 | 28 | 29 | Not privileged to request the resource. 30 | Not privileged to request the resource. 31 | 32 | 33 | Invalid CSRF token. 34 | Invalid CSRF token. 35 | 36 | 37 | No authentication provider found to support the authentication token. 38 | No authentication provider found to support the authentication token. 39 | 40 | 41 | No session available, it either timed out or cookies are not enabled. 42 | No session available, it either timed out or cookies are not enabled. 43 | 44 | 45 | No token could be found. 46 | No token could be found. 47 | 48 | 49 | Username could not be found. 50 | Invalid credentials. 51 | 52 | 53 | Account has expired. 54 | Account has expired. 55 | 56 | 57 | Credentials have expired. 58 | Credentials have expired. 59 | 60 | 61 | Account is disabled. 62 | Account is disabled. 63 | 64 | 65 | Account is locked. 66 | Account is locked. 67 | 68 | 69 |
70 |
71 | -------------------------------------------------------------------------------- /tests/Functional/CalendarControllerTest.php: -------------------------------------------------------------------------------- 1 | loginUser($user); 17 | $client->request('GET', '/calendars/test_user'); 18 | 19 | $this->assertResponseIsSuccessful(); 20 | 21 | $this->assertSelectorExists('nav.navbar'); 22 | $this->assertSelectorTextContains('h1', 'Calendars for Test User'); 23 | $this->assertSelectorTextContains('a.btn', '+ New Calendar'); 24 | $this->assertSelectorTextContains('h5', 'default.calendar.title'); 25 | } 26 | 27 | public function testCalendarEdit(): void 28 | { 29 | $user = new AdminUser('admin', 'test'); 30 | 31 | $client = static::createClient(); 32 | $client->loginUser($user); 33 | 34 | $calendarRepository = static::getContainer()->get(CalendarInstanceRepository::class); 35 | $calendar = $calendarRepository->findOneByDisplayName('default.calendar.title'); 36 | 37 | $client->request('GET', '/calendars/test_user/edit/'.$calendar->getId()); 38 | 39 | $this->assertResponseIsSuccessful(); 40 | 41 | $this->assertSelectorTextContains('h1', 'Editing Calendar «default.calendar.title»'); 42 | $this->assertSelectorTextContains('button#calendar_instance_save', 'Save'); 43 | 44 | $client->submitForm('calendar_instance_save'); 45 | 46 | $this->assertResponseRedirects('/calendars/test_user'); 47 | $client->followRedirect(); 48 | 49 | $this->assertSelectorTextContains('h5', 'default.calendar.title'); 50 | } 51 | 52 | public function testCalendarNew(): void 53 | { 54 | $user = new AdminUser('admin', 'test'); 55 | 56 | $client = static::createClient(); 57 | $client->loginUser($user); 58 | $crawler = $client->request('GET', '/calendars/test_user/new'); 59 | 60 | $this->assertResponseIsSuccessful(); 61 | 62 | $this->assertSelectorTextContains('h1', 'New Calendar '); 63 | $this->assertSelectorTextContains('button#calendar_instance_save', 'Save'); 64 | 65 | $buttonCrawlerNode = $crawler->selectButton('calendar_instance_save'); 66 | 67 | $form = $buttonCrawlerNode->form(); 68 | $client->submit($form, [ 69 | 'calendar_instance[uri]' => 'new_test_calendar', 70 | 'calendar_instance[displayName]' => 'New test calendar', 71 | 'calendar_instance[description]' => 'new calendar', 72 | 'calendar_instance[calendarColor]' => '#00112233', 73 | ]); 74 | 75 | $this->assertResponseRedirects('/calendars/test_user'); 76 | $client->followRedirect(); 77 | 78 | $this->assertSelectorTextContains('h5', 'default.calendar.title'); 79 | $this->assertAnySelectorTextContains('h5', 'New test calendar'); 80 | } 81 | 82 | public function testCalendarDelete(): void 83 | { 84 | $user = new AdminUser('admin', 'test'); 85 | 86 | $client = static::createClient(); 87 | $client->loginUser($user); 88 | 89 | $calendarRepository = static::getContainer()->get(CalendarInstanceRepository::class); 90 | $calendar = $calendarRepository->findOneByDisplayName('default.calendar.title'); 91 | 92 | $client->request('GET', '/calendars/test_user/delete/'.$calendar->getId()); 93 | 94 | $this->assertResponseRedirects('/calendars/test_user'); 95 | $client->followRedirect(); 96 | 97 | $this->assertSelectorTextNotContains('h5', 'default.calendar.title'); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /config/services.yaml: -------------------------------------------------------------------------------- 1 | # This file is the entry point to configure your own services. 2 | # Files in the packages/ subdirectory configure your dependencies. 3 | 4 | # Put parameters here that don't need to change on each machine where the app is deployed 5 | # https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration 6 | parameters: 7 | default_database_driver: "mysql" 8 | default_admin_auth_bypass: "false" 9 | timezone: '%env(APP_TIMEZONE)%' 10 | public_calendars_enabled: '%env(default:default_public_calendars_enabled:bool:PUBLIC_CALENDARS_ENABLED)%' 11 | default_public_calendars_enabled: "true" 12 | birthday_reminder_offset: '%env(default:default_birthday_reminder_offset:BIRTHDAY_REMINDER_OFFSET)%' 13 | default_birthday_reminder_offset: "PT9H" 14 | caldav_enabled: "%env(bool:CALDAV_ENABLED)%" 15 | carddav_enabled: "%env(bool:CARDDAV_ENABLED)%" 16 | 17 | services: 18 | # default configuration for services in *this* file 19 | _defaults: 20 | autowire: true # Automatically injects dependencies in your services. 21 | autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. 22 | 23 | # makes classes in src/ available to be used as services 24 | # this creates a service per class whose id is the fully-qualified class name 25 | App\: 26 | resource: '../src/*' 27 | exclude: '../src/{DependencyInjection,Entity,Migrations,Tests,Kernel.php}' 28 | 29 | App\Services\Utils: 30 | arguments: 31 | $authRealm: "%env(AUTH_REALM)%" 32 | 33 | App\Services\IMAPAuth: 34 | arguments: 35 | $IMAPAuthUrl: "%env(IMAP_AUTH_URL)%" 36 | $IMAPEncryptionMethod: "%env(IMAP_ENCRYPTION_METHOD)%" 37 | $IMAPCertificateValidation: "%env(bool:IMAP_CERTIFICATE_VALIDATION)%" 38 | $autoCreate: "%env(bool:IMAP_AUTH_USER_AUTOCREATE)%" 39 | 40 | App\Services\LDAPAuth: 41 | arguments: 42 | $LDAPAuthUrl: "%env(LDAP_AUTH_URL)%" 43 | $LDAPDnPattern: "%env(LDAP_DN_PATTERN)%" 44 | $LDAPMailAttribute: "%env(LDAP_MAIL_ATTRIBUTE)%" 45 | $LDAPCertificateCheckingStrategy: "%env(LDAP_CERTIFICATE_CHECKING_STRATEGY)%" 46 | $autoCreate: "%env(bool:LDAP_AUTH_USER_AUTOCREATE)%" 47 | 48 | # controllers are imported separately to make sure services can be injected 49 | # as action arguments even if you don't extend any base controller class 50 | App\Controller\: 51 | resource: '../src/Controller' 52 | tags: ['controller.service_arguments'] 53 | 54 | App\Controller\DAVController: 55 | arguments: 56 | $publicDir: "%kernel.project_dir%/public" 57 | $calDAVEnabled: "%env(bool:CALDAV_ENABLED)%" 58 | $cardDAVEnabled: "%env(bool:CARDDAV_ENABLED)%" 59 | $webDAVEnabled: "%env(bool:WEBDAV_ENABLED)%" 60 | $publicCalendarsEnabled: "%public_calendars_enabled%" 61 | $inviteAddress: "%env(INVITE_FROM_ADDRESS)%" 62 | $authMethod: "%env(AUTH_METHOD)%" 63 | $authRealm: "%env(AUTH_REALM)%" 64 | $webdavPublicDir: "%env(resolve:WEBDAV_PUBLIC_DIR)%" 65 | $webdavHomesDir: "%env(resolve:WEBDAV_HOMES_DIR)%" 66 | $webdavTmpDir: "%env(resolve:WEBDAV_TMP_DIR)%" 67 | 68 | App\Security\LoginFormAuthenticator: 69 | arguments: 70 | $adminLogin: "%env(ADMIN_LOGIN)%" 71 | $adminPassword: "%env(ADMIN_PASSWORD)%" 72 | 73 | App\Logging\Monolog\PasswordFilterProcessor: 74 | tags: 75 | - { name: monolog.processor } 76 | 77 | App\Services\BirthdayService: 78 | arguments: 79 | $birthdayReminderOffset: "%birthday_reminder_offset%" 80 | 81 | when@dev: 82 | services: 83 | Symfony\Component\HttpKernel\Profiler\Profiler: '@profiler' 84 | 85 | when@test: 86 | services: 87 | Symfony\Component\HttpKernel\Profiler\Profiler: '@profiler' 88 | -------------------------------------------------------------------------------- /src/Services/Utils.php: -------------------------------------------------------------------------------- 1 | authRealm = $authRealm ?? User::DEFAULT_AUTH_REALM; 39 | $this->trans = $trans; 40 | $this->doctrine = $doctrine; 41 | } 42 | 43 | /** 44 | * Hash a password according to the realm. 45 | * Important note: It is very insecure and this is used only for the legacy sabre/dav implementation. 46 | */ 47 | public function hashPassword(string $username, string $password): string 48 | { 49 | return md5($username.':'.$this->authRealm.':'.$password); 50 | } 51 | 52 | public function createPasswordlessUserWithDefaultObjects(string $username, string $displayName, string $email) 53 | { 54 | $user = new User(); 55 | $user->setUsername($username); 56 | 57 | // Set the password to a random string (but hashed beforehand) 58 | $randomBytes = substr(bin2hex(random_bytes(256)), 0, 48); 59 | $hash = password_hash($randomBytes, PASSWORD_DEFAULT); 60 | $user->setPassword($hash); 61 | 62 | // Create principal, default calendar and addressbook 63 | $principal = new Principal(); 64 | $principal->setUri(Principal::PREFIX.$username) 65 | ->setDisplayName($displayName) 66 | ->setEmail($email) 67 | ->setIsAdmin(false); 68 | 69 | $calendarInstance = new CalendarInstance(); 70 | $calendar = new Calendar(); 71 | $calendarInstance->setPrincipalUri(Principal::PREFIX.$username) 72 | ->setUri('default') // No risk of collision since unicity is guaranteed by the new user principal 73 | ->setDisplayName($this->trans->trans('default.calendar.title')) 74 | ->setDescription($this->trans->trans('default.calendar.description', ['user' => $displayName])) 75 | ->setCalendar($calendar); 76 | 77 | // Enable delegation by default 78 | $principalProxyRead = new Principal(); 79 | $principalProxyRead->setUri($principal->getUri().Principal::READ_PROXY_SUFFIX) 80 | ->setIsMain(false); 81 | 82 | $principalProxyWrite = new Principal(); 83 | $principalProxyWrite->setUri($principal->getUri().Principal::WRITE_PROXY_SUFFIX) 84 | ->setIsMain(false); 85 | 86 | $addressbook = new AddressBook(); 87 | $addressbook->setPrincipalUri(Principal::PREFIX.$username) 88 | ->setUri('default') // No risk of collision since unicity is guaranteed by the new user principal 89 | ->setDisplayName($this->trans->trans('default.addressbook.title')) 90 | ->setDescription($this->trans->trans('default.addressbook.description', ['user' => $displayName])); 91 | 92 | // Persist all items 93 | $em = $this->doctrine->getManager(); 94 | $em->persist($principalProxyRead); 95 | $em->persist($principalProxyWrite); 96 | $em->persist($calendarInstance); 97 | $em->persist($addressbook); 98 | $em->persist($principal); 99 | $em->persist($user); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /migrations/Version20230209142217.php: -------------------------------------------------------------------------------- 1 | skipIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'postgresql\'. Skipping it is fine.'); 23 | 24 | $this->addSql('ALTER TABLE addressbooks ALTER COLUMN id SET DEFAULT nextval(\'addressbooks_id_seq\');'); 25 | $this->addSql('ALTER TABLE calendars ALTER COLUMN id SET DEFAULT nextval(\'calendars_id_seq\');'); 26 | $this->addSql('ALTER TABLE cards ALTER COLUMN id SET DEFAULT nextval(\'cards_id_seq\');'); 27 | $this->addSql('ALTER TABLE calendarsubscriptions ALTER COLUMN id SET DEFAULT nextval(\'calendarsubscriptions_id_seq\');'); 28 | $this->addSql('ALTER TABLE schedulingobjects ALTER COLUMN id SET DEFAULT nextval(\'schedulingobjects_id_seq\');'); 29 | $this->addSql('ALTER TABLE locks ALTER COLUMN id SET DEFAULT nextval(\'locks_id_seq\');'); 30 | $this->addSql('ALTER TABLE calendarinstances ALTER COLUMN id SET DEFAULT nextval(\'calendarinstances_id_seq\');'); 31 | $this->addSql('ALTER TABLE addressbookchanges ALTER COLUMN id SET DEFAULT nextval(\'addressbookchanges_id_seq\');'); 32 | $this->addSql('ALTER TABLE principals ALTER COLUMN id SET DEFAULT nextval(\'principals_id_seq\');'); 33 | $this->addSql('ALTER TABLE calendarchanges ALTER COLUMN id SET DEFAULT nextval(\'calendarchanges_id_seq\');'); 34 | $this->addSql('ALTER TABLE users ALTER COLUMN id SET DEFAULT nextval(\'users_id_seq\');'); 35 | $this->addSql('ALTER TABLE calendarobjects ALTER COLUMN id SET DEFAULT nextval(\'calendarobjects_id_seq\');'); 36 | $this->addSql('ALTER TABLE propertystorage ALTER COLUMN id SET DEFAULT nextval(\'propertystorage_id_seq\');'); 37 | $this->addSql('ALTER TABLE addressbooks ALTER COLUMN synctoken TYPE integer USING synctoken::integer;'); 38 | $this->addSql('ALTER TABLE calendars ALTER COLUMN synctoken TYPE integer USING synctoken::integer;'); 39 | } 40 | 41 | public function down(Schema $schema): void 42 | { 43 | $this->skipIf('postgresql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'postgresql\'. Skipping it is fine.'); 44 | 45 | $this->addSql('ALTER TABLE addressbooks ALTER COLUMN id DROP DEFAULT;'); 46 | $this->addSql('ALTER TABLE calendars ALTER COLUMN id DROP DEFAULT;'); 47 | $this->addSql('ALTER TABLE cards ALTER COLUMN id DROP DEFAULT;'); 48 | $this->addSql('ALTER TABLE calendarsubscriptions ALTER COLUMN id DROP DEFAULT;'); 49 | $this->addSql('ALTER TABLE schedulingobjects ALTER COLUMN id DROP DEFAULT;'); 50 | $this->addSql('ALTER TABLE locks ALTER COLUMN id DROP DEFAULT;'); 51 | $this->addSql('ALTER TABLE calendarinstances ALTER COLUMN id DROP DEFAULT;'); 52 | $this->addSql('ALTER TABLE addressbookchanges ALTER COLUMN id DROP DEFAULT;'); 53 | $this->addSql('ALTER TABLE principals ALTER COLUMN id DROP DEFAULT;'); 54 | $this->addSql('ALTER TABLE calendarchanges ALTER COLUMN id DROP DEFAULT;'); 55 | $this->addSql('ALTER TABLE users ALTER COLUMN id DROP DEFAULT;'); 56 | $this->addSql('ALTER TABLE calendarobjects ALTER COLUMN id DROP DEFAULT;'); 57 | $this->addSql('ALTER TABLE propertystorage ALTER COLUMN id DROP DEFAULT;'); 58 | $this->addSql('ALTER TABLE addressbooks ALTER COLUMN synctoken TYPE varchar(255);'); 59 | $this->addSql('ALTER TABLE calendars ALTER COLUMN synctoken TYPE varchar(255);'); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /translations/security.de.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 |
7 | 8 | 9 | An authentication exception occurred. 10 | Es ist eine Authentifizierungsausnahme aufgetreten. 11 | 12 | 13 | Authentication credentials could not be found. 14 | Die Authentifizierungsdaten konnten nicht gefunden werden. 15 | 16 | 17 | Authentication request could not be processed due to a system problem. 18 | Die Authentifizierungsanfrage konnte aufgrund eines Systemproblems nicht verarbeitet werden. 19 | 20 | 21 | Invalid credentials. 22 | Ungültige Zugangsdaten. 23 | 24 | 25 | Cookie has already been used by someone else. 26 | Der Cookie wurde bereits von jemand anderem verwendet. 27 | 28 | 29 | Not privileged to request the resource. 30 | Nicht berechtigt die Ressource anzufordern. 31 | 32 | 33 | Invalid CSRF token. 34 | Ungültiger CSRF-Token. 35 | 36 | 37 | No authentication provider found to support the authentication token. 38 | Es wurde kein Authentifizierungsanbieter gefunden, der den Authentifizierungstoken unterstützt. 39 | 40 | 41 | No session available, it either timed out or cookies are not enabled. 42 | Keine Sitzung verfügbar, entweder ist die Zeit abgelaufen oder die Cookies sind nicht aktiviert. 43 | 44 | 45 | No token could be found. 46 | Es wurde kein Token gefunden. 47 | 48 | 49 | Username could not be found. 50 | Benutzername wurde nicht gefunden. 51 | 52 | 53 | Account has expired. 54 | Das Konto ist abgelaufen. 55 | 56 | 57 | Credentials have expired. 58 | Die Zugangsdaten sind abgelaufen. 59 | 60 | 61 | Account is disabled. 62 | Das Konto ist deaktiviert. 63 | 64 | 65 | Account is locked. 66 | Das Konto ist gesperrt. 67 | 68 | 69 |
70 |
71 | -------------------------------------------------------------------------------- /src/Entity/Calendar.php: -------------------------------------------------------------------------------- 1 | synctoken = 1; 40 | $this->components = static::COMPONENT_EVENTS; 41 | $this->objects = new ArrayCollection(); 42 | $this->changes = new ArrayCollection(); 43 | } 44 | 45 | public function getId(): ?int 46 | { 47 | return $this->id; 48 | } 49 | 50 | public function getSynctoken(): ?string 51 | { 52 | return $this->synctoken; 53 | } 54 | 55 | public function setSynctoken(string $synctoken): self 56 | { 57 | $this->synctoken = $synctoken; 58 | 59 | return $this; 60 | } 61 | 62 | public function getComponents(): ?string 63 | { 64 | return $this->components; 65 | } 66 | 67 | public function setComponents(?string $components): self 68 | { 69 | $this->components = $components; 70 | 71 | return $this; 72 | } 73 | 74 | /** 75 | * @return Collection|CalendarObject[] 76 | */ 77 | public function getObjects(): Collection 78 | { 79 | return $this->objects; 80 | } 81 | 82 | public function addObject(CalendarObject $object): self 83 | { 84 | if (!$this->objects->contains($object)) { 85 | $this->objects[] = $object; 86 | $object->setCalendar($this); 87 | } 88 | 89 | return $this; 90 | } 91 | 92 | public function removeObject(CalendarObject $object): self 93 | { 94 | if ($this->objects->contains($object)) { 95 | $this->objects->removeElement($object); 96 | // set the owning side to null (unless already changed) 97 | if ($object->getCalendar() === $this) { 98 | $object->setCalendar(null); 99 | } 100 | } 101 | 102 | return $this; 103 | } 104 | 105 | /** 106 | * @return Collection|CalendarChange[] 107 | */ 108 | public function getChanges(): Collection 109 | { 110 | return $this->changes; 111 | } 112 | 113 | public function addChange(CalendarChange $change): self 114 | { 115 | if (!$this->changes->contains($change)) { 116 | $this->changes[] = $change; 117 | $change->setCalendar($this); 118 | } 119 | 120 | return $this; 121 | } 122 | 123 | public function removeChange(CalendarChange $change): self 124 | { 125 | if ($this->changes->contains($change)) { 126 | $this->changes->removeElement($change); 127 | // set the owning side to null (unless already changed) 128 | if ($change->getCalendar() === $this) { 129 | $change->setCalendar(null); 130 | } 131 | } 132 | 133 | return $this; 134 | } 135 | 136 | /** 137 | * @return Collection|CalendarInstance[] 138 | */ 139 | public function getInstances(): Collection 140 | { 141 | return $this->instances; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # In all environments, the following files are loaded if they exist, 2 | # the latter taking precedence over the former: 3 | # 4 | # * .env contains default values for the environment variables needed by the app 5 | # * .env.local uncommitted file with local overrides 6 | # * .env.$APP_ENV committed environment-specific defaults 7 | # * .env.$APP_ENV.local uncommitted environment-specific overrides 8 | # 9 | # Real environment variables win over .env files. 10 | # 11 | # DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES. 12 | # 13 | # Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2). 14 | # https://symfony.com/doc/current/best_practices/configuration.html#infrastructure-related-configuration 15 | 16 | ###> symfony/framework-bundle ### 17 | APP_ENV=dev 18 | APP_SECRET=630dc0d699fd37e720aff268f75583ed 19 | #TRUSTED_PROXIES=127.0.0.1,127.0.0.2 20 | #TRUSTED_HOSTS='^localhost|example\.com$' 21 | ###< symfony/framework-bundle ### 22 | 23 | APP_TIMEZONE= 24 | 25 | ###> doctrine/doctrine-bundle ### 26 | DATABASE_DRIVER=mysql # or postgresql, or sqlite 27 | # Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url 28 | # For a PostgreSQL database, use: "postgresql://db_user:db_password@127.0.0.1:5432/db_name?serverVersion=11&charset=UTF-8" 29 | # For an SQLite database, use: "sqlite:///%kernel.project_dir%/var/data.db" (without the quotes so Symfony can resolve it if it's an absolute path) 30 | DATABASE_URL="mysql://davis:davis@127.0.0.1:3306/davis?serverVersion=10.9.3-MariaDB&charset=utf8mb4" 31 | ###< doctrine/doctrine-bundle ### 32 | 33 | ###> symfony/mailer ### 34 | MAILER_DSN=smtp://localhost:465?encryption=ssl&auth_mode=login&username=&password= 35 | ###< symfony/mailer ### 36 | 37 | # The admin password for the backend 38 | ADMIN_LOGIN=admin 39 | ADMIN_PASSWORD=test 40 | # You can bypass auth entirely by setting this to "true" (case sensitive). 41 | # Useful if you use an external authorization provider such as Authelia 42 | ADMIN_AUTH_BYPASS=false 43 | 44 | # Auth Realm for HTTP auth 45 | AUTH_REALM=SabreDAV 46 | 47 | # Auth Method for the frontend 48 | # "Basic", "IMAP", or "LDAP" 49 | AUTH_METHOD=Basic 50 | 51 | # In case of IMAP Auth, you must specify the url of the mailbox in the following format host[:port]. 52 | IMAP_AUTH_URL=null 53 | IMAP_ENCRYPTION_METHOD=ssl 54 | IMAP_CERTIFICATE_VALIDATION=true 55 | IMAP_AUTH_USER_AUTOCREATE=false 56 | 57 | # In case of LDAP Auth, you must specify the url of the LDAP server 58 | # See https://www.php.net/manual/en/function.ldap-connect for more details 59 | LDAP_AUTH_URL="ldap://127.0.0.1" 60 | LDAP_DN_PATTERN="mail=%u" 61 | LDAP_MAIL_ATTRIBUTE="mail" 62 | LDAP_AUTH_USER_AUTOCREATE=false 63 | # See https://www.php.net/manual/en/ldap.constants.php#constant.ldap-opt-x-tls-require-cert 64 | # Allowed values are: never, hard, demand, allow or try. 65 | # "try" is the default if left unspecified 66 | LDAP_CERTIFICATE_CHECKING_STRATEGY="try" 67 | 68 | # Do we enable caldav and carddav ? 69 | CALDAV_ENABLED=true 70 | CARDDAV_ENABLED=true 71 | WEBDAV_ENABLED=false 72 | 73 | # Do we allow calendars to be public ? 74 | PUBLIC_CALENDARS_ENABLED=true 75 | 76 | # For Birthday calendars, what should be the reminder offset ? 77 | # (The default is PT9H, 9am on the day of the event) 78 | BIRTHDAY_REMINDER_OFFSET=PT9H 79 | 80 | # What mail is used as the sender for invites ? 81 | INVITE_FROM_ADDRESS=no-reply@example.org 82 | 83 | # Paths for WebDAV 84 | # Make sure that these directories exist, with write permissions for your server. 85 | # USE ABSOLUTE PATHS for better predictability 86 | WEBDAV_TMP_DIR='/webdav/tmp' 87 | WEBDAV_PUBLIC_DIR='/webdav/public' 88 | # By default, home directories are disabled totally (env var set to an empty string). 89 | # If needed, it is recommended to use a folder that is NOT a child of the public dir, 90 | # such as /webdav/homes for instance, so that users cannot access other users' homes. 91 | WEBDAV_HOMES_DIR= 92 | 93 | # Logging path 94 | # By default, it will log in the standard Symfony directory: var/log/prod.log (for production) 95 | # You can use /dev/null here if you want to discard logs entirely 96 | LOG_FILE_PATH="%kernel.logs_dir%/%kernel.environment%.log" 97 | 98 | # Trust the immediate proxy for X-Forwarded-* headers including HTTPS detection 99 | SYMFONY_TRUSTED_PROXIES=REMOTE_ADDR -------------------------------------------------------------------------------- /src/Entity/Principal.php: -------------------------------------------------------------------------------- 1 | delegees = new ArrayCollection(); 56 | $this->isMain = true; 57 | $this->isAdmin = false; 58 | } 59 | 60 | public function getId(): ?int 61 | { 62 | return $this->id; 63 | } 64 | 65 | public function getUri(): ?string 66 | { 67 | return $this->uri; 68 | } 69 | 70 | public function setUri(string $uri): self 71 | { 72 | $this->uri = $uri; 73 | 74 | return $this; 75 | } 76 | 77 | public function getUsername(): ?string 78 | { 79 | return str_replace(self::PREFIX, '', $this->getUri()); 80 | } 81 | 82 | public function getEmail(): ?string 83 | { 84 | return $this->email; 85 | } 86 | 87 | public function setEmail(?string $email): self 88 | { 89 | $this->email = $email; 90 | 91 | return $this; 92 | } 93 | 94 | public function getDisplayName(): ?string 95 | { 96 | return $this->displayName; 97 | } 98 | 99 | public function setDisplayName(?string $displayName): self 100 | { 101 | $this->displayName = $displayName; 102 | 103 | return $this; 104 | } 105 | 106 | /** 107 | * @return Collection|Principal[] 108 | */ 109 | public function getDelegees(): Collection 110 | { 111 | return $this->delegees; 112 | } 113 | 114 | public function addDelegee(Principal $delegee): self 115 | { 116 | if (!$this->delegees->contains($delegee)) { 117 | $this->delegees[] = $delegee; 118 | } 119 | 120 | return $this; 121 | } 122 | 123 | public function removeDelegee(Principal $delegee): self 124 | { 125 | if ($this->delegees->contains($delegee)) { 126 | $this->delegees->removeElement($delegee); 127 | } 128 | 129 | return $this; 130 | } 131 | 132 | public function removeAllDelegees(): self 133 | { 134 | $this->delegees->clear(); 135 | 136 | return $this; 137 | } 138 | 139 | public function getIsMain(): ?bool 140 | { 141 | return $this->isMain; 142 | } 143 | 144 | public function setIsMain(bool $isMain): self 145 | { 146 | $this->isMain = $isMain; 147 | 148 | return $this; 149 | } 150 | 151 | public function getIsAdmin(): ?bool 152 | { 153 | return $this->isAdmin; 154 | } 155 | 156 | public function setIsAdmin(bool $isAdmin): self 157 | { 158 | $this->isAdmin = $isAdmin; 159 | 160 | return $this; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # Initial PHP image is available here: 2 | # https://github.com/docker-library/php/blob/master/8.2/alpine3.18/fpm/Dockerfile#L33 3 | ARG fpm_user=82:82 4 | 5 | # Base image, used to build extensions and the final image ——————————————————————— 6 | FROM php:8.3-fpm-alpine AS base-image 7 | 8 | # Run update, and gets basic packages and packages for runtime 9 | RUN apk --no-progress --update add --no-cache \ 10 | curl unzip fcgi \ 11 | # These are for php-intl 12 | icu-libs \ 13 | # This one for LDAP 14 | libldap \ 15 | # This one for IMAP 16 | libzip \ 17 | # These are for GD (map image in mail) 18 | freetype \ 19 | libjpeg-turbo \ 20 | libpng \ 21 | # This is for PostgreSQL 22 | libpq 23 | 24 | # Build all extensions in a separate image ——————————————————————————————————————— 25 | FROM base-image AS extension-builder 26 | 27 | # Intl support 28 | RUN apk --update --virtual build-deps-intl add --no-cache icu-dev \ 29 | && docker-php-ext-install intl \ 30 | && apk del build-deps-intl \ 31 | && rm -rf /tmp/* 32 | 33 | # PDO: MySQL 34 | RUN docker-php-ext-configure pdo_mysql --with-pdo-mysql=mysqlnd \ 35 | && docker-php-ext-install pdo_mysql 36 | 37 | # PDO: PostgreSQL 38 | RUN apk --update --virtual build-deps-pg add --no-cache libpq-dev \ 39 | && docker-php-ext-configure pgsql -with-pgsql=/usr/local/pgsql \ 40 | && docker-php-ext-install pgsql pdo_pgsql \ 41 | && apk del build-deps-pg \ 42 | && rm -rf /tmp/* 43 | 44 | # GD (map image in mail) 45 | RUN apk --update --virtual build-deps-gd add --no-cache freetype-dev libjpeg-turbo-dev libpng-dev \ 46 | && docker-php-ext-configure gd --with-freetype \ 47 | && docker-php-ext-install gd \ 48 | && docker-php-ext-enable gd \ 49 | && apk del build-deps-gd \ 50 | && rm -rf /tmp/* 51 | 52 | # LDAP auth support 53 | RUN apk --update --virtual build-deps-ldap add --no-cache openldap-dev \ 54 | && docker-php-ext-configure ldap \ 55 | && docker-php-ext-install ldap \ 56 | && apk del build-deps-ldap \ 57 | && rm -rf /tmp/* 58 | 59 | # Zip lib for PHP-IMAP 60 | RUN apk --update --virtual build-deps-zip add --no-cache libzip-dev \ 61 | && docker-php-ext-configure zip \ 62 | && docker-php-ext-install zip \ 63 | && apk del build-deps-zip \ 64 | && rm -rf /tmp/* 65 | 66 | # OPCache 67 | RUN docker-php-ext-install opcache 68 | COPY ./docker/configurations/opcache.ini /usr/local/etc/php/conf.d/opcache.ini 69 | 70 | # Final image ———————————————————————————————————————————————————————————————————— 71 | FROM base-image 72 | 73 | ARG fpm_user=82:82 74 | ENV FPM_USER=${fpm_user} 75 | 76 | ENV PHP_OPCACHE_MEMORY_CONSUMPTION="256" \ 77 | PHP_OPCACHE_MAX_WASTED_PERCENTAGE="10" 78 | 79 | LABEL org.opencontainers.image.authors="tchap@tchap.me" 80 | LABEL org.opencontainers.image.url="https://github.com/tchapi/davis/pkgs/container/davis" 81 | LABEL org.opencontainers.image.description="A simple, fully translatable admin interface for sabre/dav based on Symfony 7 and Bootstrap 5" 82 | 83 | # Rapatriate built extensions 84 | COPY --from=extension-builder /usr/local/etc/php/conf.d /usr/local/etc/php/conf.d/ 85 | COPY --from=extension-builder /usr/local/lib/php/extensions /usr/local/lib/php/extensions/ 86 | 87 | # PHP-FPM healthcheck 88 | RUN set -xe && echo "pm.status_path = /status" >> /usr/local/etc/php-fpm.d/zz-docker.conf 89 | RUN curl https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/v0.5.0/php-fpm-healthcheck \ 90 | -o /usr/local/bin/php-fpm-healthcheck -s \ 91 | && chmod +x /usr/local/bin/php-fpm-healthcheck 92 | 93 | # Davis installation 94 | ADD --chown=${FPM_USER} . /var/www/davis 95 | WORKDIR /var/www/davis 96 | 97 | # Install Composer 2, then dependencies, compress the rather big INTL package 98 | RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer 99 | RUN APP_ENV=prod COMPOSER_ALLOW_SUPERUSER=1 composer install --no-ansi --no-dev --no-interaction --no-progress --optimize-autoloader \ 100 | && php ./vendor/symfony/intl/Resources/bin/compress 101 | 102 | # Cleanup (only useful when using --squash) 103 | RUN rm -rf /var/www/davis/docker 104 | 105 | USER $FPM_USER 106 | 107 | HEALTHCHECK --interval=30s --timeout=1s CMD php-fpm-healthcheck || exit 1 108 | -------------------------------------------------------------------------------- /tests/Functional/UserControllerTest.php: -------------------------------------------------------------------------------- 1 | loginUser($user); 16 | $client->request('GET', '/users/'); 17 | 18 | $this->assertResponseIsSuccessful(); 19 | 20 | $this->assertSelectorExists('nav.navbar'); 21 | $this->assertSelectorTextContains('h1', 'Users and Resources'); 22 | $this->assertSelectorTextContains('a.btn', '+ New User'); 23 | $this->assertSelectorTextContains('h5', 'Test User'); 24 | } 25 | 26 | public function testUserEdit(): void 27 | { 28 | $user = new AdminUser('admin', 'test'); 29 | 30 | $client = static::createClient(); 31 | $client->loginUser($user); 32 | $client->request('GET', '/users/edit/test_user'); 33 | 34 | $this->assertResponseIsSuccessful(); 35 | 36 | $this->assertSelectorTextContains('h1', 'Editing User «test_user»'); 37 | $this->assertSelectorTextContains('button#user_save', 'Save'); 38 | 39 | $client->submitForm('user_save'); 40 | 41 | $this->assertResponseRedirects('/users/'); 42 | $client->followRedirect(); 43 | 44 | $this->assertSelectorTextContains('h5', 'Test User'); 45 | } 46 | 47 | public function testUserNew(): void 48 | { 49 | $user = new AdminUser('admin', 'test'); 50 | 51 | $client = static::createClient(); 52 | $client->loginUser($user); 53 | $crawler = $client->request('GET', '/users/new'); 54 | 55 | $this->assertResponseIsSuccessful(); 56 | 57 | $this->assertSelectorTextContains('h1', 'New User'); 58 | $this->assertSelectorTextContains('button#user_save', 'Save'); 59 | 60 | $buttonCrawlerNode = $crawler->selectButton('user_save'); 61 | 62 | $form = $buttonCrawlerNode->form(); 63 | $client->submit($form, [ 64 | 'user[username]' => 'new_test_user', 65 | 'user[displayName]' => 'New test User', 66 | 'user[email]' => 'coucou@coucou.com', 67 | 'user[password][first]' => 'coucou', 68 | 'user[password][second]' => 'coucou', 69 | 'user[isAdmin]' => false, 70 | ]); 71 | 72 | $this->assertResponseRedirects('/users/'); 73 | $client->followRedirect(); 74 | 75 | $this->assertSelectorTextContains('h5', 'Test User'); 76 | $this->assertAnySelectorTextContains('h5', 'New test User'); 77 | } 78 | 79 | public function testUserDelete(): void 80 | { 81 | $user = new AdminUser('admin', 'test'); 82 | 83 | $client = static::createClient(); 84 | $client->loginUser($user); 85 | $client->request('GET', '/users/delete/test_user'); 86 | 87 | $this->assertResponseRedirects('/users/'); 88 | $client->followRedirect(); 89 | 90 | $this->assertSelectorTextContains('div#no-user', 'No users yet.'); 91 | } 92 | 93 | public function testUserDelegates(): void 94 | { 95 | $user = new AdminUser('admin', 'test'); 96 | 97 | $client = static::createClient(); 98 | $client->loginUser($user); 99 | $client->request('GET', '/users/delegates/test_user'); 100 | 101 | $this->assertResponseIsSuccessful(); 102 | 103 | $this->assertSelectorExists('nav.navbar'); 104 | $this->assertSelectorTextContains('h1', 'Delegates for Test User'); 105 | $this->assertSelectorTextContains('a.btn', '+ Add a delegate'); 106 | $this->assertAnySelectorTextContains('div', 'Delegation is enabled for this account.'); 107 | $this->assertAnySelectorTextContains('a.btn', 'Disable it'); 108 | 109 | $client->clickLink('Disable it'); 110 | 111 | $this->assertResponseRedirects('/users/delegates/test_user'); 112 | $client->followRedirect(); 113 | 114 | $this->assertSelectorExists('nav.navbar'); 115 | $this->assertSelectorTextContains('h1', 'Delegates for Test User'); 116 | $this->assertSelectorTextNotContains('a.btn', '+ Add a delegate'); 117 | $this->assertAnySelectorTextContains('div', 'Delegation is not enabled for this account.'); 118 | $this->assertAnySelectorTextContains('a.btn', 'Enable it'); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /templates/dashboard.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | {% set menu = 'dashboard' %} 3 | 4 | {% block body %} 5 | 6 |

{{ "title.dashboard"|trans }}

7 | 8 |
9 |
10 |

{{ "dashboard.capabilities"|trans }}

11 | 12 | 37 | 38 |

{{ "dashboard.env"|trans }}

39 | 40 | 53 |
54 | 55 |
56 |

{{ "dashboard.objects"|trans }}

57 | 58 | 75 |
76 |
77 | 78 | {% endblock %} -------------------------------------------------------------------------------- /src/Entity/CalendarObject.php: -------------------------------------------------------------------------------- 1 | id; 53 | } 54 | 55 | public function getCalendarData(): ?string 56 | { 57 | return $this->calendarData; 58 | } 59 | 60 | public function setCalendarData(?string $calendarData): self 61 | { 62 | $this->calendarData = $calendarData; 63 | 64 | return $this; 65 | } 66 | 67 | public function getUri(): ?string 68 | { 69 | return $this->uri; 70 | } 71 | 72 | public function setUri(?string $uri): self 73 | { 74 | $this->uri = $uri; 75 | 76 | return $this; 77 | } 78 | 79 | public function getCalendar(): ?Calendar 80 | { 81 | return $this->calendar; 82 | } 83 | 84 | public function setCalendar(?Calendar $calendar): self 85 | { 86 | $this->calendar = $calendar; 87 | 88 | return $this; 89 | } 90 | 91 | public function getLastModifier(): ?int 92 | { 93 | return $this->lastModifier; 94 | } 95 | 96 | public function setLastModifier(?int $lastModifier): self 97 | { 98 | $this->lastModifier = $lastModifier; 99 | 100 | return $this; 101 | } 102 | 103 | public function getEtag(): ?string 104 | { 105 | return $this->etag; 106 | } 107 | 108 | public function setEtag(?string $etag): self 109 | { 110 | $this->etag = $etag; 111 | 112 | return $this; 113 | } 114 | 115 | public function getSize(): ?int 116 | { 117 | return $this->size; 118 | } 119 | 120 | public function setSize(int $size): self 121 | { 122 | $this->size = $size; 123 | 124 | return $this; 125 | } 126 | 127 | public function getComponentType(): ?string 128 | { 129 | return $this->componentType; 130 | } 131 | 132 | public function setComponentType(?string $componentType): self 133 | { 134 | $this->componentType = $componentType; 135 | 136 | return $this; 137 | } 138 | 139 | public function getFirstOccurence(): ?int 140 | { 141 | return $this->firstOccurence; 142 | } 143 | 144 | public function setFirstOccurence(?int $firstOccurence): self 145 | { 146 | $this->firstOccurence = $firstOccurence; 147 | 148 | return $this; 149 | } 150 | 151 | public function getLastOccurence(): ?int 152 | { 153 | return $this->lastOccurence; 154 | } 155 | 156 | public function setLastOccurence(?int $lastOccurence): self 157 | { 158 | $this->lastOccurence = $lastOccurence; 159 | 160 | return $this; 161 | } 162 | 163 | public function getUid(): ?string 164 | { 165 | return $this->uid; 166 | } 167 | 168 | public function setUid(?string $uid): self 169 | { 170 | $this->uid = $uid; 171 | 172 | return $this; 173 | } 174 | 175 | public function getLastModified(): ?int 176 | { 177 | return $this->lastModified; 178 | } 179 | 180 | public function setLastModified(?int $lastModified): self 181 | { 182 | $this->lastModified = $lastModified; 183 | 184 | return $this; 185 | } 186 | } 187 | --------------------------------------------------------------------------------