Parse and validate AT Protocol Lexicons with DTO generation for Laravel
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}