Passed
Pull Request — master (#7353)
by
unknown
09:41
created

AiController::fetchUrlAsBase64()   A

Complexity

Conditions 6
Paths 4

Size

Total Lines 31
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 18
nc 4
nop 2
dl 0
loc 31
rs 9.0444
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
/* For licensing terms, see /license.txt */
6
7
namespace Chamilo\CoreBundle\Controller;
8
9
use Chamilo\CoreBundle\AiProvider\AiImageProviderInterface;
10
use Chamilo\CoreBundle\AiProvider\AiProviderFactory;
11
use Chamilo\CoreBundle\AiProvider\AiVideoProviderInterface;
12
use Chamilo\CoreBundle\Entity\TrackEDefault;
13
use Chamilo\CoreBundle\Entity\TrackEExercise;
14
use Chamilo\CoreBundle\Repository\TrackEAttemptRepository;
15
use Chamilo\CourseBundle\Entity\CQuizAnswer;
16
use DateTime;
17
use Doctrine\ORM\EntityManagerInterface;
18
use Exception;
19
use Question;
20
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
21
use Symfony\Component\HttpFoundation\JsonResponse;
22
use Symfony\Component\HttpFoundation\Request;
23
use Symfony\Component\Routing\Attribute\Route;
24
use Symfony\Contracts\HttpClient\HttpClientInterface;
25
26
use const FILTER_SANITIZE_NUMBER_INT;
27
28
#[Route('/ai')]
29
class AiController extends AbstractController
30
{
31
    public function __construct(
32
        private readonly AiProviderFactory $aiProviderFactory,
33
        private readonly TrackEAttemptRepository $attemptRepo,
34
        private readonly EntityManagerInterface $em,
35
        private readonly HttpClientInterface $httpClient,
36
    ) {}
37
38
    #[Route('/capabilities', name: 'chamilo_core_ai_capabilities', methods: ['GET'])]
39
    public function capabilities(): JsonResponse
40
    {
41
        return new JsonResponse([
42
            'success' => true,
43
            'types' => [
44
                'text' => $this->aiProviderFactory->getProvidersForType('text'),
45
                'image' => $this->aiProviderFactory->getProvidersForType('image'),
46
                'video' => $this->aiProviderFactory->getProvidersForType('video'),
47
                'document' => $this->aiProviderFactory->getProvidersForType('document'),
48
                'document_process' => $this->aiProviderFactory->getProvidersForType('document_process'),
49
            ],
50
            'has' => [
51
                'text' => $this->aiProviderFactory->hasProvidersForType('text'),
52
                'image' => $this->aiProviderFactory->hasProvidersForType('image'),
53
                'video' => $this->aiProviderFactory->hasProvidersForType('video'),
54
                'document' => $this->aiProviderFactory->hasProvidersForType('document'),
55
            ],
56
        ]);
57
    }
58
59
    #[Route('/generate_aiken', name: 'chamilo_core_ai_generate_aiken', methods: ['POST'])]
60
    public function generateAiken(Request $request): JsonResponse
61
    {
62
        try {
63
            $data = json_decode($request->getContent(), true);
64
            $nQ = (int) ($data['nro_questions'] ?? 0);
65
            $language = (string) ($data['language'] ?? 'en');
66
            $topic = trim((string) ($data['quiz_name'] ?? ''));
67
            $questionType = (string) ($data['question_type'] ?? 'multiple_choice');
68
            $aiProvider = $data['ai_provider'] ?? null;
69
70
            if ($nQ <= 0 || '' === $topic) {
71
                return new JsonResponse([
72
                    'success' => false,
73
                    'text' => 'Invalid request parameters. Ensure all fields are filled correctly.',
74
                ], 400);
75
            }
76
77
            $aiService = $this->aiProviderFactory->getProvider($aiProvider, 'text');
78
            $questions = $aiService->generateQuestions($topic, $nQ, $questionType, $language);
79
80
            if (empty($questions)) {
81
                return new JsonResponse([
82
                    'success' => false,
83
                    'text' => 'AI request returned an empty response.',
84
                ], 500);
85
            }
86
87
            if (\is_string($questions) && str_starts_with($questions, 'Error:')) {
88
                return new JsonResponse([
89
                    'success' => false,
90
                    'text' => $questions,
91
                ], 500);
92
            }
93
94
            return new JsonResponse([
95
                'success' => true,
96
                'text' => trim((string) $questions),
97
            ]);
98
        } catch (Exception $e) {
99
            error_log('[AI] Aiken generation failed: '.$e->getMessage());
100
101
            return new JsonResponse([
102
                'success' => false,
103
                'text' => 'An error occurred while generating questions. Please contact the administrator.',
104
            ], 500);
105
        }
106
    }
107
108
    #[Route('/open_answer_grade', name: 'chamilo_core_ai_open_answer_grade', methods: ['POST'])]
109
    public function openAnswerGrade(Request $request): JsonResponse
110
    {
111
        $exeId = $request->request->getInt('exeId', 0);
112
        $questionId = $request->request->getInt('questionId', 0);
113
        $courseId = $request->request->getInt('courseId', 0);
114
115
        if (0 === $exeId || 0 === $questionId || 0 === $courseId) {
116
            return $this->json(['error' => 'Missing parameters'], 400);
117
        }
118
119
        /** @var TrackEExercise|null $trackExercise */
120
        $trackExercise = $this->em->getRepository(TrackEExercise::class)->find($exeId);
121
        if (null === $trackExercise) {
122
            return $this->json(['error' => 'Exercise attempt not found'], 404);
123
        }
124
125
        $attempt = $this->attemptRepo->findOneBy([
126
            'trackExercise' => $trackExercise,
127
            'questionId' => $questionId,
128
            'user' => $trackExercise->getUser(),
129
        ]);
130
        if (null === $attempt) {
131
            return $this->json(['error' => 'Attempt not found'], 404);
132
        }
133
134
        $answerText = $attempt->getAnswer();
135
136
        if (ctype_digit($answerText)) {
137
            $cqa = $this->em->getRepository(CQuizAnswer::class)->find((int) $answerText);
138
            if ($cqa) {
139
                $answerText = $cqa->getAnswer();
140
            }
141
        }
142
143
        $courseInfo = api_get_course_info_by_id($courseId);
144
        if (empty($courseInfo['real_id'])) {
145
            return $this->json(['error' => 'Course info not found'], 500);
146
        }
147
148
        $question = Question::read($questionId, $courseInfo);
149
        if (false === $question) {
150
            return $this->json(['error' => 'Question not found'], 404);
151
        }
152
153
        $language = $courseInfo['language'] ?? 'en';
154
        $courseTitle = $courseInfo['title'] ?? '';
155
        $maxScore = $question->selectWeighting();
156
        $questionText = $question->selectTitle();
157
158
        $prompt = \sprintf(
159
            "In language %s, for the question: '%s', in the context of %s, provide a score between 0 and %d on one line, then feedback on the next line for the following answer: '%s'.",
160
            $language,
161
            $questionText,
162
            $courseTitle,
163
            $maxScore,
164
            $answerText
165
        );
166
167
        $provider = $this->aiProviderFactory->getProvider(null, 'text');
168
        $raw = trim((string) $provider->gradeOpenAnswer($prompt, 'open_answer_grade'));
169
170
        if ('' === $raw) {
171
            return $this->json(['error' => 'AI request failed'], 500);
172
        }
173
174
        if (str_contains($raw, "\n")) {
175
            [$scoreLine, $feedback] = explode("\n", $raw, 2);
176
        } else {
177
            $scoreLine = (string) $maxScore;
178
            $feedback = $raw;
179
        }
180
181
        $score = (int) filter_var($scoreLine, FILTER_SANITIZE_NUMBER_INT);
182
183
        $track = new TrackEDefault();
184
        $track
185
            ->setDefaultUserId($this->getUser()->getId())
186
            ->setDefaultEventType('ai_use_question_feedback')
187
            ->setDefaultValueType('attempt_id')
188
            ->setDefaultValue((string) $attempt->getId())
189
            ->setDefaultDate(new DateTime())
190
            ->setCId($courseId)
191
            ->setSessionId(api_get_session_id())
192
        ;
193
194
        $this->em->persist($track);
195
        $this->em->flush();
196
197
        return $this->json([
198
            'score' => $score,
199
            'feedback' => $feedback,
200
        ]);
201
    }
202
203
    #[Route('/generate_image', name: 'chamilo_core_ai_generate_image', methods: ['POST'])]
204
    public function generateImage(Request $request): JsonResponse
205
    {
206
        try {
207
            $this->denyIfNotTeacher();
208
209
            $data = json_decode($request->getContent(), true);
210
211
            $n = (int) ($data['n'] ?? 1);
212
            $language = (string) ($data['language'] ?? 'en');
213
            $prompt = trim((string) ($data['prompt'] ?? ''));
214
            $toolName = trim((string) ($data['tool'] ?? 'document'));
215
            $aiProvider = $data['ai_provider'] ?? null;
216
217
            if ($n <= 0 || '' === $prompt || '' === $toolName) {
218
                return new JsonResponse([
219
                    'success' => false,
220
                    'text' => 'Invalid request parameters. Ensure all fields are filled correctly.',
221
                ], 400);
222
            }
223
224
            $availableProviders = $this->aiProviderFactory->getProvidersForType('image');
225
            if (empty($availableProviders)) {
226
                return new JsonResponse([
227
                    'success' => false,
228
                    'text' => 'No AI providers available for image generation.',
229
                ], 400);
230
            }
231
232
            $explicitProvider = null;
233
            if (null !== $aiProvider && '' !== (string) $aiProvider) {
234
                $explicitProvider = (string) $aiProvider;
235
236
                if (!in_array($explicitProvider, $availableProviders, true)) {
237
                    return new JsonResponse([
238
                        'success' => false,
239
                        'text' => 'Selected AI provider is not available for image generation.',
240
                    ], 400);
241
                }
242
            }
243
244
            $providersToTry = $explicitProvider ? [$explicitProvider] : $availableProviders;
245
            $errors = [];
246
            $providerUsed = null;
247
            $result = null;
248
249
            foreach ($providersToTry as $providerName) {
250
                try {
251
                    $aiService = $this->aiProviderFactory->getProvider($providerName, 'image');
252
253
                    if (!$aiService instanceof AiImageProviderInterface) {
254
                        $errors[$providerName] = 'Provider does not implement image generation interface.';
255
                        continue;
256
                    }
257
258
                    $result = $aiService->generateImage($prompt, $toolName, [
259
                        'language' => $language,
260
                        'n' => $n,
261
                    ]);
262
263
                    if (empty($result)) {
264
                        $errors[$providerName] = 'Provider returned an empty response.';
265
                        continue;
266
                    }
267
268
                    if (\is_string($result) && str_starts_with($result, 'Error:')) {
269
                        $errors[$providerName] = $result;
270
                        $result = null;
271
                        continue;
272
                    }
273
274
                    $providerUsed = $providerName;
275
                    break;
276
                } catch (\Throwable $e) {
277
                    $errors[$providerName] = $e->getMessage();
278
                    continue;
279
                }
280
            }
281
282
            if (null === $providerUsed || empty($result)) {
283
                error_log('[AI][image] Image generation failed for all providers: '.json_encode($errors));
284
285
                return new JsonResponse([
286
                    'success' => false,
287
                    'text' => $explicitProvider
288
                        ? 'Image generation failed for the selected provider.'
289
                        : 'All image providers failed.',
290
                    'providers_tried' => $providersToTry,
291
                    'errors' => $errors,
292
                ], 500);
293
            }
294
295
            if (\is_string($result)) {
296
                $normalized = [
297
                    'content' => trim($result),
298
                    'url' => null,
299
                    'is_base64' => true,
300
                    'content_type' => 'image/png',
301
                    'revised_prompt' => null,
302
                ];
303
304
                return new JsonResponse([
305
                    'success' => true,
306
                    'text' => $normalized['content'],
307
                    'result' => $normalized,
308
                    'provider_used' => $providerUsed,
309
                    'providers_tried' => $providersToTry,
310
                    'errors' => $errors,
311
                ]);
312
            }
313
314
            $content = isset($result['content']) && \is_string($result['content']) ? trim($result['content']) : '';
315
            $url = isset($result['url']) && \is_string($result['url']) ? trim($result['url']) : '';
316
            $isBase64 = (bool) ($result['is_base64'] ?? false);
317
            $contentType = (string) ($result['content_type'] ?? 'image/png');
318
319
            if (!$isBase64 && '' === $content && '' !== $url) {
320
                try {
321
                    $fetched = $this->fetchUrlAsBase64($url, 10 * 1024 * 1024);
322
                    $result['content'] = $fetched['content'];
323
                    $result['content_type'] = $fetched['content_type'];
324
                    $result['is_base64'] = true;
325
                    $result['url'] = null;
326
                } catch (\Throwable $e) {
327
                    error_log('[AI][image] Failed to fetch image URL as base64: '.$e->getMessage());
328
329
                    return new JsonResponse([
330
                        'success' => false,
331
                        'text' => 'Image was generated, but could not be converted to base64 for preview.',
332
                    ], 500);
333
                }
334
            }
335
336
            $text = '';
337
            if (!empty($result['content']) && \is_string($result['content'])) {
338
                $text = trim($result['content']);
339
            }
340
341
            return new JsonResponse([
342
                'success' => true,
343
                'text' => $text,
344
                'result' => $result,
345
                'provider_used' => $providerUsed,
346
                'providers_tried' => $providersToTry,
347
                'errors' => $errors,
348
            ]);
349
        } catch (Exception $e) {
350
            error_log('[AI][image] Controller exception: '.$e->getMessage());
351
352
            return new JsonResponse([
353
                'success' => false,
354
                'text' => 'An error occurred while generating the image. Please contact the administrator.',
355
            ], 500);
356
        }
357
    }
358
359
    #[Route('/generate_video', name: 'chamilo_core_ai_generate_video', methods: ['POST'])]
360
    public function generateVideo(Request $request): JsonResponse
361
    {
362
        try {
363
            $this->denyIfNotTeacher();
364
365
            $data = json_decode($request->getContent(), true);
366
            if (!is_array($data)) {
367
                return new JsonResponse([
368
                    'success' => false,
369
                    'text' => 'Invalid JSON payload.',
370
                ], 400);
371
            }
372
373
            $n = (int) ($data['n'] ?? 1);
374
            $language = (string) ($data['language'] ?? 'en');
375
            $prompt = trim((string) ($data['prompt'] ?? ''));
376
            $toolName = trim((string) ($data['tool'] ?? 'document'));
377
            $aiProvider = $data['ai_provider'] ?? null;
378
379
            // Optional overrides (if later you decide to send them from the UI)
380
            $seconds = isset($data['seconds']) ? (string) $data['seconds'] : null;
381
            $size = isset($data['size']) ? (string) $data['size'] : null;
382
383
            if ($n <= 0 || '' === $prompt || '' === $toolName) {
384
                return new JsonResponse([
385
                    'success' => false,
386
                    'text' => 'Invalid request parameters. Ensure all fields are filled correctly.',
387
                ], 400);
388
            }
389
390
            $availableProviders = $this->aiProviderFactory->getProvidersForType('video');
391
            if (empty($availableProviders)) {
392
                return new JsonResponse([
393
                    'success' => false,
394
                    'text' => 'No AI providers available for video generation.',
395
                ], 400);
396
            }
397
398
            $explicitProvider = null;
399
            if (null !== $aiProvider && '' !== (string) $aiProvider) {
400
                $explicitProvider = (string) $aiProvider;
401
402
                if (!in_array($explicitProvider, $availableProviders, true)) {
403
                    return new JsonResponse([
404
                        'success' => false,
405
                        'text' => 'Selected AI provider is not available for video generation.',
406
                    ], 400);
407
                }
408
            }
409
410
            $providersToTry = $explicitProvider ? [$explicitProvider] : $availableProviders;
411
            $errors = [];
412
            $providerUsed = null;
413
            $result = null;
414
415
            foreach ($providersToTry as $providerName) {
416
                try {
417
                    $aiService = $this->aiProviderFactory->getProvider($providerName, 'video');
418
419
                    if (!$aiService instanceof AiVideoProviderInterface) {
420
                        $errors[$providerName] = 'Provider does not implement video generation interface.';
421
                        continue;
422
                    }
423
424
                    $options = [
425
                        'language' => $language,
426
                        'n' => $n,
427
                    ];
428
429
                    if (null !== $seconds && '' !== trim($seconds)) {
430
                        $options['seconds'] = trim($seconds);
431
                    }
432
                    if (null !== $size && '' !== trim($size)) {
433
                        $options['size'] = trim($size);
434
                    }
435
436
                    $result = $aiService->generateVideo($prompt, $toolName, $options);
437
438
                    if (empty($result)) {
439
                        $errors[$providerName] = 'Provider returned an empty response.';
440
                        $result = null;
441
                        continue;
442
                    }
443
444
                    // Critical fix: treat "Error: ..." as a real failure (try next provider in AUTO).
445
                    if (\is_string($result) && str_starts_with($result, 'Error:')) {
446
                        $errors[$providerName] = $result;
447
                        $result = null;
448
                        continue;
449
                    }
450
451
                    $providerUsed = $providerName;
452
                    break;
453
                } catch (\Throwable $e) {
454
                    $errors[$providerName] = $e->getMessage();
455
                    $result = null;
456
                    continue;
457
                }
458
            }
459
460
            if (null === $providerUsed || empty($result)) {
461
                error_log('[AI][video] Video generation failed for all providers: '.json_encode($errors));
462
463
                $firstError = '';
464
                foreach ($errors as $err) {
465
                    if (is_string($err) && '' !== trim($err)) {
466
                        $firstError = trim($err);
467
                        break;
468
                    }
469
                }
470
471
                $message = $firstError !== '' ? preg_replace('/^Error:\s*/', '', $firstError) : (
472
                $explicitProvider
473
                    ? 'Video generation failed for the selected provider.'
474
                    : 'All video providers failed.'
475
                );
476
477
                $statusCode = $this->mapVideoErrorToHttpStatus($message);
478
479
                return new JsonResponse([
480
                    'success' => false,
481
                    'text' => $message,
482
                    'providers_tried' => $providersToTry,
483
                    'errors' => $errors,
484
                ], $statusCode);
485
            }
486
487
            // Normalize string response (best-effort).
488
            if (\is_string($result)) {
489
                $raw = trim($result);
490
491
                $normalized = [
492
                    'content' => null,
493
                    'url' => null,
494
                    'id' => null,
495
                    'status' => null,
496
                    'is_base64' => false,
497
                    'content_type' => 'video/mp4',
498
                    'revised_prompt' => null,
499
                ];
500
501
                if ($this->looksLikeUrl($raw)) {
502
                    $normalized['url'] = $raw;
503
                } elseif ($this->looksLikeBase64($raw)) {
504
                    $normalized['content'] = $raw;
505
                    $normalized['is_base64'] = true;
506
                } else {
507
                    // Treat as a job id or opaque identifier.
508
                    $normalized['id'] = $raw;
509
                }
510
511
                return new JsonResponse([
512
                    'success' => true,
513
                    'text' => (string) ($normalized['url'] ?? $normalized['content'] ?? $normalized['id'] ?? ''),
514
                    'result' => $normalized,
515
                    'provider_used' => $providerUsed,
516
                    'providers_tried' => $providersToTry,
517
                    'errors' => $errors,
518
                ]);
519
            }
520
521
            // Array response: allow url/content and job-based (id/status).
522
            if (!is_array($result)) {
523
                return new JsonResponse([
524
                    'success' => false,
525
                    'text' => 'Provider returned an unsupported response type.',
526
                ], 500);
527
            }
528
529
            $result['is_base64'] = (bool) ($result['is_base64'] ?? false);
530
            $result['content_type'] = (string) ($result['content_type'] ?? 'video/mp4');
531
            $result['revised_prompt'] = $result['revised_prompt'] ?? null;
532
533
            $url = isset($result['url']) && \is_string($result['url']) ? trim($result['url']) : '';
534
            $content = isset($result['content']) && \is_string($result['content']) ? trim($result['content']) : '';
535
536
            // If URL returned, try to inline as base64 (limited) to enable save/preview.
537
            if (empty($content) && !empty($url) && false === (bool) ($result['is_base64'] ?? false)) {
538
                try {
539
                    $fetched = $this->fetchUrlAsBase64($url, 15 * 1024 * 1024);
540
                    $result['content'] = $fetched['content'];
541
                    $result['content_type'] = $fetched['content_type'];
542
                    $result['is_base64'] = true;
543
                    $result['url'] = null;
544
                } catch (\Throwable $e) {
545
                    // Keep URL mode (preview OK, saving disabled in frontend).
546
                    error_log('[AI][video] Failed to fetch video URL as base64: '.$e->getMessage());
547
                }
548
            }
549
550
            $text = '';
551
            if (isset($result['url']) && \is_string($result['url']) && '' !== trim($result['url'])) {
552
                $text = trim($result['url']);
553
            } elseif (isset($result['content']) && \is_string($result['content']) && '' !== trim($result['content'])) {
554
                $text = trim($result['content']);
555
            } elseif (isset($result['id']) && \is_string($result['id']) && '' !== trim($result['id'])) {
556
                $text = trim($result['id']);
557
            }
558
559
            return new JsonResponse([
560
                'success' => true,
561
                'text' => $text,
562
                'result' => $result,
563
                'provider_used' => $providerUsed,
564
                'providers_tried' => $providersToTry,
565
                'errors' => $errors,
566
            ]);
567
        } catch (Exception $e) {
568
            error_log('[AI][video] Video generation failed: '.$e->getMessage());
569
570
            return new JsonResponse([
571
                'success' => false,
572
                'text' => 'An error occurred while generating the video. Please contact the administrator.',
573
            ], 500);
574
        }
575
    }
576
577
    /**
578
     * Returns a reasonable HTTP status code for known provider errors.
579
     */
580
    private function mapVideoErrorToHttpStatus(string $message): int
581
    {
582
        $m = strtolower(trim($message));
583
584
        if ($m === '') {
585
            return 500;
586
        }
587
588
        // OpenAI typical cases
589
        if (str_contains($m, 'invalid api key') || str_contains($m, 'incorrect api key') || str_contains($m, 'unauthorized')) {
590
            return 401;
591
        }
592
593
        if (str_contains($m, 'must be verified') || str_contains($m, 'verify organization') || str_contains($m, 'organization must be verified')) {
594
            return 403;
595
        }
596
597
        if (str_contains($m, 'does not have access') || str_contains($m, 'not authorized') || str_contains($m, 'permission')) {
598
            return 403;
599
        }
600
601
        if (str_contains($m, 'rate limit') || str_contains($m, 'too many requests')) {
602
            return 429;
603
        }
604
605
        if (str_contains($m, 'insufficient_quota') || str_contains($m, 'quota')) {
606
            return 402;
607
        }
608
609
        return 500;
610
    }
611
612
    private function looksLikeUrl(string $s): bool
613
    {
614
        $s = trim($s);
615
        if ($s === '') {
616
            return false;
617
        }
618
619
        return (bool) filter_var($s, FILTER_VALIDATE_URL);
620
    }
621
622
    private function looksLikeBase64(string $s): bool
623
    {
624
        $s = trim($s);
625
        if ($s === '' || strlen($s) < 64) {
626
            return false;
627
        }
628
629
        // Basic base64 charset check
630
        if (!preg_match('/^[A-Za-z0-9+\/=\r\n]+$/', $s)) {
631
            return false;
632
        }
633
634
        // Validate decode (strict)
635
        $decoded = base64_decode($s, true);
636
        if ($decoded === false) {
637
            return false;
638
        }
639
640
        // Video will likely be binary; just ensure not empty
641
        return $decoded !== '';
642
    }
643
644
    #[Route('/video_job/{id}', name: 'chamilo_core_ai_video_job', methods: ['GET'])]
645
    public function videoJobStatus(string $id, Request $request): JsonResponse
646
    {
647
        try {
648
            $this->denyIfNotTeacher();
649
650
            $aiProvider = $request->query->get('ai_provider'); // required when multiple providers exist
651
652
            $aiService = $this->aiProviderFactory->getProvider($aiProvider, 'video');
653
            if (!$aiService instanceof AiVideoProviderInterface) {
654
                return new JsonResponse([
655
                    'success' => false,
656
                    'text' => 'Selected AI provider does not support video generation.',
657
                ], 400);
658
            }
659
660
            if (!method_exists($aiService, 'getVideoJobStatus')) {
661
                return new JsonResponse([
662
                    'success' => false,
663
                    'text' => 'This AI provider does not expose a video job status method.',
664
                ], 400);
665
            }
666
667
            /** @var array|null $job */
668
            $job = $aiService->getVideoJobStatus($id);
669
            if (empty($job)) {
670
                return new JsonResponse([
671
                    'success' => false,
672
                    'text' => 'Failed to fetch video job status.',
673
                ], 500);
674
            }
675
676
            $status = (string) ($job['status'] ?? '');
677
678
            $result = [
679
                'id' => (string) ($job['id'] ?? $id),
680
                'status' => $status,
681
                'content' => null,
682
                'url' => null,
683
                'is_base64' => false,
684
                'content_type' => 'video/mp4',
685
                'revised_prompt' => null,
686
            ];
687
688
            // Only attempt to fetch content when the job is completed.
689
            if (in_array($status, ['completed', 'succeeded', 'done'], true)) {
690
                if (method_exists($aiService, 'getVideoJobContentAsBase64')) {
691
                    $maxBytes = 15 * 1024 * 1024;
692
693
                    $content = $aiService->getVideoJobContentAsBase64($id, $maxBytes);
694
695
                    if (is_array($content)) {
696
                        $result['is_base64'] = (bool) ($content['is_base64'] ?? false);
697
                        $result['content'] = $content['content'] ?? null;
698
                        $result['url'] = $content['url'] ?? null;
699
                        $result['content_type'] = (string) ($content['content_type'] ?? 'video/mp4');
700
701
                        if (!empty($content['error'])) {
702
                            return new JsonResponse([
703
                                'success' => true,
704
                                'text' => (string) $content['error'],
705
                                'result' => $result,
706
                                'provider_used' => $aiProvider,
707
                            ]);
708
                        }
709
                    }
710
                }
711
            }
712
713
            return new JsonResponse([
714
                'success' => true,
715
                'text' => '',
716
                'result' => $result,
717
                'provider_used' => $aiProvider,
718
            ]);
719
        } catch (Exception $e) {
720
            error_log('[AI][video] Video job status failed: '.$e->getMessage());
721
722
            return new JsonResponse([
723
                'success' => false,
724
                'text' => 'An error occurred while checking the video status. Please contact the administrator.',
725
            ], 500);
726
        }
727
    }
728
729
    private function denyIfNotTeacher(): void
730
    {
731
        if (!$this->isGranted('ROLE_CURRENT_COURSE_TEACHER')
732
            && !$this->isGranted('ROLE_CURRENT_COURSE_SESSION_TEACHER')
733
            && !$this->isGranted('ROLE_TEACHER')
734
        ) {
735
            throw new \RuntimeException('Access denied.');
736
        }
737
    }
738
739
    private function fetchUrlAsBase64(string $url, int $maxBytes = 10485760): array
740
    {
741
        if (!$this->isSafeRemoteUrl($url)) {
742
            throw new \RuntimeException('Remote URL is not allowed.');
743
        }
744
745
        $response = $this->httpClient->request('GET', $url, [
746
            'headers' => [
747
                'Accept' => '*/*',
748
            ],
749
        ]);
750
751
        $headers = $response->getHeaders(false);
752
        $contentType = $headers['content-type'][0] ?? 'application/octet-stream';
753
754
        $lenHeader = $headers['content-length'][0] ?? null;
755
        if (null !== $lenHeader && is_numeric($lenHeader) && (int) $lenHeader > $maxBytes) {
756
            throw new \RuntimeException('Remote content is too large to inline as base64.');
757
        }
758
759
        $binary = $response->getContent(false);
760
761
        if (strlen($binary) > $maxBytes) {
762
            throw new \RuntimeException('Remote content exceeded the maximum allowed size.');
763
        }
764
765
        return [
766
            'content' => base64_encode($binary),
767
            'content_type' => (string) $contentType,
768
            'is_base64' => true,
769
            'url' => null,
770
        ];
771
    }
772
773
    private function isSafeRemoteUrl(string $url): bool
774
    {
775
        $parts = parse_url($url);
776
        if (!is_array($parts)) {
777
            return false;
778
        }
779
780
        $scheme = strtolower((string) ($parts['scheme'] ?? ''));
781
        if (!in_array($scheme, ['https'], true)) {
782
            return false;
783
        }
784
785
        $host = strtolower((string) ($parts['host'] ?? ''));
786
        if ('' === $host) {
787
            return false;
788
        }
789
790
        if (in_array($host, ['localhost', '127.0.0.1', '::1'], true)) {
791
            return false;
792
        }
793
794
        $ip = gethostbyname($host);
795
        if (filter_var($ip, FILTER_VALIDATE_IP)) {
796
            // Block private/reserved ranges (basic SSRF hardening).
797
            if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
798
                return false;
799
            }
800
        }
801
802
        return true;
803
    }
804
}
805