Implement trashbin support

This commit is contained in:
bohwaz 2023-09-11 16:38:07 +02:00
parent 6e51f11e9d
commit 18bb00057b
10 changed files with 325 additions and 19 deletions

View file

@ -35,6 +35,7 @@ If you are looking for an even lighter WebDAV server, try also our other server,
* Passes most of the [Litmus compliance tests](https://github.com/tolsen/litmus) (see below)
* Supports WOPI, for editing and viewing of documents using OnlyOffice, Collabora Online or MS Office.
* Support for LDAP
* Trashbin: files are moved to a `.trash` folder before being deleted completely
* Good performance!
### NextCloud/ownCloud features
@ -45,6 +46,7 @@ The following ownCloud/NextCloud specific features are supported:
* [Direct download](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-api-overview.html#direct-download)
* [Chunk upload](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/chunking.html)
* [Trashbin](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/trashbin.html)
* `X-OC-MTime` [header](https://gitlab.gnome.org/GNOME/gvfs/-/issues/637) to set file modification time
* `OC-Checksum` [header](https://github.com/owncloud-archive/documentation/issues/2964) to verify file upload integrity
* Login via app-specific passwords (necessary for NextCloud desktop and Android clients)
@ -106,8 +108,7 @@ Here is a list of clients tested with KaraDAV:
This might get supported in future (maybe):
* Probably: [NextCloud Trashbin](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/trashbin.html)
* Maybe: [NextCloud sharing](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-share-api.html)
* Probably: [NextCloud sharing](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-share-api.html)
* Maybe: NextCloud files versioning
* [NextCloud API](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/versions.html)
* [NextCloud versioning pattern](https://docs.nextcloud.com/server/latest/user_manual/en/files/version_control.html)

View file

@ -16,6 +16,13 @@ namespace KaraDAV;
*/
const DEFAULT_QUOTA = 200;
/**
* Default delay after which files should be deleted from the trashbin
* (in seconds)
* Set to zero (0) to disable the trashbin (files will be deleted directly)
*/
const DEFAULT_TRASHBIN_DELAY = 60*60*24*30; // 15 days
/**
* Users file storage path
* %s is replaced by the login name of the user

View file

@ -1,4 +1,8 @@
## 0.3.13 - September 10, 2023
## 0.4.0 - September 11, 2023
* Implement trashbin support, compatible with NextCloud API
## 0.3.13
* Use files inodes as file ID, so that we keep the same id when the file is moved or renamed.
* Fix a bug in the NextCloud Android app, where the "Plus" button was disabled, because the NextCloud app doesn't respect the NextCloud spec.

View file

@ -52,6 +52,10 @@ abstract class NextCloud
// in Android app
const PROP_NC_RICH_WORKSPACE = self::NC_NAMESPACE . ':rich-workspace';
const PROP_NC_TRASHBIN_FILENAME = self::NC_NAMESPACE . ':trashbin-filename';
const PROP_NC_TRASHBIN_ORIGINAL_LOCATION = self::NC_NAMESPACE . ':trashbin-original-location';
const PROP_NC_TRASHBIN_DELETION_TIME = self::NC_NAMESPACE . ':trashbin-deletion-time';
// Useless?
const PROP_OC_SHARETYPES = self::OC_NAMESPACE . ':share-types';
const PROP_NC_NOTE = self::NC_NAMESPACE . ':note';
@ -238,6 +242,10 @@ abstract class NextCloud
// There's just 3 or 4 different endpoints for avatars, this is ridiculous
'remote.php/dav/avatars/' => 'avatar',
// Trasbin API
// https://docs.nextcloud.com/server/19/developer_manual/client_apis/WebDAV/trashbin.html
'remote.php/dav/trashbin/' => 'trashbin',
// Main routes
'remote.php/webdav/' => 'webdav', // desktop client
'remote.php/dav' => 'webdav', // android client
@ -526,7 +534,7 @@ abstract class NextCloud
// https://github.com/owncloud/client/blob/24ca9615f6e8ea765f6c25fb4e009b1acc262a2d/src/libsync/capabilities.cpp#L166
'bigfilechunking' => true,
'comments' => false,
'undelete' => false,
'undelete' => in_array(TrashInterface::class, class_implements($this->storage)),
'versioning' => false,
],
'files_sharing' => [
@ -786,6 +794,10 @@ abstract class NextCloud
$dir = $match[2] ?? null;
$part = $match[3] ?? null;
if ($login !== $user) {
throw new Exception('Invalid username in URL, does not match logged user', 403);
}
if ($method == 'MKCOL') {
http_response_code(201);
}
@ -858,4 +870,68 @@ abstract class NextCloud
throw new Exception('Invalid method for chunked upload', 400);
}
}
protected function nc_trashbin(string $uri): ?string
{
$this->requireAuth();
$r = '!^remote\.php/dav/trashbin/([^/]+)/trash/([^/]*)$!';
if (!preg_match($r, $uri, $match)) {
throw new Exception('Invalid URL for trashbin API', 400);
}
$method = $_SERVER['REQUEST_METHOD'] ?? null;
$login = $match[1] ?? null;
$path = $match[2] ?? null;
if ($method === 'DELETE' && empty($path)) {
$this->storage->emptyTrash();
http_response_code(204);
}
elseif ($method === 'DELETE') {
$this->storage->deleteFromTrash($path);
http_response_code(204);
}
elseif ($method === 'MOVE' && !empty($path)) {
$this->storage->restoreFromTrash($path);
http_response_code(201);
}
elseif ($method === 'PROPFIND') {
header('HTTP/1.1 207 Multi-Status', true);
header('Content-Type: text/xml; charset=utf-8', true);
$out = '<?xml version="1.0" encoding="utf-8"?>' . PHP_EOL;
$out .= '<d:multistatus xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns" xmlns:oc="http://owncloud.org/ns">' . PHP_EOL;
$out .= sprintf('<d:response><d:href>%s</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat><d:propstat><d:prop><nc:trashbin-deletion-time/><d:getcontenttype/><oc:size/><oc:id/><d:getcontentlength/><nc:trashbin-filename/><nc:trashbin-original-location/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response>', $match[0]);
foreach ($this->storage->listTrashFiles() as $file => $props) {
$out .= '<d:response>' . PHP_EOL;
$path = '/' . trim($uri, '/') . '/' . rawurlencode($file);
$out .= sprintf('<d:href>%s</d:href>', htmlspecialchars($path, ENT_XML1)) . PHP_EOL;
$out .= '<d:propstat><d:prop>';
foreach ($props as $key => $value) {
$pos = strrpos($key, ':');
$ns = substr($key, 0, $pos);
$tag = substr($key, $pos + 1);
$out .= sprintf('<%s xmlns="%s">%s</%1$s>', $tag, $ns, htmlspecialchars($value, ENT_XML1));
}
$out .= '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>' . PHP_EOL;
$out .= '</d:response>' . PHP_EOL;
}
$out .= '</d:multistatus>';
echo $out;
$this->server->log("=> Body:\n%s", $out); }
else {
throw new Exception('Invalid method for trashbin', 400);
}
return null;
}
}

View file

@ -118,7 +118,7 @@ class Server
public function setBaseURI(string $uri): void
{
$this->base_uri = '/' . ltrim($uri, '/');
$this->base_uri = ltrim($uri, '/');
$this->base_uri = rtrim($this->base_uri, '/') . '/';
}

View file

@ -0,0 +1,50 @@
<?php
namespace KD2\WebDAV;
interface TrashInterface
{
/**
* Restore an URI (relative to the root of the trashbin) to its original location
*/
public function restoreFromTrash(string $uri): void;
/**
* Move an URI to the trashbin
*/
public function moveToTrash(string $uri): void;
/**
* Delete everything from trashbin
*/
public function emptyTrash(): void;
/**
* Delete an URI from the trashbin
* @param string $uri Path, relative to the trashbin directory
*/
public function deleteFromTrash(string $uri): void;
/**
* Delete old files from trashbin
* @param int $delete_before_timestamp UNIX timestamp representing the date before which all trashed files must be deleted
* @return int number of deleted files
*/
public function pruneTrash(int $delete_before_timestamp): int;
/**
* List all files in trash
*
* @return iterable Each item having the file name (URI) as the key
* and an array of these PROPFIND properties:
* - {http://nextcloud.org/ns}trashbin-deletion-time
* - {http://nextcloud.org/ns}trashbin-original-location
* - {http://nextcloud.org/ns}trashbin-filename
* - {dav:}getcontenttype
* - {dav:}getcontentlength
* - {dav:}resourcetype
* - {http://owncloud.org/ns}size
* - {http://owncloud.org/ns}id
*/
public function listTrashFiles(): iterable;
}

View file

@ -3,10 +3,11 @@
namespace KaraDAV;
use KD2\WebDAV\AbstractStorage;
use KD2\WebDAV\TrashInterface;
use KD2\WebDAV\WOPI;
use KD2\WebDAV\Exception as WebDAV_Exception;
class Storage extends AbstractStorage
class Storage extends AbstractStorage implements TrashInterface
{
protected Users $users;
protected NextCloud $nextcloud;
@ -24,6 +25,15 @@ class Storage extends AbstractStorage
$this->nextcloud = $nextcloud;
}
protected function ensureDirectoryExists(string $path): void
{
$path = $this->users->current()->path . $path;
if (!file_exists($path)) {
@mkdir($path, @fileperms($this->users->current()->path) ?: 0770, true);
}
}
public function getLock(string $uri, ?string $token = null): ?string
{
// It is important to check also for a lock on parent directory as we support depth=1
@ -183,6 +193,24 @@ class Storage extends AbstractStorage
}
return implode('', $permissions);
case NextCloud::PROP_NC_TRASHBIN_FILENAME:
if (0 !== strpos($uri, '.trash/')) {
return null;
}
return basename($uri);
case NextCloud::PROP_NC_TRASHBIN_DELETION_TIME:
if (0 !== strpos($uri, '.trash/')) {
return null;
}
return $this->getTrashInfo(basename($uri))['DeletionDate'] ?? null;
case NextCloud::PROP_NC_TRASHBIN_ORIGINAL_LOCATION:
if (0 !== strpos($uri, '.trash/')) {
return null;
}
return $this->getTrashInfo(basename($uri))['Path'] ?? null;
case 'DAV::quota-available-bytes':
return null;
case 'DAV::quota-used-bytes':
@ -240,7 +268,7 @@ class Storage extends AbstractStorage
return $out;
}
public function put(string $uri, $pointer, ?string $hash_algo, ?string $hash, ?int $mtime): bool
public function put(string $uri, $pointer, ?string $hash_algo = null, ?string $hash = null, ?int $mtime = null): bool
{
if (preg_match(self::PUT_IGNORE_PATTERN, basename($uri))) {
return false;
@ -253,9 +281,7 @@ class Storage extends AbstractStorage
throw new WebDAV_Exception('Target is a directory', 409);
}
if (!file_exists($parent)) {
mkdir($parent, 0770, true);
}
$this->ensureDirectoryExists($uri);
$new = !file_exists($target);
@ -326,6 +352,12 @@ class Storage extends AbstractStorage
throw new WebDAV_Exception('Target does not exist', 404);
}
// Move to trash
if (DEFAULT_TRASHBIN_DELAY > 0 && 0 !== strpos($uri, '.trash')) {
$this->moveToTrash($uri);
return;
}
if (is_dir($target)) {
self::deleteDirectory($target);
}
@ -367,7 +399,7 @@ class Storage extends AbstractStorage
$method = $move ? 'rename' : 'copy';
if ($method == 'copy' && is_dir($source)) {
@mkdir($target, 0770, true);
$this->ensureDirectoryExists($destination);
if (!is_dir($target)) {
throw new WebDAV_Exception('Target directory could not be created', 409);
@ -422,7 +454,7 @@ class Storage extends AbstractStorage
throw new WebDAV_Exception('You don\'t have the right to create a directory here', 403);
}
mkdir($target, 0770);
$this->ensureDirectoryExists($uri);
}
public function getResourceProperties(string $uri): Properties
@ -578,4 +610,135 @@ class Storage extends AbstractStorage
return $uri;
}
/**
* @see https://specifications.freedesktop.org/trash-spec/trashspec-latest.html
*/
public function moveToTrash(string $uri): void
{
$this->ensureDirectoryExists('.trash/info');
$this->ensureDirectoryExists('.trash/files');
$name = basename($uri);
$target = $this->users->current()->path . '.trash/info/' . $name . '.trashinfo';
$info = sprintf("[Trash Info]\nPath=%s\nDeletionDate=%s\n",
str_replace('%2F', '/', rawurlencode($uri)),
date(DATE_RFC3339)
);
file_put_contents($target, $info);
$this->move($uri, '.trash/files/' . $name);
}
public function restoreFromTrash(string $uri): void
{
$src = $this->users->current()->path . '.trash/files/' . $uri;
if (!file_exists($src)) {
return;
}
$info = $this->getTrashInfo($uri);
$dest = $info['Path'] ?? $uri;
if ($info) {
$this->delete('.trash/info/' . $uri . '.trashinfo');
}
$this->move('.trash/files/' . $uri, $dest);
}
public function emptyTrash(): void
{
$this->delete('.trash');
$this->ensureDirectoryExists('.trash/info');
$this->ensureDirectoryExists('.trash/files');
}
public function deleteFromTrash(string $uri): void
{
$this->delete('.trash/files/' . $uri);
$this->delete('.trash/info/' . $uri . '.trashinfo');
}
protected function getTrashInfo(string $uri): ?array
{
$info_file = $this->users->current()->path . '.trash/info/' . $uri . '.trashinfo';
$info = @parse_ini_file($info_file, false, INI_SCANNER_RAW);
if (!isset($info['Path'], $info['DeletionDate'])) {
return null;
}
$info['Path'] = rawurldecode($info['Path']);
$info['DeletionDate'] = strtotime($info['DeletionDate']);
$info['InfoFilePath'] = $info_file;
return $info;
}
public function pruneTrash(int $delete_before_timestamp): int
{
$this->ensureDirectoryExists('.trash/info');
$this->ensureDirectoryExists('.trash/files');
$info_dir = $this->users->current()->path . '.trash/info';
$count = 0;
foreach (glob($info_dir . '/*.trashinfo') as $file) {
$name = basename($file);
$name = str_replace('.trashinfo', '', $name);
$info = $this->getTrashInfo($name);
if (!$info) {
continue;
}
if ($info['DeletionDate'] < $delete_before_timestamp) {
$this->delete('.trash/files/' . $name);
$this->delete('.trash/info/' . $name . '.trashinfo');
$count++;
}
}
return $count;
}
public function listTrashFiles(): iterable
{
$this->pruneTrash(time() - DEFAULT_TRASHBIN_DELAY);
$this->ensureDirectoryExists('.trash/info');
$this->ensureDirectoryExists('.trash/files');
$info_dir = $this->users->current()->path . '.trash/info';
$files_dir = $this->users->current()->path . '.trash/files';
foreach (glob($info_dir . '/*.trashinfo') as $file) {
$name = basename($file);
$name = str_replace('.trashinfo', '', $name);
$target = $files_dir . '/' . $name;
if (!file_exists($target)) {
@unlink($file);
continue;
}
$info = $this->getTrashInfo($name);
$is_dir = is_dir($target);
$size = $is_dir ? self::getDirectorySize($target) : filesize($target);
yield $name => [
NextCloud::PROP_NC_TRASHBIN_FILENAME => $name,
NextCloud::PROP_NC_TRASHBIN_ORIGINAL_LOCATION => $info['Path'],
NextCloud::PROP_NC_TRASHBIN_DELETION_TIME => $info['DeletionDate'],
NextCloud::PROP_OC_SIZE => $size,
NextCloud::PROP_OC_ID => fileinode($file),
'DAV::getcontentlength' => $size,
'DAV::getcontenttype' => $is_dir ? null : @mime_content_type($target),
'DAV::resourcetype' => $is_dir ? 'collection' : '',
];
}
}
}

View file

@ -309,10 +309,11 @@ class Users
return $this->makeUserObjectGreatAgain($user);
}
public function quota(?stdClass $user = null): stdClass
public function quota(?stdClass $user = null, bool $with_trash = false): stdClass
{
$user ??= $this->current();
$used = $total = $free = 0;
$trash = null;
if ($user) {
if ($user->quota == -1) {
@ -330,9 +331,11 @@ class Users
$total = $user->quota;
$free = max(0, $total - $used);
}
$trash = $with_trash ? Storage::getDirectorySize($user->path . '/.trash') : null;
}
return (object) compact('free', 'total', 'used');
return (object) compact('free', 'total', 'used', 'trash');
}
public function delete(?stdClass $user)

View file

@ -20,6 +20,7 @@ if (file_exists($cfg_file)) {
// Default configuration constants
$defaults = [
'DEFAULT_QUOTA' => 200,
'DEFAULT_TRASHBIN_DELAY' => 60*60*24*30,
'STORAGE_PATH' => __DIR__ . '/../data/%s',
'DB_FILE' => __DIR__ . '/../data/db.sqlite',
'WOPI_DISCOVERY_URL' => null,
@ -28,8 +29,8 @@ $defaults = [
'ENABLE_XSENDFILE' => false,
'DISABLE_SLOW_OPERATIONS' => false,
'ERRORS_SHOW' => true,
'ERRORS_EMAIL' => true,
'ERRORS_LOG' => null,
'ERRORS_EMAIL' => null,
'ERRORS_LOG' => __DIR__ . '/../data/error.log',
'ERRORS_REPORT_URL' => null,
'AUTH_CALLBACK' => null,
'LDAP_HOST' => null,
@ -71,7 +72,7 @@ if (ERRORS_REPORT_URL) {
// Create random secret key
if (!defined('KaraDAV\SECRET_KEY')) {
$cfg = file_exists($cfg_file) ? file_get_contents($cfg_file) : "<?php\n";
$cfg = file_exists($cfg_file) ? file_get_contents($cfg_file) : "<?php\nnamespace KaraDAV;\n\n";
if (false == strpos($cfg, 'SECRET_KEY')) {
$secret = base64_encode(random_bytes(16));

View file

@ -22,11 +22,11 @@ if (!$user) {
exit;
}
$quota = $users->quota($user);
$server = new Server;
$quota = $users->quota($user, true);
$free = format_bytes($quota->free);
$used = format_bytes($quota->used);
$total = format_bytes($quota->total);
$trash = format_bytes($quota->trash ?? 0);
$percent = $quota->total ? floor(($quota->used / $quota->total)*100) . '%' : '100%';
$www_url = WWW_URL;
$username = htmlspecialchars($user->login);
@ -40,6 +40,7 @@ echo <<<EOF
<dd><h3>{$percent} used, {$free} free</h3></dd>
<dd><progress max="{$quota->total}" value="{$quota->used}"></progress>
<dd>Used {$used} out of a total of {$total}.</dd>
<dd>Trash: {$trash}.</dd>
<dt>WebDAV URL</dt>
<dd><h3><a href="{$user->dav_url}"><tt>{$user->dav_url}</tt></a></h3>
<dt>NextCloud URL</dt>