Add screenshots, nicer UI, users management

This commit is contained in:
bohwaz 2022-10-24 19:06:00 +02:00
parent 424dbef419
commit 64822ba486
12 changed files with 449 additions and 49 deletions

View file

@ -1,12 +1,12 @@
# Installing KaraDAV
0. Setup your server with PHP 8.0+, and don't forget `php-sqlite3` :)
0. Setup your server with PHP 8.0+, and don't forget `php-sqlite3` and `php-simplexml` :)
1. Just download or clone this repo
2. Copy `config.dist.php` to `config.local.php`
3. Edit `config.local.php` to match your configuration
4. Create a virtual host (nginx, Apache, etc.) pointing to the `www` folder
5. Redirect all requests to `www/_router.php`
6. Go to your new virtual host and create your admin user
6. Go to your new virtual host, a default admin user is created the first time you access the UX, with the login `demo` and the password `karadavdemo`, please change it.
## Example Apache vhost

View file

@ -6,6 +6,8 @@ It is written in PHP (8+) The only dependency is SQLite3 for the database.
Its original purpose was to serve as a demo and test for the KD2 WebDAV library, which we developed for [Paheko](http://paheko.cloud/), our non-profit management solution, but it can also be used as a simple but powerful file sharing server.
![](scr_index.jpg)
## Features
* User-friendly directory listings for file browsing with a web browser, using our [WebDAV Manager.js](https://github.com/kd2org/webdav-manager.js) client
@ -18,8 +20,9 @@ Its original purpose was to serve as a demo and test for the KD2 WebDAV library,
* Preview of images, text, MarkDown and PDF
* Editing of Office files using Collabora or OnlyOffice
* WebDAV class 1, 2, 3 support, support for Etags
* No database sever is required
* No database server is required (SQLite3 is used)
* Multiple user accounts
* Support for per-user quota
* Share files using WebDAV: delete, create, update, mkdir, get, list
* Compatible with WebDAV clients
* Support for HTTP ranges (partial download of files)
@ -40,6 +43,16 @@ The following ownCloud/NextCloud specific features are supported:
* Thumbnail/preview of images and files
* Workspace notes (`README.md` displayed on top of directory listing on Android app) and workspace notes editing
## Screenshots
### NextCloud login
![](scr_login.jpg)
### Files management
![](https://raw.githubusercontent.com/kd2org/webdav-manager.js/main/scr_desktop.png)
## NextCloud/ownCloud compatibility
This server should be compatible with ownCloud and NextCloud synchronization clients (desktop, mobile, CLI).
@ -68,11 +81,9 @@ Note that even though it has been tested with NC/OC clients, KaraDAV might stop
This might get supported in future (maybe):
* [NextCloud Trashbin](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/trashbin.html)
* [NextCloud sharing](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-share-api.html) (maybe?)
* [Partial upload via PATCH](https://github.com/miquels/webdav-handler-rs/blob/master/doc/SABREDAV-partialupdate.md)
* [Resumable upload via TUS](https://tus.io/protocols/resumable-upload.html)
* [WebDAV sharing if it ever becomes a spec?](https://evertpot.com/webdav-caldav-carddav-sharing/)
* 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)
* Maybe: NextCloud files versioning (see [API](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/versions.html), [versioning pattern](https://docs.nextcloud.com/server/latest/user_manual/en/files/version_control.html), [code](https://github.com/nextcloud/server/blob/master/apps/files_versions/lib/Storage.php))
This probably won't get supported anytime soon:
@ -82,12 +93,15 @@ This probably won't get supported anytime soon:
* for now the best option is to use [Baikal from Sabre/DAV](https://sabre.io/baikal/) for that
* Nice web clients to add to Baikal are [AgenDAV](https://github.com/agendav/agendav) and [InfCloud](https://inf-it.com/open-source/clients/infcloud/)
* [Extended MKCOL](https://www.rfc-editor.org/rfc/rfc5689) required only if CalDAV support is implemented
* [Partial upload via PATCH](https://github.com/miquels/webdav-handler-rs/blob/master/doc/SABREDAV-partialupdate.md)
* [Resumable upload via TUS](https://tus.io/protocols/resumable-upload.html)
* [WebDAV sharing if it ever becomes a spec?](https://evertpot.com/webdav-caldav-carddav-sharing/)
## 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.
They are lightweight and easy to use in your own software to add support for WebDAV and NextCloud clients to your software.
They are lightweight and easy to use in your own software to add support for WebDAV and NextCloud clients to your software. Contact us for a commercial license.
## Similar software
@ -241,7 +255,7 @@ But they mostly pass with litmus 0.13-3 supplied by Debian:
## Author
BohwaZ. Contact me on: IRC = bohwaz@irc.libera.chat / Mastodon = https://mamot.fr/@bohwaz / Twitter = @bohwaz
Paheko.cloud / BohwaZ. Contact me on: IRC = bohwaz@irc.libera.chat / Mastodon = https://mamot.fr/@bohwaz / Twitter = @bohwaz
## License

View file

@ -24,17 +24,6 @@ class DB extends \SQLite3
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);

View file

@ -17,7 +17,7 @@ class Users
public function list(): array
{
return iterator_to_array(DB::getInstance()->iterate('SELECT * FROM users ORDER BY login;'));
return array_map([$this, 'makeUserObjectGreatAgain'], iterator_to_array(DB::getInstance()->iterate('SELECT * FROM users ORDER BY login;')));
}
public function get(string $login): ?stdClass
@ -26,6 +26,12 @@ class Users
return $this->makeUserObjectGreatAgain($user);
}
public function getById(int $id): ?stdClass
{
$user = DB::getInstance()->first('SELECT * FROM users WHERE id = ?;', $id);
return $this->makeUserObjectGreatAgain($user);
}
protected function makeUserObjectGreatAgain(?stdClass $user): ?stdClass
{
if ($user) {
@ -42,21 +48,26 @@ class Users
return $user;
}
public function create(string $login, string $password)
public function create(string $login, string $password, int $quota = 0, bool $is_admin = false)
{
$login = strtolower(trim($login));
$hash = password_hash(trim($password), null);
DB::getInstance()->run('INSERT OR IGNORE INTO users (login, password) VALUES (?, ?);', $login, $hash);
DB::getInstance()->run('INSERT OR IGNORE INTO users (login, password, quota, is_admin) VALUES (?, ?, ?, ?);',
$login, $hash, $quota * 1024 * 1024, $is_admin ? 1 : 0);
}
public function edit(string $login, array $data)
public function edit(int $id, array $data)
{
$params = [];
if (isset($data['password'])) {
if (!empty($data['password'])) {
$params['password'] = password_hash(trim($data['password']), null);
}
if (!empty($data['login'])) {
$params['login'] = trim($data['login']);
}
if (isset($data['quota'])) {
$params['quota'] = (int) $data['quota'] * 1024 * 1024;
}
@ -68,9 +79,9 @@ class Users
$update = array_map(fn($k) => $k . ' = ?', array_keys($params));
$update = implode(', ', $update);
$params = array_values($params);
$params[] = $login;
$params[] = $id;
DB::getInstance()->run(sprintf('UPDATE users SET %s WHERE login = ?;', $update), ...$params);
DB::getInstance()->run(sprintf('UPDATE users SET %s WHERE id = ?;', $update), ...$params);
}
public function current(): ?stdClass
@ -132,6 +143,11 @@ class Users
return $user;
}
public function logout(): void
{
session_destroy();
}
public function appSessionCreate(?string $token = null): ?stdClass
{
$current = $this->current();
@ -249,9 +265,15 @@ class Users
if ($user) {
$used = Storage::getDirectorySize($user->path);
$total = $user->quota;
$free = $user->quota - $used;
$free = max(0, $total - $used);
}
return (object) compact('free', 'total', 'used');
}
public function delete(?stdClass $user)
{
Storage::deleteDirectory($user->path);
DB::getInstance()->run('DELETE FROM users WHERE id = ?;', $user->id);
}
}

View file

@ -1,12 +1,15 @@
CREATE TABLE users (
login TEXT NOT NULL PRIMARY KEY,
id INTEGER PRIMARY KEY NOT NULL,
login TEXT NOT NULL,
password TEXT NOT NULL,
quota INTEGER NULL,
is_admin INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX users_login ON users(login);
CREATE TABLE locks (
user TEXT NOT NULL REFERENCES users(login) ON DELETE CASCADE,
user INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
uri TEXT NOT NULL,
token TEXT NOT NULL,
scope TEXT NOT NULL,
@ -18,7 +21,7 @@ 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,
user INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NULL, -- Temporary token, exchanged for an app password
user_agent TEXT NULL,
password TEXT NULL,
@ -31,7 +34,7 @@ CREATE UNIQUE INDEX app_sessions_token ON app_sessions (token);
-- 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,
user INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
uri TEXT NOT NULL,
name TEXT NOT NULL,
attributes TEXT NULL,

BIN
scr_index.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
scr_login.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View file

@ -26,15 +26,18 @@ if (!file_exists(DB_FILE)) {
@session_start();
$users = new Users;
$p = Users::generatePassword();
$users->create('demo', $p);
$users->edit('demo', ['quota' => 10]);
$p = 'karadavdemo';
$users->create('demo', $p, 10, true);
$_SESSION['install_password'] = $p;
$users->login('demo', $p);
$db->exec('END;');
}
if (isset($_COOKIE[session_name()]) && !isset($_SESSION)) {
@session_start();
}
function html_head(string $title): void
{
$title = htmlspecialchars($title);
@ -44,17 +47,24 @@ function html_head(string $title): void
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, target-densitydpi=device-dpi" />
<title>{$title}</title>
<link rel="stylesheet" type="text/css" href="/admin.css" />
<link rel="stylesheet" type="text/css" href="/ui.css" />
</head>
<body>
<h1>{$title}</h1>
<main>
EOF;
if (isset($_SESSION['install_password'])) {
printf('<p class="info">Your server has been installed with a user named <tt>demo</tt> and the password <tt>%s</tt>, please change it.<br /><br />This message will disappear when you log out.</p>', htmlspecialchars($_SESSION['install_password']));
}
}
function html_foot(): void
{
echo '
</main>
<footer>
Powered by <a href="https://github.com/kd2org/karadav/">KaraDAV</a>
</footer>

View file

@ -12,6 +12,11 @@ require_once __DIR__ . '/_inc.php';
$users = new Users;
$user = $users->current();
if (isset($_GET['logout'])) {
$users->logout();
$user = null;
}
if (!$user) {
header(sprintf('Location: %slogin.php', WWW_URL));
exit;
@ -22,20 +27,30 @@ $server = new Server;
$free = format_bytes($quota->free);
$used = format_bytes($quota->used);
$total = format_bytes($quota->total);
$percent = floor(($quota->used / $quota->total)*100) . '%';
$www_url = WWW_URL;
$username = htmlspecialchars($user->login);
html_head('My files');
echo <<<EOF
<h2 class="myfiles"><a class="btn" href="{$user->dav_url}">Manage my files</a></h2>
<h3>Hello, {$username} !</h3>
<dl>
<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>
<dt>WebDAV URL</dt>
<dd><a href="{$user->dav_url}"><tt>{$user->dav_url}</tt></a> (click to manage your files from your browser)</dd>
<dd><h3><a href="{$user->dav_url}"><tt>{$user->dav_url}</tt></a></h3>
<dt>NextCloud URL</dt>
<dd><tt>{$www_url}</tt></dd>
<dd class="help">Use this URL to setup a NextCloud or ownCloud client to access your files.</dd>
<dt>Quota</dt>
<dd>Used {$used} out of {$total} (free: {$free})</dd>
</dl>
<p><a class="btn sm" href="?logout">Logout</a></p>
EOF;
if ($user->is_admin) {
echo '<p><a class="btn sm" href="users.php">Manager users</a></p>';
}
html_foot();

View file

@ -5,7 +5,11 @@ namespace KaraDAV;
require_once __DIR__ . '/_inc.php';
$users = new Users;
$install_password = DB::getInstallPassword();
if (empty($_GET['nc']) && $users->current()) {
header('Location: ' . WWW_URL);
exit;
}
$error = 0;
@ -37,7 +41,7 @@ if (!empty($_POST['login']) && !empty($_POST['password'])) {
html_head('Login');
if ($error == -1) {
echo '<p class="confirm">You are logged in, you can close this window or tab and go back to the app.</p>';
echo '<p class="info">You are logged in, you can close this window or tab and go back to the app.</p>';
exit;
}
@ -45,12 +49,6 @@ if ($error) {
echo '<p class="error">Invalid login or password</p>';
}
if ($install_password) {
printf('<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);
}
echo '
<form method="post" action="">';
@ -67,8 +65,8 @@ echo '
<dd><input type="text" name="login" id="f_login" required autocapitalize="none" /></dd>
<dt><label for="f_password">Password</label></dt>
<dd><input type="password" name="password" id="f_password" required /></dd>
<dd><input type="submit" value="Connect me" /></dd>
</dl>
<p><input type="submit" value="Submit" /></p>
</fieldset>
</form>
';

195
www/ui.css Normal file
View file

@ -0,0 +1,195 @@
* { margin: 0; padding: 0; }
body {
background: #222;
font-family: sans-serif;
}
body > h1 {
background: linear-gradient(to bottom, #005c97, #363795);
padding: 2rem;
text-align: center;
color: #fff;
font-size: 4rem;
}
a {
color: #005c97;
}
input[type=text], input[type=password], input[type=number] {
border: 1px solid #666;
padding: .5em;
border-radius: .5em;
min-width: 15em;
font-size: 1.2em;
}
input[size] {
min-width: 0;
}
input[type=submit], .btn {
border: none;
cursor: pointer;
padding: .5em;
border-radius: .5em;
background: linear-gradient(to bottom, #2c3e50, #3498db);
color: #fff;
font-size: 1.5em;
text-decoration: none;
display: inline-block;
margin: .5em 0;
}
input:focus {
box-shadow: 0 0 10px orange;
border-color: darkred;
outline: none;
}
input[type=submit]:hover, .btn:hover {
box-shadow: 0 0 10px orange;
}
h2.myfiles {
float: right;
}
h2 .btn {
margin: 0;
font-size: 1em;
}
main {
background: #fff;
border-radius: 1em;
padding: 2em;
max-width: 40em;
margin: 2rem auto;
}
fieldset {
text-align: center;
border: 3px solid #005c97;
border-radius: 1em;
padding: 2em;
}
legend {
font-size: 1.3em;
padding: 0 1em;
}
footer {
color: #999;
text-align: center;
}
footer a {
color: #fff;
}
dl dt {
font-weight: bold;
margin: .8em 0;
margin-top: 2em;
}
dl dd {
margin: .8em 0;
}
progress[value] {
appearance: none;
border: none;
width: 70%;
height: 20px;
background-color: #ddd;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0,0,0,.5) inset;
position: relative;
}
progress[value]::-webkit-progress-bar {
background-color: #ddd;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0,0,0,.5) inset;
}
progress[value]::-webkit-progress-value {
position: relative;
background-size: 35px 20px, 100% 100%, 100% 100%;
border-radius:3px;
background-image:
linear-gradient(135deg, transparent, transparent 33%, rgba(0,0,0,.1) 33%, rgba(0,0,0,.1) 66%, transparent 66%),
linear-gradient(to top, rgba(255, 255, 255, .25), rgba(0,0,0,.2)),
linear-gradient(to right, #0c9, #f44);
}
.btn.sm {
padding: .3em .5em;
font-size: 1em;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1em 0;
}
table tbody tr:nth-child(even) {
background: #eee;
}
table th, table td {
text-align: center;
padding: .5em;
}
table thead {
background: #333;
color: #fff;
}
table progress[value] {
height: 10px;
}
p.info {
margin: 1em 0;
padding: .5em;
background: #cfc;
border-radius: .5em;
font-size: 1.2em;
}
p.error {
margin: 1em 0;
padding: .5em;
background: #fcc;
border-radius: .5em;
font-size: 1.3em;
}
p.info tt {
background: #666;
color: #fff;
padding: .2em;
}
@media screen and (max-width: 900px) {
main {
border-radius: 0;
margin-top: 0;
padding: 1em .5em;
}
h2.myfiles {
float: none;
margin: 1em 0;
text-align: center;
}
}

154
www/users.php Normal file
View file

@ -0,0 +1,154 @@
<?php
namespace KaraDAV;
require_once __DIR__ . '/_inc.php';
$users = new Users;
$me = $users->current();
if (empty($me->is_admin)) {
header(sprintf('Location: %slogin.php', WWW_URL));
exit;
}
$user = null;
$edit = $create = $delete = false;
if (!empty($_GET['edit']) && ($user = $users->getById((int) $_GET['edit']))) {
$edit = true;
}
elseif (!empty($_GET['delete']) && ($user = $users->getById((int) $_GET['delete']))) {
$delete = true;
if ($user->id == $me->id) {
die('You cannot delete your own account.');
}
}
elseif (isset($_GET['create'])) {
$create = true;
}
if ($create && !empty($_POST['create']) && !empty($_POST['login']) && !empty($_POST['password'])) {
$users->create(trim($_POST['login']), trim($_POST['password']));
header('Location: ' . WWW_URL . 'users.php');
exit;
}
elseif ($edit && !empty($_POST['save']) && !empty($_POST['login'])) {
if (empty($_POST['is_admin']) && $user->id == $me->id) {
die("You cannot remove yourself from admins, ask another admin to do it.");
}
$users->edit($user->id, array_merge($_POST, ['is_admin' => !empty($_POST['is_admin'])]));
if ($user->id == $me->id) {
$_SESSION['user'] = $users->getById($me->id);
}
header('Location: ' . WWW_URL . 'users.php');
exit;
}
elseif ($delete && !empty($_POST['delete'])) {
$users->delete($user);
header('Location: ' . WWW_URL . 'users.php');
exit;
}
html_head('Manage users');
if ($create) {
echo <<<EOF
<form method="post" action="">
<fieldset>
<legend>Create a new user</legend>
<dl>
<dt><label for="f_login">Login</label></dt>
<dd><input type="text" pattern="[a-z0-9_]+" name="login" id="f_login" required /></dd>
<dt><label for="f_password">Password</label></dt>
<dd><input type="password" name="password" id="f_password" /></dd>
<dd><input type="submit" name="create" value="Create" /></dd>
</dl>
</fieldset>
EOF;
}
elseif ($edit) {
$login = htmlspecialchars($user->login);
$is_admin = $user->is_admin ? 'checked="checked"' : '';
$quota = $user ? round($user->quota / 1024 / 1024) : 200;
echo <<<EOF
<form method="post" action="">
<fieldset>
<legend>Edit user</legend>
<dl>
<dt><label for="f_login">Login</label></dt>
<dd><input type="text" pattern="[a-z0-9_]+" name="login" id="f_login" value="{$login}" required /></dd>
<dt><label for="f_password">Password</label></dt>
<dd><input type="password" name="password" id="f_password" /></dd>
<dd>Leave empty if you don't want to change it.</dd>
<dt><label for="f_quota">Quota</label></dt>
<dd><input type="number" name="quota" step="1" min="0" value="{$quota}" required="required" size="6" /> (in MB)</dd>
<!--<dd>Set to -1 to have unlimited space</dd>-->
<dt><label for="f_is_admin">Status</label></dt>
<dd><label><input type="checkbox" name="is_admin" id="f_is_admin" {$is_admin} /> Administrator</label></dd>
<dd><input type="submit" name="save" value="Save" /></dd>
</dl>
</fieldset>
EOF;
}
elseif ($delete) {
$login = htmlspecialchars($user->login);
echo <<<EOF
<form method="post" action="">
<fieldset>
<legend>Delete user</legend>
<h2>Do you want to delete the user "{$login}" and all their files?</h2>
<dd><input type="submit" name="delete" value="Yes, delete" /></dd>
</fieldset>
EOF;
}
else {
echo <<<EOF
<p>
<a href="./" class="btn sm">&larr; Back</a>
</p>
<p>
<a href="?create" class="btn sm">Create new user</a>
</p>
<table>
<thead>
<tr>
<th>User</th>
<td>Quota</td>
<td>Admin</td>
<td></td>
</tr>
</thead>
<tbody>
EOF;
foreach ($users->list() as $user) {
$used = Storage::getDirectorySize($user->path);
printf('<tr>
<th>%s</th>
<td>%s used out of %s<br /><progress max="%d" value="%d"></progress></td>
<td>%s</td>
<td><a href="?edit=%d" class="btn sm">Edit</a> <a href="?delete=%d" class="btn sm">Delete</a></td>
</tr>',
htmlspecialchars($user->login),
format_bytes($used),
format_bytes($user->quota),
$user->quota,
$used,
$user->is_admin ? 'Admin' : '',
$user->id,
$user->id
);
}
echo '</tbody></table>';
}
html_foot();