Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ API_KEY=
# When false, the app keys page, the app-key commands and the app key login are not available.
APP_KEYS_ENABLED=false

# Update check
# Davis does not phone home. Set this to true to have the Diagnostics page ask GitHub, at most
# once a day, whether a newer release exists, and link to its release notes.
UPDATE_CHECK_ENABLED=false

# Logging path
# By default, it will log in the standard Symfony directory: var/log/prod.log (for production)
# You can use /dev/null here if you want to discard logs entirely
Expand Down
120 changes: 116 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Dependencies
b. If you plan to run Davis on production

```
composer install --no-dev
composer install --no-dev --optimize-autoloader
```

And set `APP_ENV=prod` in your `.env.local` file (see below)
Expand Down Expand Up @@ -191,6 +191,14 @@ It works with every `AUTH_METHOD` and never contacts the backend: a wrong secret

The secret is shown once at creation and stored hashed. Keep it on the server side, never in a browser, and revoke the key (dashboard, or `davis:app-key:revoke <app_id>`) when the service goes away; an expiry date can be set when the access is meant to be temporary. This is the use case of [#183](https://github.com/tchapi/davis/issues/183).

**Update check**

```shell
UPDATE_CHECK_ENABLED=false
```

Davis does not phone home. Set `UPDATE_CHECK_ENABLED=true` to have the [diagnostics page](#start-with-the-diagnostics-page) ask GitHub whether a newer release exists, and link to its release notes when there is one. The dashboard then counts it among the points that need your attention. GitHub (`api.github.com`) is asked at most once a day, anonymously, and only when you open the dashboard; nothing about your install is sent. If GitHub cannot be reached, the check says so and is tried again an hour later.

**d. The global flags to enable CalDAV, CardDAV and WebDAV**. You can also disable the option to have calendars public

```shell
Expand Down Expand Up @@ -417,6 +425,105 @@ If you're migrating from Baïkal, then you will likely want to do the following
> [!NOTE]
> Some details / steps to resolve are also available in https://github.com/tchapi/davis/issues/226.

## Updating (without Docker)

Davis is not distributed as a bundled archive: the release assets are the plain source tree, without `vendor/`. Updating therefore means fetching the new code and re-running the same steps as the installation. The whole procedure takes a minute; **the part that matters is reading the release notes**.

> [!CAUTION]
> **Do not automate updates with cron.** Some releases need a human: v5.3.0 changed the `IMAP_AUTH_URL` format, v5.4.0 changed the dashboard URLs, and major versions (v3, v4, v5) each had their own migration notes. Every release has a *How to upgrade* section on [the releases page](https://github.com/tchapi/davis/releases) — read it first.

### 0. Prerequisites

- Run all commands below **as the user that owns the Davis directory** (ideally the same user PHP-FPM runs as). Running them as `root` leaves `var/cache` and `vendor/` unreadable or unwritable for the web server.
- If you keep your configuration in the web server (`SetEnv`, `fastcgi_param`) or in another folder (`ENV_DIR`) rather than in `.env.local`, export the same values for the shell session, otherwise the CLI falls back to the `dev` defaults of `.env`:

```shell
export APP_ENV=prod
export ENV_DIR=/var/lib/davis # only if you use it
# export DATABASE_DRIVER=... DATABASE_URL=... if they are not in an env file either
```

- Check where you are: `git describe --tags` (git install).

### 1. Back up the database

Migrations can go wrong (even though they are generally reversible). Back up **before** touching anything:

```shell
# MySQL / MariaDB
mysqldump -u davis -p davis > davis-$(date +%F).sql
# PostgreSQL
pg_dump -U davis davis > davis-$(date +%F).sql
# SQLite (safe while the server is running)
sqlite3 var/data.db ".backup davis-$(date +%F).db"
```

Also keep a copy of `.env.local` if you have one.

### 2. Update the code

**a. If you cloned the repository**

```shell
cd /var/www/davis
git status # must be clean: local edits are not supported and may block the checkout
git fetch --tags
git checkout vX.Y.Z # always a release tag — never `main`, which is the unstable edge branch
```

To see the newest tag: `git tag --sort=-v:refname | head -n 1`. `.env.local`, `var/` and `vendor/` are untracked and survive the checkout.

**b. If you installed from a release archive**

Unpack the new archive **next to** the current install, not over it, then move your state into it:

```shell
cd /var/www
tar xzf vX.Y.Z.tar.gz # creates davis-X.Y.Z/
cp davis/.env.local davis-X.Y.Z/ # if you use one
mv davis/var davis-X.Y.Z/var # logs, cache, and the SQLite database if you use the default path
mv davis davis-old && mv davis-X.Y.Z davis
```

### 3. Install the dependencies

```shell
composer install --no-dev --optimize-autoloader
```

This also clears the cache and installs the public assets (`cache:clear` and `assets:install` run automatically after install, see `composer.json`). Use plain `composer install` only for a development setup with `APP_ENV=dev`.

### 4. Migrate the database

```shell
bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
```

`--allow-no-migration` makes the command succeed when the release contains no migration, so the same command works for every update.

> [!NOTE]
> Coming from **before v3.0.0**? Follow the v3.0.0 release notes first: `doctrine:migrations:migrate` alone will not work.

### 5. Reload PHP (OPcache)

If `opcache.validate_timestamps=0` in your PHP configuration (a common production setting), the old code stays in memory until PHP-FPM is reloaded:

```shell
sudo systemctl reload php8.4-fpm # adjust to your PHP version / service name
```

### 6. Check

Open `/dashboard/diagnostics`. It reports pending migrations, the database engine in use and the state of each endpoint — if everything is green, you're done. The log, if any, is in `var/log/prod.log`.

### Rolling back

Code: `git checkout <previous tag>` (or swap the directories back for an archive install), then `composer install --no-dev --optimize-autoloader` again. Database: restore the backup from step 1.

### Being notified of new releases

On GitHub, use *Watch → Custom → Releases*, or subscribe to the feed at `https://github.com/tchapi/davis/releases.atom`. Alternatively, set `UPDATE_CHECK_ENABLED=true` (see [Configuration](#configuration)) and the diagnostics page tells you when a newer release exists. Davis never checks on its own unless you turn that on, and never installs anything.

# 🌐 Access / Webserver

A simple status page is available on the root `/` of the server.
Expand Down Expand Up @@ -514,7 +621,7 @@ server {
}

location ~ ^/index\.php(/|$) {
fastcgi_pass unix:/var/run/php/php7.2-fpm.sock; # Change for your PHP version
fastcgi_pass unix:/var/run/php/php8.4-fpm.sock; # Change for your PHP version
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;

Expand Down Expand Up @@ -628,6 +735,9 @@ You can start the containers with :

### Updating from a previous version

> [!TIP]
> For installations without Docker, see [Updating (without Docker)](#updating-without-docker)

If you update the code, you need to make sure the database structure is in sync.

**Before v3.0.0**, you need to force the update:
Expand All @@ -636,7 +746,7 @@ If you update the code, you need to make sure the database structure is in sync.

**For v3.0.0 and after**, you can just migrate again (_provided you correctly followed the migration notes in the v3.0.0 release_):

docker exec -it davis sh -c "APP_ENV=prod bin/console doctrine:migrations:migrate --no-interaction"
docker exec -it davis sh -c "APP_ENV=prod bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration"


Then, head up to `http://<YOUR_DOCKER_IP>:9000` to see the status display :
Expand Down Expand Up @@ -695,7 +805,9 @@ The dashboard tells you how many points need your attention and links to **Diagn

It groups the checks into Runtime, Database, Authentication, Scheduling and mail, and Endpoints, and each one that needs action says what to do about it and gives the command or query to run. Among other things it reports migrations that have not been run, the database engine actually in use, whether the log directory is writable, whether the selected authentication method has the PHP extension it needs, and how many accounts have no email address and therefore never send an invitation.

Everything on it is read-only and passive — no connection is opened, so it stays usable when something is down. Credentials are never shown: the mail transport is displayed as scheme and host only. Use `bin/console davis:mail:test you@example.org` to actually exercise the mailer.
Everything on it is read-only and passive — no connection is opened, so it stays usable when something is down. The only exception is the optional update check (`UPDATE_CHECK_ENABLED=true`), which asks GitHub at most once a day whether a newer release exists, and degrades to "unknown" when it cannot.

Credentials are never shown: the mail transport is displayed as scheme and host only. Use `bin/console davis:mail:test you@example.org` to actually exercise the mailer.

### Logs

Expand Down
6 changes: 6 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ parameters:
default_webdav_public_dir_writable: "false"
app_keys_enabled: "%env(bool:default:default_app_keys_enabled:APP_KEYS_ENABLED)%"
default_app_keys_enabled: "false"
update_check_enabled: "%env(bool:default:default_update_check_enabled:UPDATE_CHECK_ENABLED)%"
default_update_check_enabled: "false"

services:
# default configuration for services in *this* file
Expand Down Expand Up @@ -60,6 +62,10 @@ services:
$appKeysEnabled: "%app_keys_enabled%"
$adminAuthBypass: "%admin_auth_bypass%"

App\Services\UpdateChecker:
arguments:
$enabled: "%update_check_enabled%"

App\Command\MailTestCommand:
arguments:
$inviteAddress: "%env(INVITE_FROM_ADDRESS)%"
Expand Down
3 changes: 3 additions & 0 deletions docker/.env
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ WEBDAV_HOMES_DIR=
# App keys (see the README)
APP_KEYS_ENABLED=false

# Update check: ask GitHub once a day whether a newer release exists (see the README)
UPDATE_CHECK_ENABLED=false

# Mail settings
INVITE_FROM_ADDRESS=no-reply@example.org
MAIL_HOST=smtp.myprovider.com
Expand Down
29 changes: 29 additions & 0 deletions src/Services/Diagnostics.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
*
* Every check is passive: the page has to stay usable precisely when something is down, so it
* opens no socket and runs no migration. `davis:mail:test` is the active counterpart for mail.
*
* The one exception is the update check, which is opt-in and asks GitHub at most once a day
* (see UpdateChecker); the page stays usable when GitHub is not reachable.
*/
final class Diagnostics
{
Expand All @@ -26,6 +29,7 @@ public function __construct(
private Connection $connection,
private DependencyFactory $migrations,
private UrlGeneratorInterface $router,
private UpdateChecker $updateChecker,
private string $environment,
private bool $debug,
private string $logFilePath,
Expand All @@ -51,6 +55,7 @@ public function buckets(): array
$buckets = [
'diagnostics.bucket.runtime' => [
$this->versions(),
$this->update(),
$this->environment(),
$this->timezone(),
$this->logFile(),
Expand Down Expand Up @@ -118,6 +123,30 @@ private function versions(): array
return $this->check(self::INFO, 'diagnostics.versions', sprintf('Davis %s · sabre/dav %s · PHP %s', \App\Version::VERSION, \Sabre\DAV\Version::VERSION, PHP_VERSION));
}

/**
* Releases are easy to miss on a self-hosted install, and the release notes are where the
* migration to run and any change of behaviour are announced. Off by default: see UpdateChecker.
*/
private function update(): array
{
if (!$this->updateChecker->isEnabled()) {
return $this->check(self::INFO, 'diagnostics.update', 'diagnostics.value.disabled', 'diagnostics.update.disabled', 'UPDATE_CHECK_ENABLED=true', UpdateChecker::RELEASES_URL);
}

$latest = $this->updateChecker->latestRelease();
if (null === $latest) {
return $this->check(self::INFO, 'diagnostics.update', 'diagnostics.value.unknown', 'diagnostics.update.unreachable', null, UpdateChecker::RELEASES_URL);
}

$current = 'v'.UpdateChecker::normalize($this->updateChecker->currentVersion());

if ($this->updateChecker->isOutdated($latest['version'])) {
return $this->check(self::WARNING, 'diagnostics.update', sprintf('%s → v%s', $current, $latest['version']), 'diagnostics.update.available', null, $latest['url']);
}

return $this->check(self::OK, 'diagnostics.update', $current, 'diagnostics.update.current', null, $latest['url']);
}

private function environment(): array
{
$value = $this->environment.($this->debug ? ' (debug)' : '');
Expand Down
117 changes: 117 additions & 0 deletions src/Services/UpdateChecker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

namespace App\Services;

use App\Version;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
* Looks up the latest Davis release on GitHub so the diagnostics page can say when an install is
* behind.
*
* Opt-in (`UPDATE_CHECK_ENABLED`), because self-hosted software should not phone home unless
* asked to. When enabled, GitHub is asked at most once a day, and a failed lookup is remembered
* for an hour so an unreachable network does not slow every dashboard load.
*/
final class UpdateChecker
{
public const RELEASES_URL = 'https://github.com/tchapi/davis/releases';

private const LATEST_RELEASE_URL = 'https://api.github.com/repos/tchapi/davis/releases/latest';
private const CACHE_KEY = 'davis.update_check.latest_release';
private const TTL = 86400;
private const TTL_AFTER_FAILURE = 3600;
private const TIMEOUT = 3.0;

public function __construct(
private HttpClientInterface $httpClient,
private CacheInterface $cache,
private bool $enabled = false,
private string $currentVersion = Version::VERSION,
) {
}

public function isEnabled(): bool
{
return $this->enabled;
}

public function currentVersion(): string
{
return $this->currentVersion;
}

/**
* The latest published release, or null when it could not be looked up (offline, rate
* limited, unexpected payload). Both outcomes are cached, with different lifetimes.
*
* @return array{version: string, url: string}|null
*/
public function latestRelease(): ?array
{
$cached = $this->cache->get(self::CACHE_KEY, function (ItemInterface $item): array {
try {
$release = $this->fetchLatestRelease();
} catch (\Throwable $e) {
$item->expiresAfter(self::TTL_AFTER_FAILURE);

return ['error' => $e->getMessage()];
}

$item->expiresAfter(self::TTL);

return $release;
});

if (!is_array($cached) || !isset($cached['version'], $cached['url'])) {
return null;
}

return ['version' => (string) $cached['version'], 'url' => (string) $cached['url']];
}

/**
* Whether the running version is behind the given one. Tags carry a `v` prefix, the
* constant does not.
*/
public function isOutdated(string $latestVersion): bool
{
return version_compare(self::normalize($this->currentVersion), self::normalize($latestVersion), '<');
}

public static function normalize(string $version): string
{
return ltrim(trim($version), 'vV');
}

/**
* @return array{version: string, url: string}
*/
private function fetchLatestRelease(): array
{
$response = $this->httpClient->request('GET', self::LATEST_RELEASE_URL, [
'headers' => [
'Accept' => 'application/vnd.github+json',
'User-Agent' => 'Davis/'.$this->currentVersion,
],
'timeout' => self::TIMEOUT,
'max_duration' => 2.0 * self::TIMEOUT,
]);

$release = $response->toArray();
$version = self::normalize((string) ($release['tag_name'] ?? ''));

if (!preg_match('/^\d+(\.\d+)*/', $version)) {
throw new \UnexpectedValueException(sprintf('Unexpected tag name "%s"', $release['tag_name'] ?? ''));
}

$url = (string) ($release['html_url'] ?? '');

return [
'version' => $version,
'url' => str_starts_with($url, 'https://github.com/') ? $url : self::RELEASES_URL,
];
}
}
2 changes: 1 addition & 1 deletion templates/diagnostics.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
<pre class="mb-0 mt-2 p-2 small bg-body-tertiary rounded"><code>{{ check.command }}</code></pre>
{% endif %}
{% if check.link %}
<a class="small d-inline-block mt-2" href="{{ check.link }}">{{ "diagnostics.go_there"|trans }}</a>
<a class="small d-inline-block mt-2" href="{{ check.link }}"{% if check.link starts with 'http' %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ "diagnostics.go_there"|trans }}</a>
{% endif %}
</li>
{% endfor %}
Expand Down
Loading