Parse and validate AT Protocol Lexicons with DTO generation for Laravel
at main 355 lines 9.5 kB view raw
1<?php 2 3namespace SocialDept\AtpSchema\Services; 4 5use Illuminate\Contracts\Filesystem\Filesystem; 6use Illuminate\Http\UploadedFile; 7use Illuminate\Support\Facades\Storage; 8use Illuminate\Support\Traits\Macroable; 9use SocialDept\AtpSchema\Data\BlobReference; 10use SocialDept\AtpSchema\Exceptions\RecordValidationException; 11 12class BlobHandler 13{ 14 use Macroable; 15 16 /** 17 * Storage disk name. 18 */ 19 protected string $disk; 20 21 /** 22 * Base path for blob storage. 23 */ 24 protected string $basePath; 25 26 /** 27 * Create a new BlobHandler. 28 */ 29 public function __construct(?string $disk = null, string $basePath = 'blobs') 30 { 31 $this->disk = $disk ?? config('filesystems.default', 'local'); 32 $this->basePath = $basePath; 33 } 34 35 /** 36 * Store a blob from an uploaded file. 37 */ 38 public function store(UploadedFile $file, ?array $constraints = null): BlobReference 39 { 40 // Validate constraints if provided 41 if ($constraints !== null) { 42 $this->validateConstraints($file, $constraints); 43 } 44 45 // Generate CID-like identifier (in production, this would be actual CID) 46 $cid = $this->generateCid($file); 47 48 // Store the file 49 $path = $this->getPath($cid); 50 $this->getStorage()->put($path, $file->get()); 51 52 return new BlobReference( 53 ref: $cid, 54 mimeType: $file->getMimeType() ?? 'application/octet-stream', 55 size: $file->getSize() 56 ); 57 } 58 59 /** 60 * Store a blob from string content. 61 */ 62 public function storeFromString(string $content, string $mimeType, ?array $constraints = null): BlobReference 63 { 64 $size = strlen($content); 65 66 // Validate constraints if provided 67 if ($constraints !== null) { 68 $this->validateStringConstraints($content, $mimeType, $size, $constraints); 69 } 70 71 // Generate CID 72 $cid = $this->generateCidFromContent($content); 73 74 // Store the content 75 $path = $this->getPath($cid); 76 $this->getStorage()->put($path, $content); 77 78 return new BlobReference( 79 ref: $cid, 80 mimeType: $mimeType, 81 size: $size 82 ); 83 } 84 85 /** 86 * Retrieve blob content. 87 */ 88 public function get(string $cid): ?string 89 { 90 $path = $this->getPath($cid); 91 92 if (! $this->getStorage()->exists($path)) { 93 return null; 94 } 95 96 return $this->getStorage()->get($path); 97 } 98 99 /** 100 * Check if blob exists. 101 */ 102 public function exists(string $cid): bool 103 { 104 return $this->getStorage()->exists($this->getPath($cid)); 105 } 106 107 /** 108 * Delete a blob. 109 */ 110 public function delete(string $cid): bool 111 { 112 $path = $this->getPath($cid); 113 114 if (! $this->getStorage()->exists($path)) { 115 return false; 116 } 117 118 return $this->getStorage()->delete($path); 119 } 120 121 /** 122 * Get blob size. 123 */ 124 public function size(string $cid): ?int 125 { 126 $path = $this->getPath($cid); 127 128 if (! $this->getStorage()->exists($path)) { 129 return null; 130 } 131 132 return $this->getStorage()->size($path); 133 } 134 135 /** 136 * Validate blob against constraints. 137 */ 138 public function validate(BlobReference $blob, array $constraints): void 139 { 140 // Validate MIME type 141 if (isset($constraints['accept'])) { 142 $accepted = (array) $constraints['accept']; 143 $matches = false; 144 145 foreach ($accepted as $pattern) { 146 if ($blob->matchesMimeType($pattern)) { 147 $matches = true; 148 149 break; 150 } 151 } 152 153 if (! $matches) { 154 throw RecordValidationException::invalidValue( 155 'blob', 156 "MIME type '{$blob->mimeType}' not accepted. Allowed: ".implode(', ', $accepted) 157 ); 158 } 159 } 160 161 // Validate size constraints 162 if (isset($constraints['maxSize']) && $blob->size > $constraints['maxSize']) { 163 throw RecordValidationException::invalidValue( 164 'blob', 165 "Blob size {$blob->size} exceeds maximum {$constraints['maxSize']}" 166 ); 167 } 168 169 if (isset($constraints['minSize']) && $blob->size < $constraints['minSize']) { 170 throw RecordValidationException::invalidValue( 171 'blob', 172 "Blob size {$blob->size} is less than minimum {$constraints['minSize']}" 173 ); 174 } 175 } 176 177 /** 178 * Validate file against constraints. 179 */ 180 protected function validateConstraints(UploadedFile $file, array $constraints): void 181 { 182 $mimeType = $file->getMimeType() ?? 'application/octet-stream'; 183 $size = $file->getSize(); 184 185 // Validate MIME type 186 if (isset($constraints['accept'])) { 187 $accepted = (array) $constraints['accept']; 188 $matches = false; 189 190 foreach ($accepted as $pattern) { 191 if ($this->matchesMimeType($mimeType, $pattern)) { 192 $matches = true; 193 194 break; 195 } 196 } 197 198 if (! $matches) { 199 throw RecordValidationException::invalidValue( 200 'file', 201 "MIME type '{$mimeType}' not accepted. Allowed: ".implode(', ', $accepted) 202 ); 203 } 204 } 205 206 // Validate size 207 if (isset($constraints['maxSize']) && $size > $constraints['maxSize']) { 208 throw RecordValidationException::invalidValue( 209 'file', 210 "File size {$size} exceeds maximum {$constraints['maxSize']}" 211 ); 212 } 213 214 if (isset($constraints['minSize']) && $size < $constraints['minSize']) { 215 throw RecordValidationException::invalidValue( 216 'file', 217 "File size {$size} is less than minimum {$constraints['minSize']}" 218 ); 219 } 220 } 221 222 /** 223 * Validate string content against constraints. 224 */ 225 protected function validateStringConstraints(string $content, string $mimeType, int $size, array $constraints): void 226 { 227 // Validate MIME type 228 if (isset($constraints['accept'])) { 229 $accepted = (array) $constraints['accept']; 230 $matches = false; 231 232 foreach ($accepted as $pattern) { 233 if ($this->matchesMimeType($mimeType, $pattern)) { 234 $matches = true; 235 236 break; 237 } 238 } 239 240 if (! $matches) { 241 throw RecordValidationException::invalidValue( 242 'content', 243 "MIME type '{$mimeType}' not accepted. Allowed: ".implode(', ', $accepted) 244 ); 245 } 246 } 247 248 // Validate size 249 if (isset($constraints['maxSize']) && $size > $constraints['maxSize']) { 250 throw RecordValidationException::invalidValue( 251 'content', 252 "Content size {$size} exceeds maximum {$constraints['maxSize']}" 253 ); 254 } 255 256 if (isset($constraints['minSize']) && $size < $constraints['minSize']) { 257 throw RecordValidationException::invalidValue( 258 'content', 259 "Content size {$size} is less than minimum {$constraints['minSize']}" 260 ); 261 } 262 } 263 264 /** 265 * Check if MIME type matches pattern. 266 */ 267 protected function matchesMimeType(string $mimeType, string $pattern): bool 268 { 269 if (str_contains($pattern, '*')) { 270 $regex = '/^'.str_replace('\\*', '.*', preg_quote($pattern, '/')).'$/'; 271 272 return (bool) preg_match($regex, $mimeType); 273 } 274 275 return $mimeType === $pattern; 276 } 277 278 /** 279 * Generate a CID-like identifier from file. 280 */ 281 protected function generateCid(UploadedFile $file): string 282 { 283 // In production, this would generate an actual CID 284 // For now, use a hash-based approach 285 $hash = hash('sha256', $file->get()); 286 287 return 'bafyrei'.substr($hash, 0, 52); 288 } 289 290 /** 291 * Generate a CID-like identifier from content. 292 */ 293 protected function generateCidFromContent(string $content): string 294 { 295 $hash = hash('sha256', $content); 296 297 return 'bafyrei'.substr($hash, 0, 52); 298 } 299 300 /** 301 * Get storage path for CID. 302 */ 303 protected function getPath(string $cid): string 304 { 305 // Use first 2 chars for directory partitioning 306 $prefix = substr($cid, 0, 2); 307 $middle = substr($cid, 2, 2); 308 309 return "{$this->basePath}/{$prefix}/{$middle}/{$cid}"; 310 } 311 312 /** 313 * Get the storage instance. 314 */ 315 protected function getStorage(): Filesystem 316 { 317 return Storage::disk($this->disk); 318 } 319 320 /** 321 * Set the storage disk. 322 */ 323 public function setDisk(string $disk): self 324 { 325 $this->disk = $disk; 326 327 return $this; 328 } 329 330 /** 331 * Set the base path. 332 */ 333 public function setBasePath(string $basePath): self 334 { 335 $this->basePath = $basePath; 336 337 return $this; 338 } 339 340 /** 341 * Get the current disk name. 342 */ 343 public function getDisk(): string 344 { 345 return $this->disk; 346 } 347 348 /** 349 * Get the current base path. 350 */ 351 public function getBasePath(): string 352 { 353 return $this->basePath; 354 } 355}