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.
278 lines
8.8 KiB
PHP
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(); |