mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 09:05:08 +00:00
6e47d76ba6
Client file sharing, rebuilt from the ground up: a private area per client, resumable uploads, folders, groups and categories, sharing with expiry dates and download limits, comments, file versions, an activity log, a REST API, and sixteen languages. This repository begins here. ProjectSend 2 was developed privately, and that development history is not published — the previous generation remains available, with its own history, at projectsend/legacy. Free software under the GNU General Public License v2, or (at your option) any later version.
186 lines
6.8 KiB
PHP
186 lines
6.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Platform\Storage;
|
|
|
|
use App\Modules\Platform\Installation\Installation;
|
|
use App\Modules\Platform\Installation\InstallationKind;
|
|
use App\Modules\Platform\Settings\ExternalStorageConfigApplier;
|
|
|
|
/**
|
|
* Where the uploads directory really lives when ProjectSend runs in a
|
|
* container — and therefore what would survive losing the container, or
|
|
* losing Docker itself.
|
|
*
|
|
* Containers are meant to be thrown away and rebuilt. That is only safe if
|
|
* the files are somewhere else, and whether they are is invisible from the
|
|
* screen: an installation whose uploads sit on the container's own
|
|
* filesystem looks exactly like a correct one right up until the first
|
|
* `docker compose up --build`, at which point every file a client ever
|
|
* received is gone. This class is how the dashboard can say which of those
|
|
* two installations it is looking at, instead of the operator finding out
|
|
* afterwards.
|
|
*
|
|
* It reads /proc/self/mountinfo, which the app user can read, and resolves
|
|
* the longest mount point that is a prefix of the uploads path. Three
|
|
* outcomes, and they are genuinely different risks:
|
|
*
|
|
* - the mount is the container root itself (an overlay filesystem)
|
|
* => Ephemeral. Recreating the container destroys the files.
|
|
* - the mount's source is under /docker/volumes/<name>/_data
|
|
* => DockerVolume. Survives upgrades and `docker compose down`, but
|
|
* lives inside Docker: `down -v`, `docker volume prune`, or losing
|
|
* the Docker installation takes it, and it is easy to leave out of
|
|
* a backup because it is nowhere the operator chose.
|
|
* - anything else (a bind mount from the host)
|
|
* => Durable. The files are on a path outside Docker's control.
|
|
*
|
|
* The database deliberately gets no equivalent check. It runs in its own
|
|
* container, in its own mount namespace, so this process cannot see its
|
|
* mounts — and the only way to change that is to hand the Docker socket to
|
|
* PHP, which would turn any vulnerability in this application into root on
|
|
* the host. DOCKER.md covers that half for a human instead.
|
|
*/
|
|
class StorageDurability
|
|
{
|
|
public function __construct(
|
|
private readonly ExternalStorageConfigApplier $externalStorage,
|
|
private readonly Installation $installation,
|
|
) {}
|
|
|
|
/**
|
|
* Null when the question does not apply: not running in a container
|
|
* (a plain server's disk layout is the operator's own business, and
|
|
* nothing here would be news), or uploads no longer go to the local
|
|
* disk at all because external object storage is switched on.
|
|
*
|
|
* @return array{level: string, volume: string|null, source: string|null}|null
|
|
*/
|
|
public function inspect(): ?array
|
|
{
|
|
if (! $this->inContainer() || $this->externalStorage->isActive()) {
|
|
return null;
|
|
}
|
|
|
|
$mount = $this->mountFor($this->existingAncestorOf(storage_path('app/files')));
|
|
|
|
if ($mount === null) {
|
|
return ['level' => StorageDurabilityLevel::Unknown->value, 'volume' => null, 'source' => null];
|
|
}
|
|
|
|
[$mountPoint, $source] = $mount;
|
|
|
|
if ($mountPoint === '/') {
|
|
return ['level' => StorageDurabilityLevel::Ephemeral->value, 'volume' => null, 'source' => null];
|
|
}
|
|
|
|
if (preg_match('#(?:^|/)docker/volumes/([^/]+)/_data#', $source, $matches) === 1) {
|
|
return [
|
|
'level' => StorageDurabilityLevel::DockerVolume->value,
|
|
'volume' => $matches[1],
|
|
'source' => null,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'level' => StorageDurabilityLevel::Durable->value,
|
|
'volume' => null,
|
|
// Shown as a hint, never as an authoritative host path: the
|
|
// source is relative to the source filesystem's own root, so a
|
|
// host /home/x/app bind-mounted from a separate /home partition
|
|
// reads as "/x/app" here.
|
|
'source' => $source,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Protected rather than private so a test can say "pretend we are in a
|
|
* container" without a container. Same for readMountInfo() below: those
|
|
* two are the only things this class learns from outside itself.
|
|
*/
|
|
protected function inContainer(): bool
|
|
{
|
|
// Delegated rather than duplicated: "are we in a container" is now
|
|
// asked in two unrelated places (here, and to decide which upgrade
|
|
// instructions to print), and two copies of that check would drift.
|
|
return $this->installation->kind() === InstallationKind::Container;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>|null
|
|
*/
|
|
protected function readMountInfo(): ?array
|
|
{
|
|
$lines = @file('/proc/self/mountinfo', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
|
|
|
return $lines === false ? null : $lines;
|
|
}
|
|
|
|
/**
|
|
* The path itself if it exists, otherwise its nearest existing parent —
|
|
* the uploads directory is created on first write, and a brand-new
|
|
* installation should still get an answer.
|
|
*/
|
|
private function existingAncestorOf(string $path): string
|
|
{
|
|
while ($path !== '/' && ! is_dir($path)) {
|
|
$path = dirname($path);
|
|
}
|
|
|
|
return $path;
|
|
}
|
|
|
|
/**
|
|
* The mount whose mount point is the longest prefix of $path.
|
|
*
|
|
* @return array{0: string, 1: string}|null [mount point, source root]
|
|
*/
|
|
private function mountFor(string $path): ?array
|
|
{
|
|
$lines = $this->readMountInfo();
|
|
|
|
if ($lines === null) {
|
|
return null;
|
|
}
|
|
|
|
$best = null;
|
|
|
|
foreach ($lines as $line) {
|
|
$fields = explode(' ', trim($line));
|
|
|
|
// 3 = root of the mount within its source filesystem,
|
|
// 4 = mount point. Both are octal-escaped for spaces and tabs.
|
|
if (! isset($fields[3], $fields[4])) {
|
|
continue;
|
|
}
|
|
|
|
$source = $this->unescape($fields[3]);
|
|
$mountPoint = $this->unescape($fields[4]);
|
|
|
|
$isPrefix = $mountPoint === '/'
|
|
|| $path === $mountPoint
|
|
|| str_starts_with($path, rtrim($mountPoint, '/').'/');
|
|
|
|
if (! $isPrefix) {
|
|
continue;
|
|
}
|
|
|
|
// >= rather than >: later entries win ties, and mountinfo is
|
|
// ordered so that a mount stacked over an earlier one at the
|
|
// same point comes later. Without this, a bind mount placed
|
|
// over the container root would be reported as the root.
|
|
if ($best === null || strlen($mountPoint) >= strlen($best[0])) {
|
|
$best = [$mountPoint, $source];
|
|
}
|
|
}
|
|
|
|
return $best;
|
|
}
|
|
|
|
private function unescape(string $value): string
|
|
{
|
|
return str_replace(['\040', '\011', '\012', '\134'], [' ', "\t", "\n", '\\'], $value);
|
|
}
|
|
}
|