Files
Crussell/sabredav/server.php
T
popertots 5cc5a7f6d2 fix: review round 7 — fresh-eyes audit fixes (6 agents) + full test suites for every backend change
Fresh-eyes review round with 6 independent agents (money-safety, concurrency,
Square wire parity, security, frontend flow, testing-gaps). Every finding was
independently verified against the code before fixing. All backend changes
now carry full test suites (10+ new tests, each verified to FAIL without its
guard). All 20 packages green, race detector clean.

Money-safety:
- Gift-card purchase refunds no longer create money: manual refunds of a
  no-booking (gift-card purchase) payment are rejected with a clear message
  in the direct handler AND never re-issued by the sweep-resume path
  (processManualPaymentGroup skips them; reconcile-then-fail, no re-issue).
- BuyGiftCard no-client-key fallback: derived deterministically under the
  advisory lock (pending-row reuse fixes lost-response double-charge;
  completed-row sequence advance preserves distinct-purchase collapse fix).
- Terminal completion is never unrecorded: activeTerminalCheckoutID now calls
  recordUntrackedTerminalPayment when a provisional (tmp-) checkout is found
  COMPLETED at Square (previously only marked the row COMPLETED — a lost poll
  left the payment invisible and unrefundable).
- Sweep: provisional tmp- checkout rows are resolved against Square first
  (COMPLETED → record; live → keep guard; NOT_FOUND/CANCELED → fail;
  ambiguous → leave pending) instead of blind-failing a possibly-live
  checkout. recordUntrackedTerminalPayment re-checks the booking status
  (FOR UPDATE) and refuses to record on a cancelled booking, inserting a
  critical_payment_log admin notification instead. Till-sale post-charge
  UPDATE now requires status='pending' (no resurrection of a clawed-back sale).

Frontend (Svelte 5):
- UserPaymentModal keeps CardSelection mounted through processing (bind:this
  ref + Square iframe survive the loyalty/tokenize awaits) — new-card
  payments work again.
- BookingFlow clears the cached nonce/verification pair on any failure (retry
  re-tokenizes fresh; idempotency key retained for dedup); 409 'already paid'
  refetches the booking and reconciles depositPaid so the confirmation gate
  opens; Back button disabled during processing.
- Synchronous double-submit guards on buyGiftCard/redeemGiftCard/submitTip.

Square wire parity (mock vs real):
- processing_fee sign unified (negated at paymentFromSquare; mock agrees).
- SimulateSourceUsed (SOURCE_USED, 400) matches real CreateCard.
- GetCardsOnFile excludes disabled cards (matches ListCards).
- ForcePaymentStatus toggle + tests prove the charge path can't be status-blind.
- CreateCheckout rejects empty device_id (env fallback SQUARE_TERMINAL_DEVICE_ID);
  completed terminal checkout's payment resolvable by id.

Security:
- 2FA attempt-map data race fixed: lastAt is atomic.Int64 (nanos) — eviction
  scan reads race-free; concurrent verify+evict tests under -race.
- Backend refuses to start on weak/placeholder JWT_SECRET_KEY (<32 chars or
  known public placeholders) with openssl rand -hex 32 guidance.
- Dockerfile no longer COPYs .env (secrets injected via compose env_file).
- SabreDAV requires DAV_ADMIN_PASSWORD (no admin/admin default); compose
  fails at config time when missing.

Testing gaps closed (each verified to FAIL without its guard):
- refunded-dedup 409 (CreateBookingPayment), keyed sweep past-retention
  blind-fail, reconcile status-switch (CANCELED/FAILED/APPROVED/PENDING/unknown
  in both by-key and by-id paths), resolveChargeSource Square-failure branches,
  structured 500 / CARD_DECLINED / cancelled-context E2E (row stays pending),
  deriveBookingPaymentIdempotencyKey >45-char truncation, webhook
  findPaymentByDisputeID fallback, clawbackOneTillSale non-gift-card branch,
  dispute.evidence / terminal.checkout dispatch.

Infra:
- local-dev-2.sh fails loudly on port-5432 squatters / docker compose failures
  (previously died silently under ERR_EXIT with hidden output).
- Test harness defaults SQUARE_TERMINAL_DEVICE_ID; money_safety_fixes_test.go
  gained the missing build tag.

Verification: go test -tags test,dev -count=1 -parallel 8 ./... (20/20 ok),
-race clean on 2FA + payments money paths, go build ./... + -tags dev, go vet
clean, svelte-check 0 errors, env-docs gate OK (36 vars), docker compose
config valid.
2026-08-22 00:34:49 +01:00

278 lines
8.8 KiB
PHP

<?php
/**
* Save as: ./sabredav/server.php
*
* SabreDAV CardDAV and CalDAV server implementation with PostgreSQL
*/
use Sabre\DAV;
use Sabre\CalDAV;
use Sabre\CardDAV;
use Sabre\DAVACL;
require 'vendor/autoload.php';
// PostgreSQL connection
$dbHost = getenv('POSTGRES_HOST') ?: 'postgres';
$dbPort = getenv('POSTGRES_PORT') ?: '5432';
$dbName = getenv('POSTGRES_DB') ?: 'mydb';
$dbUser = getenv('POSTGRES_USER') ?: 'myuser';
$dbPass = getenv('POSTGRES_PASSWORD') ?: 'mypassword';
$dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', $dbHost, $dbPort, $dbName);
try {
$pdo = new PDO($dsn, $dbUser, $dbPass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
]);
} catch (PDOException $e) {
error_log("Database connection failed: " . $e->getMessage());
http_response_code(500);
die("Database connection failed");
}
// Create tables if they don't exist
$pdo->exec("
CREATE TABLE IF NOT EXISTS dav_principals (
id SERIAL PRIMARY KEY,
uri VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255),
displayname VARCHAR(255)
);
CREATE TABLE IF NOT EXISTS dav_calendars (
id SERIAL PRIMARY KEY,
principaluri VARCHAR(255) NOT NULL,
displayname VARCHAR(255),
uri VARCHAR(255) NOT NULL,
description TEXT,
calendarorder INT DEFAULT 0,
calendarcolor VARCHAR(10),
timezone TEXT,
components VARCHAR(255),
transparent BOOLEAN DEFAULT FALSE,
UNIQUE(principaluri, uri)
);
CREATE TABLE IF NOT EXISTS dav_calendarobjects (
id SERIAL PRIMARY KEY,
calendardata TEXT,
uri VARCHAR(255) NOT NULL,
calendarid INTEGER NOT NULL REFERENCES dav_calendars(id) ON DELETE CASCADE,
lastmodified INTEGER,
etag VARCHAR(32),
size INTEGER,
componenttype VARCHAR(8),
firstoccurence INTEGER,
lastoccurence INTEGER,
uid VARCHAR(255),
UNIQUE(calendarid, uri)
);
CREATE TABLE IF NOT EXISTS dav_addressbooks (
id SERIAL PRIMARY KEY,
principaluri VARCHAR(255) NOT NULL,
displayname VARCHAR(255),
uri VARCHAR(255) NOT NULL,
description TEXT,
synctoken INTEGER DEFAULT 1,
UNIQUE(principaluri, uri)
);
CREATE TABLE IF NOT EXISTS dav_cards (
id SERIAL PRIMARY KEY,
addressbookid INTEGER NOT NULL REFERENCES dav_addressbooks(id) ON DELETE CASCADE,
carddata TEXT,
uri VARCHAR(255) NOT NULL,
lastmodified INTEGER,
etag VARCHAR(32),
size INTEGER,
UNIQUE(addressbookid, uri)
);
CREATE TABLE IF NOT EXISTS dav_addressbookchanges (
id SERIAL PRIMARY KEY,
uri VARCHAR(255) NOT NULL,
synctoken INTEGER NOT NULL,
addressbookid INTEGER NOT NULL REFERENCES dav_addressbooks(id) ON DELETE CASCADE,
operation SMALLINT NOT NULL
);
CREATE TABLE IF NOT EXISTS dav_calendarchanges (
id SERIAL PRIMARY KEY,
uri VARCHAR(255) NOT NULL,
synctoken INTEGER NOT NULL,
calendarid INTEGER NOT NULL REFERENCES dav_calendars(id) ON DELETE CASCADE,
operation SMALLINT NOT NULL
);
CREATE TABLE IF NOT EXISTS dav_users (
id SERIAL PRIMARY KEY,
username VARCHAR(255) NOT NULL UNIQUE,
digesta1 VARCHAR(32) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_calendarobjects_calendarid ON dav_calendarobjects(calendarid);
CREATE INDEX IF NOT EXISTS idx_cards_addressbookid ON dav_cards(addressbookid);
");
// Create default principal if not exists
$stmt = $pdo->query("SELECT COUNT(*) FROM dav_principals");
if ($stmt->fetchColumn() == 0) {
$pdo->exec("
INSERT INTO dav_principals (uri, email, displayname)
VALUES ('principals/default', 'admin@example.com', 'Default User')
");
}
// Create default addressbook if not exists
$stmt = $pdo->query("SELECT COUNT(*) FROM dav_addressbooks");
if ($stmt->fetchColumn() == 0) {
$pdo->exec("
INSERT INTO dav_addressbooks (principaluri, displayname, uri, description, synctoken)
VALUES ('principals/default', 'Contacts', 'default', 'Default address book', 1)
");
}
// Create default calendar if not exists
$stmt = $pdo->query("SELECT COUNT(*) FROM dav_calendars");
if ($stmt->fetchColumn() == 0) {
$pdo->exec("
INSERT INTO dav_calendars (principaluri, displayname, uri, description, components, transparent)
VALUES ('principals/default', 'Default Calendar', 'default', 'Default calendar', 'VEVENT,VTODO', false)
");
}
// Create default user if not exists (username: admin, password from DAV_ADMIN_PASSWORD env).
// DAV_ADMIN_PASSWORD is REQUIRED: this CardDAV/CalDAV server exposes customer
// PII (vCards), so a default or publicly-known admin credential is never
// acceptable — fail fast instead of starting with one.
$davPassword = getenv('DAV_ADMIN_PASSWORD');
$weakDavPasswords = ['admin', 'password', 'changeme', 'change-me', 'changethis', 'secret', 'sabredav', 'test'];
if ($davPassword === false || $davPassword === '' || in_array(strtolower(trim($davPassword)), $weakDavPasswords, true)) {
error_log("FATAL: DAV_ADMIN_PASSWORD is not set or is a known weak/default value. Refusing to start: set a strong random DAV_ADMIN_PASSWORD (e.g. `openssl rand -hex 32`) in the environment and restart.");
http_response_code(500);
die("DAV_ADMIN_PASSWORD is not configured");
}
$stmt = $pdo->query("SELECT COUNT(*) FROM dav_users");
if ($stmt->fetchColumn() == 0) {
$digest = md5('admin:SabreDAV:' . $davPassword);
$pdo->exec("
INSERT INTO dav_users (username, digesta1)
VALUES ('admin', '$digest')
");
}
// Custom auth backend for PostgreSQL
class PostgreSQLAuthBackend extends DAV\Auth\Backend\AbstractDigest {
protected $pdo;
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
$this->realm = 'SabreDAV';
}
public function getDigestHash($realm, $username) {
$stmt = $this->pdo->prepare('SELECT digesta1 FROM dav_users WHERE username = ?');
$stmt->execute([$username]);
return $stmt->fetchColumn() ?: null;
}
}
// Custom principal backend for PostgreSQL
class PostgreSQLPrincipalBackend extends DAVACL\PrincipalBackend\AbstractBackend {
protected $pdo;
protected $tableName = 'dav_principals';
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
public function getPrincipalsByPrefix($prefixPath) {
$stmt = $this->pdo->query('SELECT uri, email, displayname FROM ' . $this->tableName);
$principals = [];
while ($row = $stmt->fetch()) {
if (strpos($row['uri'], $prefixPath) !== 0) continue;
$principals[] = [
'uri' => $row['uri'],
'{DAV:}displayname' => $row['displayname'],
'{http://sabredav.org/ns}email-address' => $row['email'],
];
}
return $principals;
}
public function getPrincipalByPath($path) {
$stmt = $this->pdo->prepare('SELECT uri, email, displayname FROM ' . $this->tableName . ' WHERE uri = ?');
$stmt->execute([$path]);
$row = $stmt->fetch();
if (!$row) return null;
return [
'uri' => $row['uri'],
'{DAV:}displayname' => $row['displayname'],
'{http://sabredav.org/ns}email-address' => $row['email'],
];
}
public function updatePrincipal($path, $mutations) {
return 0;
}
public function searchPrincipals($prefixPath, array $searchProperties, $test = 'allof') {
return [];
}
public function getGroupMemberSet($principal) {
return [];
}
public function getGroupMembership($principal) {
return [];
}
public function setGroupMemberSet($principal, array $members) {
throw new DAV\Exception('Setting group members is not supported');
}
}
// Backends
$authBackend = new PostgreSQLAuthBackend($pdo);
$principalBackend = new PostgreSQLPrincipalBackend($pdo);
// Use SabreDAV's PostgreSQL backends for CardDAV and CalDAV
$carddavBackend = new CardDAV\Backend\PDO($pdo);
$carddavBackend->tableName = 'dav_cards';
$carddavBackend->addressBooksTableName = 'dav_addressbooks';
$carddavBackend->addressBookChangesTableName = 'dav_addressbookchanges';
$caldavBackend = new CalDAV\Backend\PDO($pdo);
$caldavBackend->tableName = 'dav_calendarobjects';
$caldavBackend->calendarTableName = 'dav_calendars';
$caldavBackend->calendarChangesTableName = 'dav_calendarchanges';
// Directory structure
$tree = [
new DAVACL\PrincipalCollection($principalBackend),
new CalDAV\CalendarRoot($principalBackend, $caldavBackend),
new CardDAV\AddressBookRoot($principalBackend, $carddavBackend),
];
$server = new DAV\Server($tree);
$server->setBaseUri('/dav/');
// Plugins
$server->addPlugin(new DAV\Auth\Plugin($authBackend));
$server->addPlugin(new DAVACL\Plugin());
$server->addPlugin(new DAV\Browser\Plugin());
$server->addPlugin(new CalDAV\Plugin());
$server->addPlugin(new CardDAV\Plugin());
$server->addPlugin(new CalDAV\Schedule\Plugin());
$server->addPlugin(new DAV\Sync\Plugin());
// Run the server
$server->exec();