Build Reactive Signals for Bluesky's AT Protocol Firehose in Laravel
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add CID (Content Identifier) implementation for IPLD

+371
+271
src/Core/CID.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace SocialDept\Signal\Core; 6 + 7 + use RuntimeException; 8 + use SocialDept\Signal\Binary\Reader; 9 + use SocialDept\Signal\Binary\Varint; 10 + 11 + /** 12 + * Content Identifier (CID) parser for IPLD. 13 + * 14 + * Supports CIDv0 (base58btc) and CIDv1 (multibase). 15 + * Minimal implementation for reading CIDs from CBOR and CAR data. 16 + */ 17 + class CID 18 + { 19 + private const BASE32_CHARSET = 'abcdefghijklmnopqrstuvwxyz234567'; 20 + private const BASE58BTC_CHARSET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; 21 + 22 + public function __construct( 23 + public readonly int $version, 24 + public readonly int $codec, 25 + public readonly string $hash, 26 + ) {} 27 + 28 + /** 29 + * Parse CID from binary data. 30 + * 31 + * @param string $data Binary CID data 32 + * @return self 33 + * @throws RuntimeException If CID is malformed 34 + */ 35 + public static function fromBinary(string $data): self 36 + { 37 + $reader = new Reader($data); 38 + 39 + // Read version 40 + $version = $reader->readVarint(); 41 + 42 + if ($version === 0x12) { 43 + // CIDv0 (legacy format - starts with multihash directly) 44 + // Reset and read as v0 45 + $reader = new Reader($data); 46 + $hashType = $reader->readVarint(); // 0x12 = sha256 47 + $hashLength = $reader->readVarint(); // typically 32 48 + $hash = $reader->readBytes($hashLength); 49 + 50 + return new self( 51 + version: 0, 52 + codec: 0x70, // dag-pb 53 + hash: chr($hashType) . chr($hashLength) . $hash, 54 + ); 55 + } 56 + 57 + if ($version !== 1) { 58 + throw new RuntimeException("Unsupported CID version: {$version}"); 59 + } 60 + 61 + // Read codec 62 + $codec = $reader->readVarint(); 63 + 64 + // Read multihash (hash type + length + hash bytes) 65 + $hashType = $reader->readVarint(); 66 + $hashLength = $reader->readVarint(); 67 + $hashBytes = $reader->readBytes($hashLength); 68 + 69 + // Store complete multihash 70 + $hash = chr($hashType) . chr($hashLength) . $hashBytes; 71 + 72 + return new self( 73 + version: $version, 74 + codec: $codec, 75 + hash: $hash, 76 + ); 77 + } 78 + 79 + /** 80 + * Parse CID from string (base32 or base58btc). 81 + * 82 + * @param string $str CID string 83 + * @return self 84 + * @throws RuntimeException If CID string is invalid 85 + */ 86 + public static function fromString(string $str): self 87 + { 88 + if (empty($str)) { 89 + throw new RuntimeException('Empty CID string'); 90 + } 91 + 92 + // Check multibase prefix 93 + $prefix = $str[0]; 94 + 95 + if ($prefix === 'b') { 96 + // base32 (CIDv1) 97 + $binary = self::decodeBase32(substr($str, 1)); 98 + 99 + return self::fromBinary($binary); 100 + } 101 + 102 + if ($prefix === 'Q' || $prefix === '1') { 103 + // base58btc (likely CIDv0) 104 + $binary = self::decodeBase58($str); 105 + 106 + return self::fromBinary($binary); 107 + } 108 + 109 + throw new RuntimeException("Unsupported multibase prefix: {$prefix}"); 110 + } 111 + 112 + /** 113 + * Convert CID to string representation. 114 + */ 115 + public function toString(): string 116 + { 117 + if ($this->version === 0) { 118 + // CIDv0 is always base58btc without prefix 119 + return self::encodeBase58($this->hash); 120 + } 121 + 122 + // CIDv1 uses base32 with 'b' prefix 123 + $binary = chr($this->version) . $this->encodeVarint($this->codec) . $this->hash; 124 + 125 + return 'b' . self::encodeBase32($binary); 126 + } 127 + 128 + /** 129 + * Get binary representation. 130 + */ 131 + public function toBinary(): string 132 + { 133 + if ($this->version === 0) { 134 + return $this->hash; 135 + } 136 + 137 + return chr($this->version) . $this->encodeVarint($this->codec) . $this->hash; 138 + } 139 + 140 + /** 141 + * Decode base32 string to binary. 142 + */ 143 + private static function decodeBase32(string $str): string 144 + { 145 + $str = strtolower($str); 146 + $result = ''; 147 + $bits = 0; 148 + $value = 0; 149 + 150 + for ($i = 0; $i < strlen($str); $i++) { 151 + $char = $str[$i]; 152 + $pos = strpos(self::BASE32_CHARSET, $char); 153 + 154 + if ($pos === false) { 155 + throw new RuntimeException("Invalid base32 character: {$char}"); 156 + } 157 + 158 + $value = ($value << 5) | $pos; 159 + $bits += 5; 160 + 161 + if ($bits >= 8) { 162 + $result .= chr(($value >> ($bits - 8)) & 0xFF); 163 + $bits -= 8; 164 + } 165 + } 166 + 167 + return $result; 168 + } 169 + 170 + /** 171 + * Encode binary to base32 string. 172 + */ 173 + private static function encodeBase32(string $data): string 174 + { 175 + $result = ''; 176 + $bits = 0; 177 + $value = 0; 178 + 179 + for ($i = 0; $i < strlen($data); $i++) { 180 + $value = ($value << 8) | ord($data[$i]); 181 + $bits += 8; 182 + 183 + while ($bits >= 5) { 184 + $result .= self::BASE32_CHARSET[($value >> ($bits - 5)) & 0x1F]; 185 + $bits -= 5; 186 + } 187 + } 188 + 189 + if ($bits > 0) { 190 + $result .= self::BASE32_CHARSET[($value << (5 - $bits)) & 0x1F]; 191 + } 192 + 193 + return $result; 194 + } 195 + 196 + /** 197 + * Decode base58btc string to binary. 198 + */ 199 + private static function decodeBase58(string $str): string 200 + { 201 + $decoded = gmp_init(0); 202 + $base = gmp_init(58); 203 + 204 + for ($i = 0; $i < strlen($str); $i++) { 205 + $char = $str[$i]; 206 + $pos = strpos(self::BASE58BTC_CHARSET, $char); 207 + 208 + if ($pos === false) { 209 + throw new RuntimeException("Invalid base58 character: {$char}"); 210 + } 211 + 212 + $decoded = gmp_add(gmp_mul($decoded, $base), gmp_init($pos)); 213 + } 214 + 215 + $hex = gmp_strval($decoded, 16); 216 + if (strlen($hex) % 2) { 217 + $hex = '0' . $hex; 218 + } 219 + 220 + // Add leading zeros 221 + for ($i = 0; $i < strlen($str) && $str[$i] === '1'; $i++) { 222 + $hex = '00' . $hex; 223 + } 224 + 225 + return hex2bin($hex); 226 + } 227 + 228 + /** 229 + * Encode binary to base58btc string. 230 + */ 231 + private static function encodeBase58(string $data): string 232 + { 233 + $num = gmp_init('0x' . bin2hex($data)); 234 + $base = gmp_init(58); 235 + $result = ''; 236 + 237 + while (gmp_cmp($num, 0) > 0) { 238 + [$num, $remainder] = gmp_div_qr($num, $base); 239 + $result = self::BASE58BTC_CHARSET[gmp_intval($remainder)] . $result; 240 + } 241 + 242 + // Add leading '1's for leading zero bytes 243 + for ($i = 0; $i < strlen($data) && ord($data[$i]) === 0; $i++) { 244 + $result = '1' . $result; 245 + } 246 + 247 + return $result; 248 + } 249 + 250 + /** 251 + * Encode varint for CID binary format. 252 + */ 253 + private function encodeVarint(int $value): string 254 + { 255 + $result = ''; 256 + 257 + while ($value >= 0x80) { 258 + $result .= chr(($value & 0x7F) | 0x80); 259 + $value >>= 7; 260 + } 261 + 262 + $result .= chr($value); 263 + 264 + return $result; 265 + } 266 + 267 + public function __toString(): string 268 + { 269 + return $this->toString(); 270 + } 271 + }
+100
tests/Unit/CIDTest.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace SocialDept\Signal\Tests\Unit; 6 + 7 + use PHPUnit\Framework\TestCase; 8 + use SocialDept\Signal\Core\CID; 9 + 10 + class CIDTest extends TestCase 11 + { 12 + public function test_parse_binary_cidv1(): void 13 + { 14 + // CIDv1: version=1, codec=0x71 (dag-cbor), sha256 hash 15 + $hash = str_repeat("\x00", 32); 16 + $binary = "\x01\x71\x12\x20" . $hash; 17 + 18 + $cid = CID::fromBinary($binary); 19 + 20 + $this->assertSame(1, $cid->version); 21 + $this->assertSame(0x71, $cid->codec); 22 + $this->assertSame("\x12\x20" . $hash, $cid->hash); 23 + } 24 + 25 + public function test_parse_binary_cidv0(): void 26 + { 27 + // CIDv0: starts with 0x12 (sha256) 0x20 (32 bytes) 28 + $hash = str_repeat("\x00", 32); 29 + $binary = "\x12\x20" . $hash; 30 + 31 + $cid = CID::fromBinary($binary); 32 + 33 + $this->assertSame(0, $cid->version); 34 + $this->assertSame(0x70, $cid->codec); // dag-pb 35 + $this->assertSame("\x12\x20" . $hash, $cid->hash); 36 + } 37 + 38 + public function test_to_string_cidv1(): void 39 + { 40 + $hash = str_repeat("\x00", 32); 41 + $binary = "\x01\x71\x12\x20" . $hash; 42 + $cid = CID::fromBinary($binary); 43 + 44 + $str = $cid->toString(); 45 + 46 + // Should start with 'b' (base32 prefix) 47 + $this->assertStringStartsWith('b', $str); 48 + 49 + // Should be able to parse it back 50 + $parsed = CID::fromString($str); 51 + $this->assertSame($cid->version, $parsed->version); 52 + $this->assertSame($cid->codec, $parsed->codec); 53 + } 54 + 55 + public function test_to_binary_cidv1(): void 56 + { 57 + $hash = str_repeat("\x00", 32); 58 + $binary = "\x01\x71\x12\x20" . $hash; 59 + $cid = CID::fromBinary($binary); 60 + 61 + $this->assertSame($binary, $cid->toBinary()); 62 + } 63 + 64 + public function test_round_trip_binary(): void 65 + { 66 + $hash = hash('sha256', 'test', true); 67 + $binary = "\x01\x71\x12\x20" . $hash; 68 + 69 + $cid = CID::fromBinary($binary); 70 + $encoded = $cid->toBinary(); 71 + $decoded = CID::fromBinary($encoded); 72 + 73 + $this->assertSame($cid->version, $decoded->version); 74 + $this->assertSame($cid->codec, $decoded->codec); 75 + $this->assertSame($cid->hash, $decoded->hash); 76 + } 77 + 78 + public function test_round_trip_string(): void 79 + { 80 + $hash = hash('sha256', 'test', true); 81 + $binary = "\x01\x71\x12\x20" . $hash; 82 + $cid = CID::fromBinary($binary); 83 + 84 + $str = $cid->toString(); 85 + $parsed = CID::fromString($str); 86 + 87 + $this->assertSame($cid->version, $parsed->version); 88 + $this->assertSame($cid->codec, $parsed->codec); 89 + $this->assertSame($cid->hash, $parsed->hash); 90 + } 91 + 92 + public function test_to_string_magic_method(): void 93 + { 94 + $hash = str_repeat("\x00", 32); 95 + $binary = "\x01\x71\x12\x20" . $hash; 96 + $cid = CID::fromBinary($binary); 97 + 98 + $this->assertSame($cid->toString(), (string) $cid); 99 + } 100 + }