'HTTPS required']); exit; } header('Content-Type: application/json'); // This endpoint is for native clients, never a browser page: no CORS, no cache. header('Cache-Control: no-store'); header('X-Content-Type-Options: nosniff'); // --------------------------------------------------------------------------- // Authentication // --------------------------------------------------------------------------- /** * Bearer token, compared in constant time so a wrong guess leaks no timing. * * Three sources on purpose: Apache commonly strips Authorization before PHP * sees it (it is only forwarded with CGIPassAuth on, or through a rewrite), so * $_SERVER alone answers empty on a stock XAMPP and every request looks * unauthenticated. getallheaders() reads what the client actually sent. */ function client_token(): string { $h = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''; if ($h === '' && function_exists('getallheaders')) { foreach (getallheaders() as $name => $value) { if (strcasecmp($name, 'Authorization') === 0) { $h = $value; break; } } } if (stripos($h, 'Bearer ') === 0) return trim(substr($h, 7)); return ''; } if (!hash_equals(SYNC_TOKEN, client_token())) { // 404, not 401: an unauthenticated caller learns nothing about what lives // here — same reasoning as the desktop server's loopback lockdown. http_response_code(404); echo json_encode(['error' => 'Not found']); exit; } // --------------------------------------------------------------------------- // Routing // --------------------------------------------------------------------------- $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; if ($method === 'HEAD' || $method === 'GET') { if (!is_file(SYNC_FILE)) { // 404 = "no snapshot yet". The desktop client already treats this as // "first sync, remote is empty" rather than an error. http_response_code(404); echo json_encode(['error' => 'No snapshot yet']); exit; } $blob = file_get_contents(SYNC_FILE); if ($blob === false) { http_response_code(500); echo json_encode(['error' => 'Read failed']); exit; } // Strong ETag over the bytes. The write path will require If-Match against // it, which is what stops two devices from clobbering each other. $etag = '"' . hash('sha256', $blob) . '"'; header('ETag: ' . $etag); header('Last-Modified: ' . gmdate('D, d M Y H:i:s', (int)filemtime(SYNC_FILE)) . ' GMT'); // Save the round trip when the client already holds this version. // Compared on the bare hash: a validator that loses its quotes or arrives // as a weak tag (W/"…") would otherwise never match, and every request // would silently download the whole snapshot again — costly on mobile data // and invisible in the logs, since a 200 looks perfectly normal. $sent = trim($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''); if ($sent !== '' && trim($sent, 'W/" ') === trim($etag, '" ')) { http_response_code(304); exit; } header('Content-Length: ' . strlen($blob)); if ($method === 'HEAD') exit; echo $blob; exit; } // Everything else — including PUT — is not implemented yet. http_response_code(405); header('Allow: GET, HEAD'); echo json_encode(['error' => 'Read-only endpoint']);