diff --git a/Makefile b/Makefile index 2dc2a48..63564c1 100644 --- a/Makefile +++ b/Makefile @@ -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' server: - php -S localhost:8080 -t www www/index.php \ No newline at end of file + php -S localhost:8080 -t www www/_router.php \ No newline at end of file diff --git a/NEXTCLOUD.md b/NEXTCLOUD.md index 84e4c68..f4dc8ed 100644 --- a/NEXTCLOUD.md +++ b/NEXTCLOUD.md @@ -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 * [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 Those endpoints are requested by the clients and one or the other client will fail if they don't return something that looks valid. diff --git a/README.md b/README.md index 9226a25..d288145 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,44 @@ -# karadav -Lightweight NextCloud compatible WebDAV server +# KaraDAV - A lightweight WebDAV server, with NextCloud compatibility + +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. diff --git a/config.dist.php b/config.dist.php index 555064b..2c7e38a 100644 --- a/config.dist.php +++ b/config.dist.php @@ -2,26 +2,18 @@ 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 * %s is replaced by the login name of the user */ 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 * 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'] : ''; $root = '/'; -define('KaraDAV\WWW_URL', sprintf('http%s://%s/', $https, $name, $port, $root)); \ No newline at end of file +define('KaraDAV\WWW_URL', sprintf('http%s://%s%s%s', $https, $name, $port, $root)); diff --git a/lib/KaraDAV/DB.php b/lib/KaraDAV/DB.php new file mode 100644 index 0000000..bad82d2 --- /dev/null +++ b/lib/KaraDAV/DB.php @@ -0,0 +1,67 @@ +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; + } +} diff --git a/lib/KaraDAV/Properties.php b/lib/KaraDAV/Properties.php new file mode 100644 index 0000000..76ea743 --- /dev/null +++ b/lib/KaraDAV/Properties.php @@ -0,0 +1,61 @@ +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); + } +} diff --git a/lib/KaraDAV/Server.php b/lib/KaraDAV/Server.php index c91d7cb..705b56c 100644 --- a/lib/KaraDAV/Server.php +++ b/lib/KaraDAV/Server.php @@ -9,8 +9,10 @@ use KD2\WebDAV_NextCloud_Exception; class Server extends WebDAV_NextCloud { - protected string $path; + protected Users $users; + protected \stdClass $user; const LOCK = true; + protected bool $parse_propfind = true; /** * 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)$!'; + public function __construct() + { + $this->users = new Users; + } + public function route(?string $uri = null): bool { if (!parent::route($uri)) { @@ -25,155 +32,60 @@ class Server extends WebDAV_NextCloud // it means we fall back to the default WebDAV server // available on the root path. We need to handle a // classic login/password auth here. - $this->setBaseURI('/'); $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); header('WWW-Authenticate: Basic realm="Please login"'); return true; } - $this->setUser($login); + $this->user = $user; + $this->setBaseURI('/files/' . $user->login . '/'); 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 { - if (isset($_COOKIE[session_name()]) && !isset($_SESSION)) { - session_start(); - } + $user = $this->users->appSessionLogin($login, $password); - // Check if user already has a session - 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) { + if (!$user) { return false; } - // The app password contains the user password hash - // 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); + $this->user = $user; 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 { 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 { - if (!isset($_COOKIE[session_name()])) { + $session = $this->users->appSessionValidateToken($token); + + if (!$session) { return null; } - @session_start(); - - 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'); + return ['user' => $session->user, 'password' => $session->password]; } 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 { - if ($scope = ($_SESSION['locks'][$uri][$token] ?? null)) { - return $lock; + // It is important to check also for a lock on parent directory as we support depth=1 + $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 - if (trim($uri, '/') && $lock = ($_SESSION['locks'][dirname($uri)][$scope] ?? null)) { - return $lock; - } + $sql .= ' LIMIT 1'; - return null; + return DB::getInstance()->firstColumn($sql, ...$params); } protected function lock(string $uri, string $token, string $scope): void { - if (!isset($_SESSION['locks'])) { - $_SESSION['locks'] = []; - } - - if (!isset($_SESSION['locks'][$uri])) { - $_SESSION['locks'][$uri] = []; - } - - $_SESSION['locks'][$uri][$token] = 'scope'; + DB::getInstance()->run('REPLACE INTO locks VALUES (?, ?, ?, ?, datetime(\'now\', \'+5 minutes\'));', $this->user->login, $uri, $token, $scope); } 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 { - $dirs = glob($this->path . $uri . '/*', \GLOB_ONLYDIR); + $dirs = glob($this->user->path . $uri . '/*', \GLOB_ONLYDIR); $dirs = array_map('basename', $dirs); natcasesort($dirs); - $files = glob($this->path . $uri . '/*'); + $files = glob($this->user->path . $uri . '/*'); $files = array_map('basename', $files); $files = array_diff($files, $dirs); natcasesort($files); @@ -241,23 +142,23 @@ class Server extends WebDAV_NextCloud protected function get(string $uri): ?array { - if (!file_exists($this->path . $uri)) { + if (!file_exists($this->user->path . $uri)) { return null; } //return ['content' => file_get_contents($this->path . $uri)]; //return ['resource' => fopen($this->path . $uri, 'r')]; - return ['path' => $this->path . $uri]; + return ['path' => $this->user->path . $uri]; } 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 { - $target = $this->path . $uri; + $target = $this->user->path . $uri; if (!file_exists($target)) { return null; @@ -265,9 +166,10 @@ class Server extends WebDAV_NextCloud $meta = [ 'modified' => filemtime($target), - 'size' => filesize($target), + 'size' => is_dir($target) ? null : filesize($target), 'type' => mime_content_type($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) { @@ -285,7 +187,7 @@ class Server extends WebDAV_NextCloud return false; } - $target = $this->path . $uri; + $target = $this->user->path . $uri; $parent = dirname($target); if (is_dir($target)) { @@ -297,18 +199,46 @@ class Server extends WebDAV_NextCloud } $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($pointer); + if ($delete) { + @unlink($tmp_file); + throw new WebDAV_Exception('Your quota is exhausted', 403); + } + else { + rename($tmp_file, $target); + } + return $new; } protected function delete(string $uri): void { - $target = $this->path . $uri; + $target = $this->user->path . $uri; if (!file_exists($target)) { throw new WebDAV_Exception('Target does not exist', 404); @@ -316,7 +246,7 @@ class Server extends WebDAV_NextCloud if (is_dir($target)) { foreach (glob($target . '/*') as $file) { - $this->delete(substr($file, strlen($this->path))); + $this->delete(substr($file, strlen($this->user->path))); } rmdir($target); @@ -328,8 +258,8 @@ class Server extends WebDAV_NextCloud protected function copymove(bool $move, string $uri, string $destination): bool { - $source = $this->path . $uri; - $target = $this->path . $destination; + $source = $this->user->path . $uri; + $target = $this->user->path . $destination; $parent = dirname($target); if (!file_exists($source)) { @@ -342,6 +272,14 @@ class Server extends WebDAV_NextCloud 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) { $this->delete($destination); } @@ -379,7 +317,11 @@ class Server extends WebDAV_NextCloud 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); if (file_exists($target)) { @@ -397,9 +339,68 @@ class Server extends WebDAV_NextCloud { $out = parent::html_directory($uri, $list, $strings); - $out = str_replace('', '', $out); - $out = str_replace('', '', $out); + $out = str_replace('', sprintf('', WWW_URL), $out); + $out = str_replace('', sprintf('', WWW_URL), $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; + } } diff --git a/lib/KaraDAV/Users.php b/lib/KaraDAV/Users.php index a086096..b7e61a2 100644 --- a/lib/KaraDAV/Users.php +++ b/lib/KaraDAV/Users.php @@ -2,56 +2,205 @@ namespace KaraDAV; +use stdClass; + 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 { - $users = []; - - foreach (file(USERS_PASSWD_FILE) as $line) { - $login = strtolower(trim(strtok($line, ':'))); - $password = trim(strtok('')); - $users[$login] = $password; - } - - return $users; + return iterator_to_array(DB::getInstance()->iterate('SELECT * FROM users ORDER BY login;')); } - 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) { - return null; + if ($user) { + $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)) { 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 - if (!empty($_SESSION['user'])) { - return $_SESSION['user']; + $current = $this->current(); + + if ($current && (!$login || $current->login == $login)) { + return $current; } - // If not, try to login - $login = strtolower(trim($login)); - $hash = $this->get($login); - - if (!$hash) { + if (!$login || (!$password && !$app_password)) { 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; } @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'); } } diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..5fe83af --- /dev/null +++ b/schema.sql @@ -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); diff --git a/users.passwd b/users.passwd deleted file mode 100644 index 4ca50da..0000000 --- a/users.passwd +++ /dev/null @@ -1 +0,0 @@ -dav:$2y$05$7DY5c2mNOdnRRNhRnd28juBaUd8d1IKUsWxGit5HVJiohzjqxO87O diff --git a/www/.htaccess b/www/.htaccess index ef83f3c..e20662e 100644 --- a/www/.htaccess +++ b/www/.htaccess @@ -1,4 +1,8 @@ -FallbackResource /index.php +Options -Indexes -Multiviews +DirectoryIndex disabled +DirectoryIndex index.php + +FallbackResource /_router.php # see https://stackoverflow.com/a/66136226 -ErrorDocument 404 /index.php +ErrorDocument 404 /_router.php diff --git a/www/_inc.php b/www/_inc.php index 6607027..8b1b930 100644 --- a/www/_inc.php +++ b/www/_inc.php @@ -4,17 +4,74 @@ namespace KaraDAV; 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); - -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'; +ErrorManager::setLogFile(__DIR__ . '/../error.log'); 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.'); } 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 << + + + + {$title} + + + +

{$title}

+{$html} + + + +EOF; + +} diff --git a/www/_router.php b/www/_router.php new file mode 100644 index 0000000..c67619f --- /dev/null +++ b/www/_router.php @@ -0,0 +1,22 @@ +route($_SERVER['REQUEST_URI'])) { + http_response_code(404); + echo '

Invalid URL

'; +} diff --git a/www/admin.css b/www/admin.css new file mode 100644 index 0000000..e69de29 diff --git a/www/_files.css b/www/files.css similarity index 96% rename from www/_files.css rename to www/files.css index 76a0021..b5c7217 100644 --- a/www/_files.css +++ b/www/files.css @@ -111,6 +111,7 @@ dialog.preview { bottom: 0; padding: 0; border-radius: 0; + background: #ddd; } iframe, .md_preview { @@ -148,6 +149,9 @@ iframe, .md_preview { margin: 0; padding: 0; border-radius: 0; + background: #fff; + color: #000; + box-shadow: 0px 0px 5px #000; } .preview img { diff --git a/www/_files.js b/www/files.js similarity index 100% rename from www/_files.js rename to www/files.js diff --git a/www/index.php b/www/index.php index 29b465d..ae3e506 100644 --- a/www/index.php +++ b/www/index.php @@ -4,13 +4,28 @@ namespace KaraDAV; require_once __DIR__ . '/_inc.php'; -if (PHP_SAPI == 'cli-server' && is_file(__DIR__ . '/' . $_SERVER['REQUEST_URI'])) { - return false; +$users = new Users; +$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; - -if (!$s->route($_SERVER['REQUEST_URI'])) { - die('The supplied URL is not managed by this server'); -} +html('My files', << +
WebDAV URL
+
{$user->dav_url} (click to manage your files from your browser)
+
NextCloud URL
+
{$www_url}
+
Quota
+
Used {$used} out of {$total} (free: {$free})
+ +EOF); diff --git a/www/login.php b/www/login.php new file mode 100644 index 0000000..5cd4308 --- /dev/null +++ b/www/login.php @@ -0,0 +1,42 @@ +login($_POST['login'], $_POST['password'])) { + header('Location: /'); + exit; + } + + $error = '

Invalid login or password

'; +} + +if ($install_password) { + $install_message = sprintf('

Your default user is:
+ demo / %1$s
+ (this is only visible by you and will disappear when you close your browser)

', $install_password); +} + +html('Login', << +{$install_message} +{$error} +
+ Login +
+
+
+
+
+
+

+
+ +EOF);