Most of this is just gpt slop

This commit is contained in:
j 2025-06-30 13:04:18 +10:00
parent 9213ca6df6
commit 57afb8862d
12 changed files with 600 additions and 0 deletions

29
docker-compose.yaml Normal file
View file

@ -0,0 +1,29 @@
services:
sabredav:
build:
context: .
dockerfile: docker/Dockerfile
ports:
- "8080:8080"
environment:
DB_HOST: mariadb
DB_PORT: 3306
DB_NAME: sabredav
DB_USER: sabreuser
DB_PASS: sabrepass
depends_on:
- mariadb
mariadb:
image: mariadb:10.11
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: sabredav
MYSQL_USER: sabreuser
MYSQL_PASSWORD: sabrepass
volumes:
- mariadb-data:/var/lib/mysql
volumes:
mariadb-data:

34
docker/Dockerfile Normal file
View file

@ -0,0 +1,34 @@
FROM registry.gitlab.com/dxcker/apache-php:latest
# Switch over to root user
USER root
RUN apt update \
&& apt -y install unzip git curl \
&& apt -y install php-xml php-mbstring php-curl php-pdo php-mysql \
&& apt autoclean
# Enable mod_rewrite and mod_headers. Should be upstream but hey.
RUN a2enmod rewrite headers
# Reconfigure apache for this project
RUN echo "DocumentRoot /var/www/public" >> /etc/apache2/apache2.conf
RUN sed -i 's/\/var\/www\/html/\/var\/www\/public/g' /etc/apache2/apache2.conf
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Copy over entrypoint
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Copy over files
WORKDIR /var/www/
RUN mkdir -p data/files data && chown -R app:app data
COPY src/ .
RUN chown -R app:app /var/www/
USER app
ENTRYPOINT ["/entrypoint.sh"]

8
docker/entrypoint.sh Normal file
View file

@ -0,0 +1,8 @@
#!/bin/bash
set -e
composer install --no-interaction --optimize-autoloader
composer dump-autoload -o
composer migrate
/usr/sbin/apache2 -DFOREGROUND

0
src/carddav/index.php Normal file
View file

24
src/composer.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "j/wiredav",
"description": "Minimal SabreDAV server with WebDAV, CalDAV, CardDAV and MariaDB auth",
"type": "project",
"require": {
"php": "^7.4 || ^8.0",
"sabre/dav": "^4.4.0"
},
"require-dev": {
"robmorgan/phinx": "^0.14"
},
"autoload": {
"psr-4": {
"WireDAV\\": "libs/"
}
},
"scripts": {
"migrate": "vendor/bin/phinx migrate",
"rollback": "vendor/bin/phinx rollback"
},
"minimum-stability": "stable",
"license": "MIT"
}

View file

@ -0,0 +1,145 @@
<?php
use Phinx\Migration\AbstractMigration;
use Phinx\Db\Adapter\MysqlAdapter;
final class CreateUsersAndDavTables extends AbstractMigration
{
public function change(): void
{
// === Users table ===
$users = $this->table('users', ['id' => false, 'primary_key' => ['username']]);
$users->addColumn('username', 'string', ['limit' => 50])
->addColumn('digesta1', 'string', ['limit' => 255])
->create();
// === Principals table (users and groups) ===
$principals = $this->table('principals');
$principals->addColumn('uri', 'string', ['limit' => 100])
->addColumn('email', 'string', ['limit' => 255, 'null' => true])
->addColumn('displayname', 'string', ['limit' => 255, 'null' => true])
->addColumn('password', 'string', ['limit' => 255, 'null' => true])
->addColumn('calendar_prefix', 'string', ['limit' => 20, 'null' => true])
->addColumn('backend', 'string', ['limit' => 20, 'null' => true])
->addIndex(['uri'], ['unique' => true])
->create();
// Foreign key principals.uri → users.username (optional but typical)
$principals->addForeignKey('uri', 'users', 'username', ['delete'=> 'CASCADE', 'update'=> 'CASCADE'])->save();
// === Calendars table (CalDAV) ===
$calendars = $this->table('calendars');
$calendars->addColumn('principaluri', 'string', ['limit' => 100])
->addColumn('displayname', 'string', ['limit' => 100, 'null' => true])
->addColumn('uri', 'string', ['limit' => 200])
->addColumn('description', 'text', ['null' => true])
->addColumn('calendarorder', 'integer', ['null' => true])
->addColumn('calendarcolor', 'string', ['limit' => 10, 'null' => true])
->addColumn('timezone', 'text', ['null' => true])
->addColumn('components', 'string', ['limit' => 20, 'null' => true])
->addColumn('transparent', 'boolean', ['null' => true])
->addIndex(['principaluri', 'uri'], ['unique' => true])
->create();
$calendars->addForeignKey('principaluri', 'principals', 'uri', ['delete'=> 'CASCADE', 'update'=> 'CASCADE'])->save();
// === Calendar Objects table (CalDAV) ===
$calendarObjects = $this->table('calendarobjects');
$calendarObjects->addColumn('calendarid', 'integer')
->addColumn('uri', 'string', ['limit' => 200])
->addColumn('calendardata', 'binary', ['limit' => MysqlAdapter::BLOB_MEDIUM, 'null' => true])
->addColumn('lastmodified', 'integer', ['null' => true])
->addColumn('etag', 'string', ['limit' => 32, 'null' => true])
->addColumn('size', 'integer', ['null' => true])
->addColumn('componenttype', 'string', ['limit' => 8, 'null' => true])
->addColumn('firstoccurence', 'integer', ['null' => true])
->addColumn('lastoccurence', 'integer', ['null' => true])
->addColumn('uid', 'string', ['limit' => 200, 'null' => true])
->addIndex(['calendarid', 'uri'], ['unique' => true])
->create();
$calendarObjects->addForeignKey('calendarid', 'calendars', 'id', ['delete'=> 'CASCADE', 'update'=> 'CASCADE'])->save();
// === Addressbooks table (CardDAV) ===
$addressbooks = $this->table('addressbooks');
$addressbooks->addColumn('principaluri', 'string', ['limit' => 100])
->addColumn('displayname', 'string', ['limit' => 100, 'null' => true])
->addColumn('uri', 'string', ['limit' => 200])
->addColumn('description', 'text', ['null' => true])
->addIndex(['principaluri', 'uri'], ['unique' => true])
->create();
$addressbooks->addForeignKey('principaluri', 'principals', 'uri', ['delete'=> 'CASCADE', 'update'=> 'CASCADE'])->save();
// === Addressbook Changes table (CardDAV) ===
$addressbookChanges = $this->table('addressbookchanges');
$addressbookChanges->addColumn('addressbookid', 'integer')
->addColumn('uri', 'string', ['limit' => 200, 'null' => true])
->addColumn('synctoken', 'integer', ['null' => true])
->create();
$addressbookChanges->addForeignKey('addressbookid', 'addressbooks', 'id', ['delete'=> 'CASCADE', 'update'=> 'CASCADE'])->save();
// === Addressbook Objects table (CardDAV) ===
$addressbookObjects = $this->table('addressbookobjects');
$addressbookObjects->addColumn('addressbookid', 'integer')
->addColumn('uri', 'string', ['limit' => 200])
->addColumn('carddata', 'binary', ['limit' => MysqlAdapter::BLOB_MEDIUM, 'null' => true])
->addColumn('lastmodified', 'integer', ['null' => true])
->addColumn('etag', 'string', ['limit' => 32, 'null' => true])
->addColumn('size', 'integer', ['null' => true])
->addColumn('uid', 'string', ['limit' => 200, 'null' => true])
->addIndex(['addressbookid', 'uri'], ['unique' => true])
->create();
$addressbookObjects->addForeignKey('addressbookid', 'addressbooks', 'id', ['delete'=> 'CASCADE', 'update'=> 'CASCADE'])->save();
// === Locks table (WebDAV) ===
$locks = $this->table('locks');
$locks->addColumn('id', 'string', ['limit' => 32])
->addColumn('token', 'string', ['limit' => 255, 'null' => true])
->addColumn('principaluri', 'string', ['limit' => 100, 'null' => true])
->addColumn('scope', 'string', ['limit' => 10, 'null' => true])
->addColumn('depth', 'string', ['limit' => 10, 'null' => true])
->addColumn('uri', 'string', ['limit' => 255, 'null' => true])
->addColumn('timeout', 'integer', ['null' => true])
->addColumn('created', 'integer', ['null' => true])
->addColumn('owner', 'text', ['null' => true])
->addColumn('exclusive', 'boolean', ['null' => true])
->addIndex(['id'], ['unique' => true])
->create();
// === Calendar Shares table ===
$calendarShares = $this->table('calendarshares');
$calendarShares->addColumn('calendarid', 'integer')
->addColumn('principaluri', 'string', ['limit' => 100])
->addColumn('access', 'integer') // e.g. 1=read, 2=write
->addColumn('share_access', 'integer', ['null' => true])
->addColumn('summary', 'string', ['limit' => 255, 'null' => true])
->addColumn('href', 'string', ['limit' => 255, 'null' => true])
->addColumn('uid', 'string', ['limit' => 200, 'null' => true])
->addColumn('lastupdated', 'integer', ['null' => true])
->addIndex(['calendarid', 'principaluri'], ['unique' => true])
->create();
$calendarShares->addForeignKey('calendarid', 'calendars', 'id', ['delete'=> 'CASCADE', 'update'=> 'CASCADE'])->save();
$calendarShares->addForeignKey('principaluri', 'principals', 'uri', ['delete'=> 'CASCADE', 'update'=> 'CASCADE'])->save();
// === Addressbook Shares table ===
$addressbookShares = $this->table('addressbookshares');
$addressbookShares->addColumn('addressbookid', 'integer')
->addColumn('principaluri', 'string', ['limit' => 100])
->addColumn('access', 'integer')
->addColumn('share_access', 'integer', ['null' => true])
->addColumn('summary', 'string', ['limit' => 255, 'null' => true])
->addColumn('href', 'string', ['limit' => 255, 'null' => true])
->addColumn('uid', 'string', ['limit' => 200, 'null' => true])
->addColumn('lastupdated', 'integer', ['null' => true])
->addIndex(['addressbookid', 'principaluri'], ['unique' => true])
->create();
$addressbookShares->addForeignKey('addressbookid', 'addressbooks', 'id', ['delete'=> 'CASCADE', 'update'=> 'CASCADE'])->save();
$addressbookShares->addForeignKey('principaluri', 'principals', 'uri', ['delete'=> 'CASCADE', 'update'=> 'CASCADE'])->save();
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace WireDAV\Auth;
use Sabre\DAV\Auth\Backend\AbstractDigest;
use PDO;
class SQLAuthBackend extends AbstractDigest
{
private PDO $pdo;
/**
* @param PDO $pdo A PDO instance connected to your DB
*/
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
/**
* This method must return the MD5 digest hash for the user in the format:
* MD5(username:realm:password)
*
* @param string $realm
* @param string $username
* @return string|false The hash or false if user not found
*/
public function getDigestHash($realm, $username)
{
$stmt = $this->pdo->prepare('SELECT digesta1 FROM users WHERE username = ?');
$stmt->execute([$username]);
$hash = $stmt->fetchColumn();
return $hash ?: false;
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace WireDAV\DB;
use PDO;
use PDOException;
use RuntimeException;
class PDOProvider
{
/**
* Creates and returns a PDO instance with configured DB connection.
*
* @return PDO
* @throws RuntimeException on connection failure
*/
public static function getPDO(): PDO
{
$dbHost = getenv('DB_HOST') ?: 'localhost';
$dbPort = getenv('DB_PORT') ?: '3306';
$dbName = getenv('DB_NAME') ?: 'sabredav';
$dbUser = getenv('DB_USER') ?: 'sabreuser';
$dbPass = getenv('DB_PASS') ?: 'sabrepass';
$charset = 'utf8mb4';
$dsn = "mysql:host=$dbHost;port=$dbPort;dbname=$dbName;charset=$charset";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
return new PDO($dsn, $dbUser, $dbPass, $options);
} catch (PDOException $e) {
throw new RuntimeException('Database connection failed: ' . $e->getMessage());
}
}
}

22
src/phinx.php Normal file
View file

@ -0,0 +1,22 @@
<?php
// phinx.php
return [
'paths' => [
'migrations' => 'db/migrations',
],
'environments' => [
'default_migration_table' => 'phinxlog',
'default_environment' => 'development',
'development' => [
'adapter' => 'mysql',
'host' => getenv('DB_HOST') ?: 'localhost',
'name' => getenv('DB_NAME') ?: 'sabredav',
'user' => getenv('DB_USER') ?: 'root',
'pass' => getenv('DB_PASS') ?: '',
'port' => getenv('DB_PORT') ?: '3306',
'charset' => 'utf8mb4',
],
],
];

200
src/public/admin/index.php Normal file
View file

@ -0,0 +1,200 @@
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use WireDAV\DB\PDOProvider;
// Initiate basics
$pdo = (new PDOProvider())->getPDO();
$method = $_SERVER['REQUEST_METHOD'];
$path = $_GET['action'] ?? '';
// Helper Functions
function getBasicAuthUserAndPass() {
if (!isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) {
header('WWW-Authenticate: Basic realm="Admin Area"');
header('HTTP/1.0 401 Unauthorized');
exit('Authentication required');
}
return [$_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']];
}
function isAdmin(PDO $pdo, $username, $password, $realm = 'SabreDAV') {
$stmt = $pdo->prepare("SELECT digesta1 FROM users WHERE username = ?");
$stmt->execute([$username]);
$hash = $stmt->fetchColumn();
if (!$hash) return false;
$digest = md5("$username:$realm:$password");
return $digest === $hash; // Only checking password; add is_admin column if you want real admin check
}
function anyUsersExist(PDO $pdo) {
$stmt = $pdo->query("SELECT COUNT(*) FROM users");
return $stmt->fetchColumn() > 0;
}
// Request Routing
if ($method === 'POST' && $path === 'create_user') {
list($user, $pass) = getBasicAuthUserAndPass();
if (anyUsersExist($pdo) && !isAdmin($pdo, $user, $pass)) {
http_response_code(403);
exit(json_encode(['error' => 'Forbidden: admin required']));
}
$input = json_decode(file_get_contents('php://input'), true);
if (empty($input['username']) || empty($input['password'])) {
http_response_code(400);
exit(json_encode(['error' => 'Missing username or password']));
}
$newUser = $input['username'];
$newPass = $input['password'];
$realm = 'SabreDAV';
$stmt = $pdo->prepare("SELECT COUNT(*) FROM users WHERE username = ?");
$stmt->execute([$newUser]);
if ($stmt->fetchColumn() > 0) {
http_response_code(409);
exit(json_encode(['error' => 'User already exists']));
}
$digest = md5("$newUser:$realm:$newPass");
$stmt = $pdo->prepare("INSERT INTO users (username, digesta1) VALUES (?, ?)");
$stmt->execute([$newUser, $digest]);
echo json_encode(['success' => true, 'message' => "User $newUser created"]);
exit;
}
if ($method === 'POST' && $path === 'change_password') {
list($user, $pass) = getBasicAuthUserAndPass();
if (!isAdmin($pdo, $user, $pass)) {
http_response_code(403);
exit(json_encode(['error' => 'Forbidden: admin required']));
}
$input = json_decode(file_get_contents('php://input'), true);
if (empty($input['username']) || empty($input['password'])) {
http_response_code(400);
exit(json_encode(['error' => 'Missing username or password']));
}
$targetUser = $input['username'];
$newPass = $input['password'];
$realm = 'SabreDAV';
$digest = md5("$targetUser:$realm:$newPass");
$stmt = $pdo->prepare("UPDATE users SET digesta1 = ? WHERE username = ?");
$stmt->execute([$digest, $targetUser]);
echo json_encode(['success' => true, 'message' => "Password changed for $targetUser"]);
exit;
}
if ($method === 'POST' && $path === 'delete_user') {
list($user, $pass) = getBasicAuthUserAndPass();
if (!isAdmin($pdo, $user, $pass)) {
http_response_code(403);
exit(json_encode(['error' => 'Forbidden: admin required']));
}
$input = json_decode(file_get_contents('php://input'), true);
if (empty($input['username'])) {
http_response_code(400);
exit(json_encode(['error' => 'Missing username']));
}
$targetUser = $input['username'];
$stmt = $pdo->prepare("DELETE FROM users WHERE username = ?");
$stmt->execute([$targetUser]);
echo json_encode(['success' => true, 'message' => "User $targetUser deleted"]);
exit;
}
if ($method === 'POST' && $path === 'create_calendar') {
list($username, $password) = getBasicAuthUserAndPass();
// Authenticate user (no admin check, calendar owned by user)
$stmt = $pdo->prepare("SELECT digesta1 FROM users WHERE username = ?");
$stmt->execute([$username]);
$hash = $stmt->fetchColumn();
$realm = 'SabreDAV';
if (!$hash || md5("$username:$realm:$password") !== $hash) {
http_response_code(403);
exit(json_encode(['error' => 'Invalid credentials']));
}
$input = json_decode(file_get_contents('php://input'), true);
if (empty($input['uri']) || empty($input['displayname'])) {
http_response_code(400);
exit(json_encode(['error' => 'Missing uri or displayname']));
}
$calendarUri = $input['uri'];
$displayName = $input['displayname'];
$principalUri = "principals/$username";
// Check if calendar URI already exists for user
$stmt = $pdo->prepare("SELECT COUNT(*) FROM calendars WHERE principaluri = ? AND uri = ?");
$stmt->execute([$principalUri, $calendarUri]);
if ($stmt->fetchColumn() > 0) {
http_response_code(409);
exit(json_encode(['error' => 'Calendar URI already exists']));
}
$stmt = $pdo->prepare("INSERT INTO calendars (principaluri, displayname, uri) VALUES (?, ?, ?)");
$stmt->execute([$principalUri, $displayName, $calendarUri]);
echo json_encode(['success' => true, 'message' => "Calendar '$displayName' created"]);
exit;
}
if ($method === 'POST' && $path === 'delete_calendar') {
list($username, $password) = getBasicAuthUserAndPass();
// Authenticate user
$stmt = $pdo->prepare("SELECT digesta1 FROM users WHERE username = ?");
$stmt->execute([$username]);
$hash = $stmt->fetchColumn();
$realm = 'SabreDAV';
if (!$hash || md5("$username:$realm:$password") !== $hash) {
http_response_code(403);
exit(json_encode(['error' => 'Invalid credentials']));
}
$input = json_decode(file_get_contents('php://input'), true);
if (empty($input['uri'])) {
http_response_code(400);
exit(json_encode(['error' => 'Missing calendar uri']));
}
$calendarUri = $input['uri'];
$principalUri = "principals/$username";
$stmt = $pdo->prepare("DELETE FROM calendars WHERE principaluri = ? AND uri = ?");
$stmt->execute([$principalUri, $calendarUri]);
if ($stmt->rowCount() === 0) {
http_response_code(404);
exit(json_encode(['error' => 'Calendar not found']));
}
echo json_encode(['success' => true, 'message' => "Calendar '$calendarUri' deleted"]);
exit;
}
// Default fallback if no route matched
http_response_code(404);
echo json_encode(['error' => 'Unknown endpoint']);
exit;

61
src/public/index.php Normal file
View file

@ -0,0 +1,61 @@
<?php
require __DIR__ . '/../vendor/autoload.php';
use Sabre\DAV;
use Sabre\CalDAV;
use Sabre\CardDAV;
use Sabre\DAVACL;
use WireDAV\DB\PDOProvider;
use WireDAV\Auth\SQLAuthBackend;
$pdo = PDOProvider::getPDO();
//Mapping PHP errors to exceptions
function exception_error_handler($errno, $errstr, $errfile, $errline ) {
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
}
set_error_handler("exception_error_handler");
// Backends
$authBackend = new SQLAuthBackend($pdo);
$principalBackend = new DAVACL\PrincipalBackend\PDO($pdo);
$calendarBackend = new CalDAV\Backend\PDO($pdo);
// Directory tree
$tree = array(
new DAVACL\PrincipalCollection($principalBackend),
new CalDAV\CalendarRoot($principalBackend, $calendarBackend)
);
// The object tree needs in turn to be passed to the server class
$server = new DAV\Server($tree);
// You are highly encouraged to set your WebDAV server base url. Without it,
// SabreDAV will guess, but the guess is not always correct. Putting the
// server on the root of the domain will improve compatibility.
$server->setBaseUri('/');
// Authentication plugin
$authPlugin = new DAV\Auth\Plugin($authBackend,'SabreDAV');
$server->addPlugin($authPlugin);
// CalDAV plugin
$caldavPlugin = new CalDAV\Plugin();
$server->addPlugin($caldavPlugin);
// CardDAV plugin
$carddavPlugin = new CardDAV\Plugin();
$server->addPlugin($carddavPlugin);
// ACL plugin
$aclPlugin = new DAVACL\Plugin();
$server->addPlugin($aclPlugin);
// Support for html frontend
$browser = new DAV\Browser\Plugin();
$server->addPlugin($browser);
// And off we go!
$server->exec();

0
src/webdav/index.php Normal file
View file