diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..291dd1c --- /dev/null +++ b/docker-compose.yaml @@ -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: diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..bae813a --- /dev/null +++ b/docker/Dockerfile @@ -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"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..a15b0f1 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +composer install --no-interaction --optimize-autoloader +composer dump-autoload -o +composer migrate + +/usr/sbin/apache2 -DFOREGROUND diff --git a/src/carddav/index.php b/src/carddav/index.php new file mode 100644 index 0000000..e69de29 diff --git a/src/composer.json b/src/composer.json new file mode 100644 index 0000000..e1c8fc3 --- /dev/null +++ b/src/composer.json @@ -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" +} + diff --git a/src/db/migrations/2025063000_create_users_and_dav_tables.php b/src/db/migrations/2025063000_create_users_and_dav_tables.php new file mode 100644 index 0000000..ca4aa72 --- /dev/null +++ b/src/db/migrations/2025063000_create_users_and_dav_tables.php @@ -0,0 +1,145 @@ +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(); + } +} + diff --git a/src/libs/Auth/SQLAuthBackend.php b/src/libs/Auth/SQLAuthBackend.php new file mode 100644 index 0000000..e6f689b --- /dev/null +++ b/src/libs/Auth/SQLAuthBackend.php @@ -0,0 +1,36 @@ +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; + } +} + diff --git a/src/libs/DB/PDOProvider.php b/src/libs/DB/PDOProvider.php new file mode 100644 index 0000000..a0ef5da --- /dev/null +++ b/src/libs/DB/PDOProvider.php @@ -0,0 +1,41 @@ + 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()); + } + } +} + diff --git a/src/phinx.php b/src/phinx.php new file mode 100644 index 0000000..87f7005 --- /dev/null +++ b/src/phinx.php @@ -0,0 +1,22 @@ + [ + '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', + ], + ], +]; + diff --git a/src/public/admin/index.php b/src/public/admin/index.php new file mode 100644 index 0000000..198fd78 --- /dev/null +++ b/src/public/admin/index.php @@ -0,0 +1,200 @@ +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; + diff --git a/src/public/index.php b/src/public/index.php new file mode 100644 index 0000000..655b130 --- /dev/null +++ b/src/public/index.php @@ -0,0 +1,61 @@ +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(); diff --git a/src/webdav/index.php b/src/webdav/index.php new file mode 100644 index 0000000..e69de29