Implement PROPPATCH custom properties

This commit is contained in:
bohwaz 2022-08-31 08:06:27 +02:00
parent 0455fca2c7
commit c2b9b1722f
18 changed files with 721 additions and 214 deletions

View file

@ -3,4 +3,4 @@ deps:
wget -O lib/KD2/WebDAV_NextCloud.php 'https://fossil.kd2.org/kd2fw/doc/tip/src/lib/KD2/WebDAV_NextCloud.php' wget -O lib/KD2/WebDAV_NextCloud.php 'https://fossil.kd2.org/kd2fw/doc/tip/src/lib/KD2/WebDAV_NextCloud.php'
server: server:
php -S localhost:8080 -t www www/index.php php -S localhost:8080 -t www www/_router.php

View file

@ -13,6 +13,16 @@ The desktop client is the most annoying
* [v1 is for mobile](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html) and is quite simple to implement * [v1 is for mobile](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html) and is quite simple to implement
* [v2 is for desktop](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2) and requires two endpoints * [v2 is for desktop](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2) and requires two endpoints
The first login API requires you to simply generate an app password and redirect to a special URL after login:
1. Client will call the `/index.php/login/flow` URL
2. You redirect or display a login form for the user (it is recommended that you ask the user to confirm that he wants to allow the app after login)
3. After login you redirect to this special URL: `nc://login/server:https://...&user:bohwaz&password:supersecret`.
Because why re-use standard URL query parameters when you can invent weird stuff, you have to use a colon instead of an equal sign. Also it seems that parameters cannot be URL-encoded, so not sure what happens if your server URL, username or password contain a special character. But aside from that it is quite straightforward.
The second API is different but is explained in the documentation, it involves extra steps. When the process finishes, the user is left to close the opened browser window, so you got to have a specific waiting page for that. The desktop app will try to request (poll) the username and password everytime it receives focus again, or every 30 seconds.
## JSON/XML API endpoints required ## JSON/XML API endpoints required
Those endpoints are requested by the clients and one or the other client will fail if they don't return something that looks valid. Those endpoints are requested by the clients and one or the other client will fail if they don't return something that looks valid.

View file

@ -1,2 +1,44 @@
# karadav # KaraDAV - A lightweight WebDAV server, with NextCloud compatibility
Lightweight NextCloud compatible WebDAV server
This is WebDAV server serving a demonstration of the KD2\WebDAV and KD2\WebDAV_NextCloud components, allowing to easily set up a WebDAV file sharing server compatible with NextCloud clients with no depencies and high performance.
The only dependency is SQLite3.
Although this is a demo, this can be used as a simple but powerful file sharing server.
This server features:
* No database is required
* Multiple user accounts
* Share files for users using WebDAV: delete, create, update, mkdir, get, list
* Compatible with WebDAV clients
* Supports NextCloud Android app
* Supports NextCloud desktop app
* User-friendly directory listings for file browsing with a web browser:
* Upload directly from browser
* Rename
* Delete
* Create and edit text file
* MarkDown live preview
* Preview of images, text, MarkDown and PDF
* User-management through web UI
## Future development
It is not planned to implement CalDAV and CardDAV currently, but it might come in the future, in the mean time see [Sabre/DAV](https://sabre.io/dav/) for that.
## Dependencies
This depends on the KD2\WebDAV and KD2\WebDAV_NextCloud classes from the [KD2FW package](https://fossil.kd2.org/kd2fw/), which are packaged in this repository.
These are lightweight and easy to use in your own software to add this feature to your product.
## Author
BohwaZ/KD2
## License
This software and its dependencies are available in open source with the AGPL v3 license. This requires you to share all your source code if you include this in your software. This is voluntary.
For entities wishing to use this software or libraries in a project where you don't want to have to publish all your source code, we can also sell this software with a commercial license, contact me at bohwaz /at/ kd2 /dot/ org. We can do that as we have wrote and own 100% of the source code, dependencies included, there is no third-party code here.

View file

@ -2,26 +2,18 @@
namespace KaraDAV; namespace KaraDAV;
/**
* Where login/password tuples are stored
* By default it contains a dav:superDAV user, please remove this user!
* Add users to the password file using `htpasswd -B users.passwd login`
*/
const USERS_PASSWD_FILE = __DIR__ . '/users.passwd';
/**
* Where the list of app credentials live
* Each line is a set of login:custom_password credentials
* Generated when logging in
*/
const APPS_PASSWD_FILE = __DIR__ . '/data/apps.passwd';
/** /**
* Users file storage path * Users file storage path
* %s is replaced by the login name of the user * %s is replaced by the login name of the user
*/ */
const STORAGE_PATH = __DIR__ . '/data/%s'; const STORAGE_PATH = __DIR__ . '/data/%s';
/**
* SQLite3 database file
* This is where the users, app sessions and stuff will be stored
*/
const DB_FILE = __DIR__ . '/data/db.sqlite';
/** /**
* WWW_URL is the complete URL of the root of this server * WWW_URL is the complete URL of the root of this server
* This code auto-detects it as well as it can * This code auto-detects it as well as it can
@ -33,4 +25,4 @@ $name = $_SERVER['SERVER_NAME'];
$port = !in_array($_SERVER['SERVER_PORT'], [80, 443]) ? ':' . $_SERVER['SERVER_PORT'] : ''; $port = !in_array($_SERVER['SERVER_PORT'], [80, 443]) ? ':' . $_SERVER['SERVER_PORT'] : '';
$root = '/'; $root = '/';
define('KaraDAV\WWW_URL', sprintf('http%s://%s/', $https, $name, $port, $root)); define('KaraDAV\WWW_URL', sprintf('http%s://%s%s%s', $https, $name, $port, $root));

67
lib/KaraDAV/DB.php Normal file
View file

@ -0,0 +1,67 @@
<?php
namespace KaraDAV;
class DB extends \SQLite3
{
static protected $instance;
static public function getInstance(): self
{
if (!isset(self::$instance)) {
self::$instance = new self;
}
return self::$instance;
}
public function __construct()
{
if (isset(self::$instance)) {
throw new \LogicException('Already started');
}
parent::__construct(DB_FILE);
}
static public function getInstallPassword(): ?string
{
if (!isset($_COOKIE[session_name()])) {
return null;
}
@session_start();
return $_SESSION['install_password'] ?? null;
}
public function run(string $sql, ...$params)
{
$st = $this->prepare($sql);
foreach ($params as $key => $value) {
$st->bindValue(is_int($key) ? $key+1 : ':' . $key, $value);
}
return $st->execute();
}
public function iterate(string $sql, ...$params): iterable
{
$res = $this->run($sql, ...$params);
while ($row = $res->fetchArray(\SQLITE3_ASSOC)) {
yield (object)$row;
}
}
public function first(string $sql, ...$params)
{
$row = $this->run($sql, ...$params)->fetchArray(\SQLITE3_ASSOC);
return $row ? (object) $row : null;
}
public function firstColumn(string $sql, ...$params)
{
return $this->run($sql, ...$params)->fetchArray(\SQLITE3_NUM)[0] ?? null;
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace KaraDAV;
class Properties
{
protected string $user;
protected string $uri;
protected array $ns = [];
protected array $xml = [];
protected bool $loaded = false;
public function __construct(string $user, string $uri) {
$this->user = $user;
$this->uri = $uri;
}
public function load(): void
{
if (!$this->loaded) {
$this->loaded = true;
$list = DB::getInstance()->iterate('SELECT ns, xml FROM properties WHERE user = ? AND uri = ?;', $this->user, $this->uri);
foreach ($list as $row) {
$this->ns = array_merge($this->ns, json_decode($row->ns, true));
$this->xml[] = $row->xml;
}
}
}
public function xml(): string
{
$this->load();
return implode("\n", $this->xml);
}
public function ns(): array
{
$this->load();
return $this->ns;
}
public function set(string $ns_url, string $name, array $ns, string $xml)
{
$ns = json_encode($ns);
DB::getInstance()->run('REPLACE INTO properties (user, uri, ns_url, name, ns, xml) VALUES (?, ?, ?, ?, ?, ?);', $this->user, $this->uri, $ns_url, $name, $ns, $xml);
}
public function remove(string $ns_url, string $name)
{
DB::getInstance()->run('DELETE FROM properties WHERE user = ? AND uri = ? AND ns_url = ? AND name = ?;', $this->user, $this->uri, $ns_url, $name);
}
public function clear()
{
DB::getInstance()->run('DELETE FROM properties WHERE user = ? AND uri = ?;', $this->user, $this->uri);
}
}

View file

@ -9,8 +9,10 @@ use KD2\WebDAV_NextCloud_Exception;
class Server extends WebDAV_NextCloud class Server extends WebDAV_NextCloud
{ {
protected string $path; protected Users $users;
protected \stdClass $user;
const LOCK = true; const LOCK = true;
protected bool $parse_propfind = true;
/** /**
* These file names will be ignored when doing a PUT * These file names will be ignored when doing a PUT
@ -18,6 +20,11 @@ class Server extends WebDAV_NextCloud
*/ */
const PUT_IGNORE_PATTERN = '!^~(?:lock\.|^\._)|^(?:\.DS_Store|Thumbs\.db|desktop\.ini)$!'; const PUT_IGNORE_PATTERN = '!^~(?:lock\.|^\._)|^(?:\.DS_Store|Thumbs\.db|desktop\.ini)$!';
public function __construct()
{
$this->users = new Users;
}
public function route(?string $uri = null): bool public function route(?string $uri = null): bool
{ {
if (!parent::route($uri)) { if (!parent::route($uri)) {
@ -25,155 +32,60 @@ class Server extends WebDAV_NextCloud
// it means we fall back to the default WebDAV server // it means we fall back to the default WebDAV server
// available on the root path. We need to handle a // available on the root path. We need to handle a
// classic login/password auth here. // classic login/password auth here.
$this->setBaseURI('/');
$users = new Users; $users = new Users;
$login = $users->login($_SERVER['PHP_AUTH_USER'] ?? null, $_SERVER['PHP_AUTH_PW'] ?? null); $user = $users->login($_SERVER['PHP_AUTH_USER'] ?? null, $_SERVER['PHP_AUTH_PW'] ?? null);
if (!$login) { if (!$user) {
http_response_code(401); http_response_code(401);
header('WWW-Authenticate: Basic realm="Please login"'); header('WWW-Authenticate: Basic realm="Please login"');
return true; return true;
} }
$this->setUser($login); $this->user = $user;
$this->setBaseURI('/files/' . $user->login . '/');
return WebDAV::route($uri); return WebDAV::route($uri);
} }
} }
protected function setUser(string $user): void
{
$path = sprintf(STORAGE_PATH, $user);
$this->path = rtrim($path, '/') . '/';
if (!file_exists($path)) {
mkdir($path, 0770, true);
}
}
protected function checkAppAuth(string $login, string $password): bool
{
$lines = file(APPS_PASSWD_FILE);
$removed = 0;
$ok = false;
foreach ($lines as $k => $line) {
$line = explode(':', trim($line));
if (count($line) != 3) {
continue;
}
if ($line[0] != $login) {
continue;
}
// Expired session
if ($line[2] < time()) {
unset($lines[$k]);
$removed++;
continue;
}
if (password_verify($password, $line[1])) {
$ok = true;
break;
}
}
// Clean up of expired sessions
if ($removed) {
file_put_contents(APPS_PASSWD_FILE, implode("\n", $lines) . "\n");
}
return $ok;
}
public function nc_auth(?string $login, ?string $password): bool public function nc_auth(?string $login, ?string $password): bool
{ {
if (isset($_COOKIE[session_name()]) && !isset($_SESSION)) { $user = $this->users->appSessionLogin($login, $password);
session_start();
}
// Check if user already has a session if (!$user) {
if (!empty($_SESSION['user'])) {
$this->setUser($_SESSION['user']);
return true;
}
// If not, try to login
$login = strtolower(trim($login));
$users = new Users;
$user_password_hash = $users->get($login);
// User has vanished?
if (!$user_password_hash) {
return false; return false;
} }
// The app password contains the user password hash $this->user = $user;
// this way we can invalidate all sessions if we change
// the user password
$password .= $user_password_hash;
if (!$this->checkAppAuth($login, $password)) {
return false;
}
@session_start();
$_SESSION['user'] = $login;
$this->setUser($login);
return true; return true;
} }
public function nc_get_user(): ?string
{
return $this->users->current()->login ?? null;
}
public function nc_get_quota(): array
{
return $this->users->quota();
}
public function nc_generate_token(): string public function nc_generate_token(): string
{ {
return sha1(random_bytes(16)); return sha1(random_bytes(16));
} }
public function nc_store_token(string $token): void
{
@session_start();
$_SESSION['token'] = $token;
}
public function nc_validate_token(string $token): ?array public function nc_validate_token(string $token): ?array
{ {
if (!isset($_COOKIE[session_name()])) { $session = $this->users->appSessionValidateToken($token);
if (!$session) {
return null; return null;
} }
@session_start(); return ['user' => $session->user, 'password' => $session->password];
if (empty($_SESSION['token'])) {
return null;
}
if ($_SESSION['token'] != $token) {
return null;
}
unset($_SESSION['token']);
$login = $_SESSION['user'];
$hash = $this->listUsers()[$login] ?? null;
if (!$hash) {
return null;
}
// Generate a custom app password
$password = sha1(random_bytes(16));
$hash = password_hash($password);
$expiry = time() + 3600*24*90; // Sessions expire after 3 months
file_put_contents(APPS_PASSWD_FILE, sprintf("%s:%s:%d\n", $login, $hash, $expiry), FILE_APPEND);
return (object) compact('login', 'password');
} }
public function nc_login_url(?string $token): string public function nc_login_url(?string $token): string
@ -186,50 +98,39 @@ class Server extends WebDAV_NextCloud
} }
} }
/**
* Simple locking implementation using sessions
* Because we have a user-centric store, we don't need a database,
* we just store the locks in session
*/
protected function getLock(string $uri, ?string $token = null): ?string protected function getLock(string $uri, ?string $token = null): ?string
{ {
if ($scope = ($_SESSION['locks'][$uri][$token] ?? null)) { // It is important to check also for a lock on parent directory as we support depth=1
return $lock; $sql = 'SELECT scope FROM locks WHERE user = ? AND (uri = ? OR uri = ?)';
$params = [$this->user->login, $uri, dirname($uri)];
if ($token) {
$sql .= ' AND token = ?';
$params[] = $token;
} }
// Also check lock on parent directory as we support depth = 1 $sql .= ' LIMIT 1';
if (trim($uri, '/') && $lock = ($_SESSION['locks'][dirname($uri)][$scope] ?? null)) {
return $lock;
}
return null; return DB::getInstance()->firstColumn($sql, ...$params);
} }
protected function lock(string $uri, string $token, string $scope): void protected function lock(string $uri, string $token, string $scope): void
{ {
if (!isset($_SESSION['locks'])) { DB::getInstance()->run('REPLACE INTO locks VALUES (?, ?, ?, ?, datetime(\'now\', \'+5 minutes\'));', $this->user->login, $uri, $token, $scope);
$_SESSION['locks'] = [];
}
if (!isset($_SESSION['locks'][$uri])) {
$_SESSION['locks'][$uri] = [];
}
$_SESSION['locks'][$uri][$token] = 'scope';
} }
protected function unlock(string $uri, string $token): void protected function unlock(string $uri, string $token): void
{ {
unset($_SESSION['locks'][$uri][$token]); DB::getInstance()->run('DELETE FROM locks WHERE user = ? AND uri = ? AND token = ?;', $this->user->login, $uri, $token);
} }
protected function list(string $uri): iterable protected function list(string $uri): iterable
{ {
$dirs = glob($this->path . $uri . '/*', \GLOB_ONLYDIR); $dirs = glob($this->user->path . $uri . '/*', \GLOB_ONLYDIR);
$dirs = array_map('basename', $dirs); $dirs = array_map('basename', $dirs);
natcasesort($dirs); natcasesort($dirs);
$files = glob($this->path . $uri . '/*'); $files = glob($this->user->path . $uri . '/*');
$files = array_map('basename', $files); $files = array_map('basename', $files);
$files = array_diff($files, $dirs); $files = array_diff($files, $dirs);
natcasesort($files); natcasesort($files);
@ -241,23 +142,23 @@ class Server extends WebDAV_NextCloud
protected function get(string $uri): ?array protected function get(string $uri): ?array
{ {
if (!file_exists($this->path . $uri)) { if (!file_exists($this->user->path . $uri)) {
return null; return null;
} }
//return ['content' => file_get_contents($this->path . $uri)]; //return ['content' => file_get_contents($this->path . $uri)];
//return ['resource' => fopen($this->path . $uri, 'r')]; //return ['resource' => fopen($this->path . $uri, 'r')];
return ['path' => $this->path . $uri]; return ['path' => $this->user->path . $uri];
} }
protected function exists(string $uri): bool protected function exists(string $uri): bool
{ {
return file_exists($this->path . $uri); return file_exists($this->user->path . $uri);
} }
protected function metadata(string $uri, bool $all = false): ?array protected function metadata(string $uri, bool $all = false): ?array
{ {
$target = $this->path . $uri; $target = $this->user->path . $uri;
if (!file_exists($target)) { if (!file_exists($target)) {
return null; return null;
@ -265,9 +166,10 @@ class Server extends WebDAV_NextCloud
$meta = [ $meta = [
'modified' => filemtime($target), 'modified' => filemtime($target),
'size' => filesize($target), 'size' => is_dir($target) ? null : filesize($target),
'type' => mime_content_type($target), 'type' => mime_content_type($target),
'collection' => is_dir($target), 'collection' => is_dir($target),
'nc_permissions' => implode('', [self::PERM_READ, self::PERM_WRITE, self::PERM_CREATE, self::PERM_DELETE, self::PERM_RENAME_MOVE]),
]; ];
if ($all) { if ($all) {
@ -285,7 +187,7 @@ class Server extends WebDAV_NextCloud
return false; return false;
} }
$target = $this->path . $uri; $target = $this->user->path . $uri;
$parent = dirname($target); $parent = dirname($target);
if (is_dir($target)) { if (is_dir($target)) {
@ -297,18 +199,46 @@ class Server extends WebDAV_NextCloud
} }
$new = !file_exists($target); $new = !file_exists($target);
$delete = false;
$size = 0;
$quota = $this->users->quota($this->user);
if (!$new) {
$size -= filesize($target);
}
$tmp_file = '.tmp.' . sha1($target);
$out = fopen($tmp_file, 'w');
while (!feof($pointer)) {
$bytes = fread($pointer, 8192);
$size += strlen($bytes);
if ($size > $quota->free) {
$delete = true;
break;
}
fwrite($out, $bytes);
}
$out = fopen($target, 'w');
stream_copy_to_stream($pointer, $out);
fclose($out); fclose($out);
fclose($pointer); fclose($pointer);
if ($delete) {
@unlink($tmp_file);
throw new WebDAV_Exception('Your quota is exhausted', 403);
}
else {
rename($tmp_file, $target);
}
return $new; return $new;
} }
protected function delete(string $uri): void protected function delete(string $uri): void
{ {
$target = $this->path . $uri; $target = $this->user->path . $uri;
if (!file_exists($target)) { if (!file_exists($target)) {
throw new WebDAV_Exception('Target does not exist', 404); throw new WebDAV_Exception('Target does not exist', 404);
@ -316,7 +246,7 @@ class Server extends WebDAV_NextCloud
if (is_dir($target)) { if (is_dir($target)) {
foreach (glob($target . '/*') as $file) { foreach (glob($target . '/*') as $file) {
$this->delete(substr($file, strlen($this->path))); $this->delete(substr($file, strlen($this->user->path)));
} }
rmdir($target); rmdir($target);
@ -328,8 +258,8 @@ class Server extends WebDAV_NextCloud
protected function copymove(bool $move, string $uri, string $destination): bool protected function copymove(bool $move, string $uri, string $destination): bool
{ {
$source = $this->path . $uri; $source = $this->user->path . $uri;
$target = $this->path . $destination; $target = $this->user->path . $destination;
$parent = dirname($target); $parent = dirname($target);
if (!file_exists($source)) { if (!file_exists($source)) {
@ -342,6 +272,14 @@ class Server extends WebDAV_NextCloud
throw new WebDAV_Exception('Target parent directory does not exist', 409); throw new WebDAV_Exception('Target parent directory does not exist', 409);
} }
if (false === $move) {
$quota = $this->users->quota($this->user);
if (filesize($source) > $quota->free) {
throw new WebDAV_Exception('Your quota is exhausted', 403);
}
}
if ($overwritten) { if ($overwritten) {
$this->delete($destination); $this->delete($destination);
} }
@ -379,7 +317,11 @@ class Server extends WebDAV_NextCloud
protected function mkcol(string $uri): void protected function mkcol(string $uri): void
{ {
$target = $this->path . $uri; if (!$this->user->quota) {
throw new WebDAV_Exception('Your quota is exhausted', 403);
}
$target = $this->user->path . $uri;
$parent = dirname($target); $parent = dirname($target);
if (file_exists($target)) { if (file_exists($target)) {
@ -397,9 +339,68 @@ class Server extends WebDAV_NextCloud
{ {
$out = parent::html_directory($uri, $list, $strings); $out = parent::html_directory($uri, $list, $strings);
$out = str_replace('</head>', '<link rel="stylesheet" type="text/css" href="/_files.css" /></head>', $out); $out = str_replace('</head>', sprintf('<link rel="stylesheet" type="text/css" href="%sfiles.css" /></head>', WWW_URL), $out);
$out = str_replace('</body>', '<script type="text/javascript" src="/_files.js"></script></body>', $out); $out = str_replace('</body>', sprintf('<script type="text/javascript" src="%sfiles.js"></script></body>', WWW_URL), $out);
return $out; return $out;
} }
protected function properties(string $uri): Properties
{
if (!isset($this->properties[$uri])) {
$this->properties[$uri] = new Properties($this->user->login, $uri);
}
return $this->properties[$uri];
}
protected function get_extra_ns(string $uri): array
{
$out = parent::get_extra_ns($uri);
$out = array_merge($out, $this->properties($uri)->ns());
return $out;
}
protected function get_extra_properties(string $uri, string $file, array $meta, array $requested_properties): string
{
$out = parent::get_extra_properties($uri, $file, $meta, $requested_properties);
$out .= $this->properties($uri)->xml();
return $out;
}
protected function set_extra_properties(string $uri, string $body): void
{
$xml = @simplexml_load_string($body);
// Select correct namespace if required
if (!empty(key($xml->getDocNameSpaces()))) {
$xml = $xml->children('DAV:');
}
$db = DB::getInstance();
$db->exec('BEGIN;');
$i = 0;
if (isset($xml->set)) {
foreach ($xml->set as $prop) {
$prop = $prop->prop->children();
$ns = $prop->getNamespaces(true);
$ns = array_flip($ns);
$this->properties($uri)->set(key($ns), $prop->getName(), array_filter($ns, 'trim'), $prop->asXML());
}
}
if (isset($xml->remove)) {
foreach ($xml->remove as $prop) {
$prop = $prop->prop->children();
$ns = $prop->getNamespaces();
$this->properties($uri)->remove(current($ns), $prop->getName());
}
}
$db->exec('END');
return;
}
} }

View file

@ -2,56 +2,205 @@
namespace KaraDAV; namespace KaraDAV;
use stdClass;
class Users class Users
{ {
static public function generatePassword(): string
{
$password = base64_encode(random_bytes(16));
$password = substr(str_replace(['/', '+', '='], '', $password), 0, 16);
return $password;
}
public function list(): array public function list(): array
{ {
$users = []; return iterator_to_array(DB::getInstance()->iterate('SELECT * FROM users ORDER BY login;'));
foreach (file(USERS_PASSWD_FILE) as $line) {
$login = strtolower(trim(strtok($line, ':')));
$password = trim(strtok(''));
$users[$login] = $password;
}
return $users;
} }
public function get(string $login): ?string public function get(string $login): ?stdClass
{ {
return $this->list()[$login] ?? null; $user = DB::getInstance()->first('SELECT * FROM users WHERE login = ?;', $login);
return $this->makeUserObjectGreatAgain($user);
} }
public function login(?string $login, ?string $password): ?string protected function makeUserObjectGreatAgain(?stdClass $user): ?stdClass
{ {
if (!$login || !$password) { if ($user) {
return null; $user->path = sprintf(STORAGE_PATH, $user->login);
$user->path = rtrim($user->path, '/') . '/';
if (!file_exists($user->path)) {
mkdir($user->path, 0770, true);
}
$user->dav_url = WWW_URL . 'files/' . $user->login . '/';
} }
return $user;
}
public function create(string $login, string $password)
{
$login = strtolower(trim($login));
$hash = password_hash(trim($password), null);
DB::getInstance()->run('INSERT OR IGNORE INTO users (login, password) VALUES (?, ?);', $login, $hash);
}
public function edit(string $login, array $data)
{
$params = [];
if (isset($data['password'])) {
$params['password'] = password_hash(trim($data['password']), null);
}
if (isset($data['quota'])) {
$params['quota'] = (int) $data['quota'] * 1024 * 1024;
}
if (isset($data['is_admin'])) {
$params['is_admin'] = (int) $data['is_admin'];
}
$update = array_map(fn($k) => $k . ' = ?', array_keys($params));
$update = implode(', ', $update);
$params = array_values($params);
$params[] = $login;
DB::getInstance()->run(sprintf('UPDATE users SET %s WHERE login = ?;', $update), ...$params);
}
public function current(): ?stdClass
{
if (isset($_COOKIE[session_name()]) && !isset($_SESSION)) { if (isset($_COOKIE[session_name()]) && !isset($_SESSION)) {
session_start(); session_start();
} }
return $this->makeUserObjectGreatAgain($_SESSION['user'] ?? null);
}
public function login(?string $login, ?string $password, ?string $app_password = null): ?stdClass
{
$login = null !== $login ? strtolower(trim($login)) : null;
// Check if user already has a session // Check if user already has a session
if (!empty($_SESSION['user'])) { $current = $this->current();
return $_SESSION['user'];
if ($current && (!$login || $current->login == $login)) {
return $current;
} }
// If not, try to login if (!$login || (!$password && !$app_password)) {
$login = strtolower(trim($login));
$hash = $this->get($login);
if (!$hash) {
return null; return null;
} }
if (!password_verify($password, $hash)) { // If not, try to login
$user = $this->get($login);
if (!$user) {
return null;
}
if ($app_password) {
$list = DB::getInstance()->iterate('SELECT password FROM app_sessions WHERE login = ? AND expiry > datetime();', $login);
$ok = false;
$app_password = trim($app_password) . $user->password;
// We have to iterate on all sessions, as NextCloud does not provide a unique login
foreach ($list as $session) {
if (password_verify($app_password, $hash)) {
$ok = true;
break;
}
}
if (!$ok) {
return null;
}
}
elseif (!password_verify(trim($password), $user->password)) {
return null; return null;
} }
@session_start(); @session_start();
$_SESSION['user'] = $login; $_SESSION['user'] = $user;
return $login; return $user;
}
public function appSessionCreate(?string $token = null): ?string
{
$current = $this->current();
if (!$current) {
return null;
}
if (null === $token) {
$expiry = '+10 minutes';
$hash = null;
$password = null;
}
else {
$expiry = '+1 month';
$password = $this->generatePassword();
// The app password contains the user password hash
// this way we can invalidate all sessions if we change
// the user password
$hash = password_hash($password . $current->password, null);
}
DB::getInstance()->run(
'INSERT OR IGNORE INTO app_sessions (user, password, expiry, token) VALUES (?, ?, datetime(\'now\', ?), ?);',
$current->login, $hash, $expiry, $token);
return $password;
}
public function appSessionValidateToken(string $token): ?stdClass
{
$session = DB::getInstance()->first('SELECT * FROM app_sessions WHERE token = ?;', $token);
if (!$session) {
return null;
}
// the token can only be exchanged against a session once,
// so we set a password and remove the token
$session->password = $this->generatePassword();
// The app password contains the user password hash
// this way we can invalidate all sessions if we change
// the user password
$hash = password_hash($session->password . $current->password, null);
DB::getInstance()->run('UPDATE app_sessions
SET token = NULL, password = ?, expiry = datetime(\'now\', \'+1 month\')
WHERE token = ?;',
$hash, $token);
return $session;
}
public function appSessionLogin(?string $login, ?string $app_password): ?stdClass
{
// From time to time, clean up old sessions
if (random_int() % 100 == 0) {
DB::getInstance()->run('DELETE FROM app_sessions WHERE expiry < datetime();');
}
return $this->login($login, null, $app_password);
}
public function quota(?stdClass $user = null): stdClass
{
$user ??= $this->current();
$used = get_directory_size($user->path);
$total = $user->quota;
$free = $user->quota - $used;
return (object) compact('free', 'total', 'used');
} }
} }

42
schema.sql Normal file
View file

@ -0,0 +1,42 @@
CREATE TABLE users (
login TEXT NOT NULL PRIMARY KEY,
password TEXT NOT NULL,
quota INTEGER NULL,
is_admin INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE locks (
user TEXT NOT NULL REFERENCES users(login) ON DELETE CASCADE,
uri TEXT NOT NULL,
token TEXT NOT NULL,
scope TEXT NOT NULL,
expiry TEXT NOT NULL
);
CREATE INDEX locks_uri ON locks (user, uri);
CREATE UNIQUE INDEX locks_unique ON locks (user, uri, token);
CREATE TABLE app_sessions (
user TEXT NOT NULL REFERENCES users(login) ON DELETE CASCADE,
token TEXT NULL, -- Temporary token, exchanged for an app password
user_agent TEXT NULL,
password TEXT NULL,
expiry TEXT NOT NULL
);
CREATE INDEX app_sessions_idx ON app_sessions (user);
CREATE INDEX app_sessions_login ON app_sessions (user);
-- Files properties stored using PROPPATCH
-- We are not using this currently, this is just to get test coverage from litmus
CREATE TABLE properties (
user TEXT NOT NULL REFERENCES users(login) ON DELETE CASCADE,
uri TEXT NOT NULL,
ns_url TEXT NOT NULL,
name TEXT NOT NULL,
xml TEXT NOT NULL
);
CREATE UNIQUE INDEX properties_unique ON properties (user, uri, ns_url, name);

View file

@ -1 +0,0 @@
dav:$2y$05$7DY5c2mNOdnRRNhRnd28juBaUd8d1IKUsWxGit5HVJiohzjqxO87O

View file

@ -1,4 +1,8 @@
FallbackResource /index.php Options -Indexes -Multiviews
DirectoryIndex disabled
DirectoryIndex index.php
FallbackResource /_router.php
# see https://stackoverflow.com/a/66136226 # see https://stackoverflow.com/a/66136226
ErrorDocument 404 /index.php ErrorDocument 404 /_router.php

View file

@ -4,17 +4,74 @@ namespace KaraDAV;
use KD2\ErrorManager; use KD2\ErrorManager;
require_once __DIR__ . '/../lib/KD2/ErrorManager.php'; spl_autoload_register(function ($class) {
$class = str_replace('\\', '/', $class);
require_once __DIR__ . '/../lib/' . $class . '.php';
});
ErrorManager::enable(ErrorManager::DEVELOPMENT); ErrorManager::enable(ErrorManager::DEVELOPMENT);
ErrorManager::setLogFile(__DIR__ . '/../error.log');
require_once __DIR__ . '/../lib/KD2/WebDAV.php';
require_once __DIR__ . '/../lib/KD2/WebDAV_NextCloud.php';
require_once __DIR__ . '/../lib/KaraDAV/Users.php';
require_once __DIR__ . '/../lib/KaraDAV/Server.php';
if (!file_exists(__DIR__ . '/../config.local.php')) { if (!file_exists(__DIR__ . '/../config.local.php')) {
die('This server is not configured yet. Please copy config.dist.php to config.local.php and edit it.'); die('This server is not configured yet. Please copy config.dist.php to config.local.php and edit it.');
} }
require __DIR__ . '/../config.local.php'; require __DIR__ . '/../config.local.php';
// Init database
if (!file_exists(DB_FILE)) {
$db = DB::getInstance();
$db->exec('BEGIN;');
$db->exec(file_get_contents(__DIR__ . '/../schema.sql'));
@session_start();
$users = new Users;
$p = Users::generatePassword();
$users->create('demo', $p);
$users->edit('demo', ['quota' => 10]);
$_SESSION['install_password'] = $p;
$users->login('demo', $p);
$db->exec('END;');
}
function get_directory_size(string $path): int
{
return 0;
$total = 0;
foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::LEAVES_ONLY, \RecursiveIteratorIterator::CATCH_GET_CHILD) as $p) {
try {
$total += $p->getSize();
}
catch (\RuntimeException $e) {
// Ignore file that vanished
}
}
return $total;
}
function html(string $title, string $html): void
{
$title = htmlspecialchars($title);
echo <<<EOF
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>{$title}</title>
<link rel="stylesheet" type="text/css" href="/admin.css" />
</head>
<body>
<h1>{$title}</h1>
{$html}
<footer>
Powered by <a href="https://github.com/kd2.org/karadav/">KaraDAV</a>
</footer>
</body>
</html>
EOF;
}

22
www/_router.php Normal file
View file

@ -0,0 +1,22 @@
<?php
namespace KaraDAV;
require_once __DIR__ . '/_inc.php';
if (PHP_SAPI == 'cli-server') {
if (is_file(__DIR__ . '/' . $_SERVER['REQUEST_URI'])) {
return false;
}
// Index.php
elseif ($_SERVER['REQUEST_URI'] == '/') {
return false;
}
}
$s = new Server;
if (!$s->route($_SERVER['REQUEST_URI'])) {
http_response_code(404);
echo '<h1>Invalid URL</h1>';
}

0
www/admin.css Normal file
View file

View file

@ -111,6 +111,7 @@ dialog.preview {
bottom: 0; bottom: 0;
padding: 0; padding: 0;
border-radius: 0; border-radius: 0;
background: #ddd;
} }
iframe, .md_preview { iframe, .md_preview {
@ -148,6 +149,9 @@ iframe, .md_preview {
margin: 0; margin: 0;
padding: 0; padding: 0;
border-radius: 0; border-radius: 0;
background: #fff;
color: #000;
box-shadow: 0px 0px 5px #000;
} }
.preview img { .preview img {

View file

@ -4,13 +4,28 @@ namespace KaraDAV;
require_once __DIR__ . '/_inc.php'; require_once __DIR__ . '/_inc.php';
if (PHP_SAPI == 'cli-server' && is_file(__DIR__ . '/' . $_SERVER['REQUEST_URI'])) { $users = new Users;
return false; $user = $users->current();
if (!$user) {
header(sprintf('Location: %slogin.php', WWW_URL));
exit;
} }
$quota = $users->quota($user);
$server = new Server;
$free = $server->format_bytes($quota->free);
$used = $server->format_bytes($quota->used);
$total = $server->format_bytes($quota->total);
$www_url = WWW_URL;
$s = new Server; html('My files', <<<EOF
<dl>
if (!$s->route($_SERVER['REQUEST_URI'])) { <dt>WebDAV URL</dt>
die('The supplied URL is not managed by this server'); <dd><a href="{$user->dav_url}"><tt>{$user->dav_url}</tt></a> (click to manage your files from your browser)</dd>
} <dt>NextCloud URL</dt>
<dd><tt>{$www_url}</tt></dd>
<dt>Quota</dt>
<dd>Used {$used} out of {$total} (free: {$free})</dd>
</dl>
EOF);

42
www/login.php Normal file
View file

@ -0,0 +1,42 @@
<?php
namespace KaraDAV;
require_once __DIR__ . '/_inc.php';
$users = new Users;
$install_password = DB::getInstallPassword();
$error = $install_message = '';
if (!empty($_POST['login']) && !empty($_POST['password'])) {
if ($users->login($_POST['login'], $_POST['password'])) {
header('Location: /');
exit;
}
$error = '<p class="error">Invalid login or password</p>';
}
if ($install_password) {
$install_message = sprintf('<p class="info">Your default user is:<br />
demo / %1$s<br>
<em>(this is only visible by you and will disappear when you close your browser)</em></p>', $install_password);
}
html('Login', <<<EOF
<form method="post" action="">
{$install_message}
{$error}
<fieldset>
<legend>Login</legend>
<dl>
<dt><label for="f_login">Login</label></dt>
<dd><input type="text" name="login" id="f_login" required /></dd>
<dt><label for="f_password">Password</label></dt>
<dd><input type="password" name="password" id="f_password" required /></dd>
</dl>
<p><input type="submit" value="Submit" /></p>
</fieldset>
</form>
EOF);