Passed
Pull Request — master (#6795)
by
unknown
08:29
created

DropboxController::download()   C

Complexity

Conditions 14
Paths 11

Size

Total Lines 66
Code Lines 38

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 14
eloc 38
c 1
b 0
f 0
nc 11
nop 2
dl 0
loc 66
rs 6.2666

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Chamilo\CoreBundle\Controller;
6
7
use Chamilo\CoreBundle\Repository\ResourceNodeRepository;
8
use Chamilo\CourseBundle\Entity\CDropboxCategory;
9
use Chamilo\CourseBundle\Entity\CDropboxFeedback;
10
use Chamilo\CourseBundle\Repository\CDropboxCategoryRepository;
11
use Chamilo\CourseBundle\Repository\CDropboxFeedbackRepository;
12
use Chamilo\CourseBundle\Repository\CDropboxFileRepository;
13
use DateTimeImmutable;
14
use Doctrine\ORM\EntityManagerInterface;
15
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
16
use Symfony\Component\HttpFoundation\{BinaryFileResponse,
17
    File\UploadedFile,
18
    JsonResponse,
19
    Request,
20
    Response,
21
    ResponseHeaderBag,
22
    StreamedResponse};
23
use Symfony\Component\Mime\MimeTypes;
24
use Symfony\Component\Routing\Attribute\Route;
25
use Symfony\Component\String\Slugger\SluggerInterface;
26
27
#[Route('/dropbox')]
28
class DropboxController extends AbstractController
29
{
30
    private array $userNameCache = [];
31
32
    public function __construct(
33
        private readonly EntityManagerInterface $em,
34
        private readonly CDropboxCategoryRepository $categoryRepo,
35
        private readonly CDropboxFileRepository $fileRepo,
36
        private readonly CDropboxFeedbackRepository $feedbackRepo,
37
        private readonly SluggerInterface $slugger,
38
        private readonly ResourceNodeRepository $resourceNodeRepository
39
    ) {}
40
41
    private function humanSize(int $bytes): string
42
    {
43
        $units = ['B','KB','MB','GB','TB'];
44
        $i = $bytes > 0 ? (int)floor(log($bytes, 1024)) : 0;
45
        return sprintf('%.1f %s', $bytes / (1024 ** $i), $units[$i]);
46
    }
47
48
    private function ago(DateTimeImmutable $dt): string
49
    {
50
        $diff = (new DateTimeImmutable())->getTimestamp() - $dt->getTimestamp();
51
        if ($diff < 60) return 'just now';
52
        if ($diff < 3600) return floor($diff/60).' min ago';
53
        if ($diff < 86400) return floor($diff/3600).' h ago';
54
        return floor($diff/86400).' d ago';
55
    }
56
57
    /** Pull Chamilo context (cid/sid/gid) from query string */
58
    private function context(Request $r): array
59
    {
60
        $cid = (int) $r->query->get('cid', 0);
61
        $sid = $r->query->get('sid') ? (int) $r->query->get('sid') : null;
62
        $gid = $r->query->get('gid') ? (int) $r->query->get('gid') : null;
63
64
        return [$cid, $sid, $gid];
65
    }
66
67
    #[Route('/recipients', name: 'dropbox_recipients', methods: ['GET'])]
68
    public function recipients(Request $r): JsonResponse
69
    {
70
        [$cid, $sid, $gid] = $this->context($r);
71
        $me = (int) $this->getUser()?->getId();
72
73
        if ($cid <= 0) {
74
            $ref = (string) $r->headers->get('referer', '');
75
            if ($ref && preg_match('#/resources/dropbox/(\d+)/#', $ref, $m)) {
76
                $cid = (int) $m[1];
77
            }
78
        }
79
        if ($cid <= 0) {
80
            return $this->json(['message' => 'Missing course id (cid)'], 400);
81
        }
82
83
        $conn = $this->em->getConnection();
84
        $userRows = [];
85
86
        $sqlCourse = <<<SQL
87
          SELECT DISTINCT u.id, u.firstname, u.lastname
88
          FROM course_rel_user cru
89
          INNER JOIN user u ON u.id = cru.user_id
90
          WHERE cru.c_id = :cid
91
        SQL;
92
        $userRows = $conn->fetchAllAssociative($sqlCourse, ['cid' => $cid]);
93
94
        if (!empty($sid)) {
95
            $sqlSess = <<<SQL
96
              SELECT DISTINCT u.id, u.firstname, u.lastname
97
              FROM session_rel_course_rel_user scru
98
              INNER JOIN user u ON u.id = scru.user_id
99
              WHERE scru.c_id = :cid AND scru.session_id = :sid
100
            SQL;
101
            $more = $conn->fetchAllAssociative($sqlSess, ['cid' => $cid, 'sid' => (int) $sid]);
102
103
            $seen = [];
104
            foreach ($userRows as $row) { $seen[(int)$row['id']] = true; }
105
            foreach ($more as $row) {
106
                $uid = (int) $row['id'];
107
                if (!isset($seen[$uid])) {
108
                    $userRows[] = $row; $seen[$uid] = true;
109
                }
110
            }
111
        }
112
113
        $options = [];
114
        foreach ($userRows as $u) {
115
            $uid = (int) $u['id'];
116
            if ($uid === $me) { continue; }
117
            $label = trim(($u['firstname'] ?? '').' '.($u['lastname'] ?? '')) ?: ('User #'.$uid);
118
            $options[] = ['value' => 'user_'.$uid, 'label' => $label];
119
        }
120
121
        array_unshift($options, ['value' => 'self', 'label' => '— Just upload —']);
122
123
        return $this->json($options);
124
    }
125
126
    #[Route('/categories', name: 'dropbox_categories_list', methods: ['GET'])]
127
    public function listCategories(Request $r): JsonResponse
128
    {
129
        [$cid, $sid] = $this->context($r);
130
        $uid  = (int) $this->getUser()?->getId();
131
        $area = (string) $r->query->get('area', 'sent');
132
133
        $cats = $this->categoryRepo->findByContextAndArea($cid, $sid, $uid, $area);
134
135
        $rows = array_map(fn(CDropboxCategory $c) => [
136
            'id'    => $c->getCatId(),
137
            'title' => $c->getTitle(),
138
        ], $cats);
139
140
        array_unshift($rows, ['id' => 0, 'title' => 'Root']);
141
142
        return $this->json($rows);
143
    }
144
145
    #[Route('/categories', name: 'dropbox_categories_create', methods: ['POST'])]
146
    public function createCategory(Request $r): JsonResponse
147
    {
148
        [$cid, $sid]   = $this->context($r);
149
        $uid           = (int) $this->getUser()?->getId();
150
        $payload       = json_decode($r->getContent(), true) ?: [];
151
        $title         = trim((string) ($payload['title'] ?? ''));
152
        $area          = (string) ($payload['area'] ?? 'sent');
153
154
        if ($title === '' || !\in_array($area, ['sent','received'], true)) {
155
            return $this->json(['message' => 'Invalid payload'], 400);
156
        }
157
158
        $cat = $this->categoryRepo->createForUser($cid, $sid, $uid, $title, $area);
159
        return $this->json(['id' => (int) $cat->getCatId(), 'title' => $cat->getTitle()], 201);
160
    }
161
162
    #[Route('/files', name: 'dropbox_files_list', methods: ['GET'])]
163
    public function listFiles(Request $r): JsonResponse
164
    {
165
        [$cid, $sid]  = $this->context($r);
166
        $uid          = (int) $this->getUser()?->getId();
167
        $area         = (string) $r->query->get('area', 'sent');
168
        $categoryId   = (int) $r->query->get('categoryId', 0);
169
170
        if ($area === 'sent') {
171
            $files = $this->fileRepo->findSentByContextAndCategory($cid, $sid, $uid, $categoryId);
172
173
            $out = array_map(function (array $row) {
174
                $dt = new DateTimeImmutable($row['lastUploadDate']);
175
                return [
176
                    'id'            => (int) $row['id'],
177
                    'title'         => $row['title'],
178
                    'description'   => $row['description'],
179
                    'size'          => (int) $row['filesize'],
180
                    'sizeHuman'     => $this->humanSize((int) $row['filesize']),
181
                    'lastUploadDate'=> $dt->format(DATE_ATOM),
182
                    'lastUploadAgo' => $this->ago($dt),
183
                    'recipients'    => $row['recipients'],
184
                    'categoryId'    => (int) $row['catId'],
185
                ];
186
            }, $files);
187
188
            return $this->json($out);
189
        }
190
191
        $files = $this->fileRepo->findReceivedByContextAndCategory($cid, $sid, $uid, $categoryId);
192
193
        $out = array_map(function (array $row) {
194
            $dt = new DateTimeImmutable($row['lastUploadDate']);
195
            return [
196
                'id'            => (int) $row['id'],
197
                'title'         => $row['title'],
198
                'description'   => $row['description'],
199
                'size'          => (int) $row['filesize'],
200
                'sizeHuman'     => $this->humanSize((int) $row['filesize']),
201
                'lastUploadDate'=> $dt->format(DATE_ATOM),
202
                'lastUploadAgo' => $this->ago($dt),
203
                'uploader'      => $row['uploader'],
204
                'categoryId'    => (int) $row['catId'],
205
            ];
206
        }, $files);
207
208
        return $this->json($out);
209
    }
210
211
    #[Route('/files/{id<\d+>}/move', name: 'dropbox_file_move', methods: ['PATCH'])]
212
    public function moveFile(int $id, Request $r): JsonResponse
213
    {
214
        [$cid, $sid]  = $this->context($r);
215
        $uid          = (int) $this->getUser()?->getId();
216
        $payload      = json_decode($r->getContent(), true) ?: [];
217
        $targetCatId  = (int) ($payload['targetCatId'] ?? 0);
218
        $area         = (string) ($payload['area'] ?? 'sent');
219
220
        if (!\in_array($area, ['sent','received'], true)) {
221
            return $this->json(['message' => 'Invalid "area"'], 400);
222
        }
223
224
        $affected = $this->fileRepo->moveFileForArea($id, $cid, $sid, $uid, $targetCatId, $area);
225
        return $this->json(['moved' => $affected > 0]);
226
    }
227
228
    #[Route('/files', name: 'dropbox_files_delete', methods: ['DELETE'])]
229
    public function deleteFiles(Request $r): JsonResponse
230
    {
231
        [$cid, $sid] = $this->context($r);
232
        $uid         = (int) $this->getUser()?->getId();
233
        $payload     = json_decode($r->getContent(), true) ?: [];
234
        $ids         = array_map('intval', $payload['ids'] ?? []);
235
        $area        = (string) ($payload['area'] ?? 'sent');
236
237
        if (!$ids) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $ids of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
238
            return $this->json(['deleted' => 0]);
239
        }
240
241
        $deleted = $this->fileRepo->deleteVisibility($ids, $cid, $sid, $uid, $area);
242
        return $this->json(['deleted' => $deleted]);
243
    }
244
245
    #[Route('/files/{id<\d+>}/feedback', name: 'dropbox_feedback_list', methods: ['GET'])]
246
    public function listFeedback(int $id, Request $r): JsonResponse
247
    {
248
        [$cid] = $this->context($r);
249
        $rows = $this->feedbackRepo->listByFile($cid, $id);
250
251
        return $this->json(array_map(function(CDropboxFeedback $f) {
252
            return [
253
                'id'         => $f->getFeedbackId(),
254
                'authorId'   => $f->getAuthorUserId(),
255
                'authorName' => $this->userFullName($f->getAuthorUserId()),
256
                'text'       => $f->getFeedback(),
257
                'date'       => $f->getFeedbackDate()->format(DATE_ATOM),
258
            ];
259
        }, $rows));
260
    }
261
262
    #[Route('/files/{id<\d+>}/feedback', name: 'dropbox_feedback_create', methods: ['POST'])]
263
    public function createFeedback(int $id, Request $r): JsonResponse
264
    {
265
        [$cid]  = $this->context($r);
266
        $uid    = (int) $this->getUser()?->getId();
267
        $payload= json_decode($r->getContent(), true) ?: [];
268
        $text   = trim((string) ($payload['text'] ?? ''));
269
270
        if ($text === '') {
271
            return $this->json(['message' => 'Empty feedback'], 400);
272
        }
273
274
        $this->feedbackRepo->createForFile($cid, $id, $uid, $text);
275
        return $this->json(['ok' => true], 201);
276
    }
277
278
    #[Route('/categories/{id<\d+>}', name: 'dropbox_categories_rename', methods: ['PATCH'])]
279
    public function renameCategory(int $id, Request $r): JsonResponse
280
    {
281
        [$cid, $sid] = $this->context($r);
282
        $uid  = (int) $this->getUser()?->getId();
283
        $payload = json_decode($r->getContent(), true) ?: [];
284
        $title   = trim((string) ($payload['title'] ?? ''));
285
        $area    = (string) ($payload['area'] ?? 'sent');
286
287
        if ($title === '' || !\in_array($area, ['sent','received'], true)) {
288
            return $this->json(['message' => 'Invalid payload'], 400);
289
        }
290
291
        $cat = $this->categoryRepo->findOneBy([
292
            'cId' => $cid,
293
            'sessionId' => (int) ($sid ?? 0),
294
            'userId' => $uid,
295
            'catId' => $id,
296
            'sent' => $area === 'sent',
297
            'received' => $area === 'received',
298
        ]);
299
300
        if (!$cat) {
301
            return $this->json(['message' => 'Category not found'], 404);
302
        }
303
304
        $cat->setTitle($title);
305
        $this->em->persist($cat);
306
        $this->em->flush();
307
308
        return $this->json(['ok' => true]);
309
    }
310
311
    #[Route('/categories/{id<\d+>}', name: 'dropbox_categories_delete', methods: ['DELETE'])]
312
    public function deleteCategory(int $id, Request $r): JsonResponse
313
    {
314
        [$cid, $sid] = $this->context($r);
315
        $uid  = (int) $this->getUser()?->getId();
316
        $area = (string) $r->query->get('area', 'sent');
317
318
        if (!\in_array($area, ['sent','received'], true)) {
319
            return $this->json(['message' => 'Invalid area'], 400);
320
        }
321
        if ($id === 0) {
322
            return $this->json(['message' => 'Cannot delete root category'], 400);
323
        }
324
325
        $conn = $this->em->getConnection();
326
        $sid  = (int) ($sid ?? 0);
327
328
        if ($area === 'sent') {
329
            $ids = $conn->fetchFirstColumn(
330
                <<<SQL
331
            SELECT f.iid
332
            FROM c_dropbox_file f
333
            WHERE f.c_id = :cid
334
              AND f.session_id = :sid
335
              AND f.uploader_id = :uid
336
              AND f.cat_id = :cat
337
            SQL,
338
                ['cid' => $cid, 'sid' => $sid, 'uid' => $uid, 'cat' => $id]
339
            );
340
341
            $deletedFiles = 0;
342
            if ($ids) {
343
                $deletedFiles = $this->fileRepo->deleteVisibility(array_map('intval', $ids), $cid, $sid, $uid, 'sent');
344
            }
345
346
            $cat = $this->categoryRepo->findOneBy([
347
                'cId'       => $cid,
348
                'sessionId' => $sid,
349
                'userId'    => $uid,
350
                'catId'     => $id,
351
                'sent'      => true,
352
                'received'  => false,
353
            ]);
354
            if ($cat) {
355
                $this->em->remove($cat);
356
                $this->em->flush();
357
            }
358
359
            return $this->json(['ok' => true, 'deletedFiles' => (int) $deletedFiles]);
360
        }
361
362
        $ids = $conn->fetchFirstColumn(
363
            <<<SQL
364
        SELECT p.file_id
365
        FROM c_dropbox_person p
366
        WHERE p.c_id = :cid
367
          AND p.user_id = :uid
368
          AND p.cat_id = :cat
369
        SQL,
370
            ['cid' => $cid, 'uid' => $uid, 'cat' => $id]
371
        );
372
373
        $removedVisibilities = 0;
374
        if ($ids) {
375
            $removedVisibilities = $this->fileRepo->deleteVisibility(array_map('intval', $ids), $cid, $sid, $uid, 'received');
376
        }
377
378
        $cat = $this->categoryRepo->findOneBy([
379
            'cId'       => $cid,
380
            'sessionId' => $sid,
381
            'userId'    => $uid,
382
            'catId'     => $id,
383
            'sent'      => false,
384
            'received'  => true,
385
        ]);
386
        if ($cat) {
387
            $this->em->remove($cat);
388
            $this->em->flush();
389
        }
390
391
        return $this->json(['ok' => true, 'removedVisibilities' => (int) $removedVisibilities]);
392
    }
393
394
    #[Route('/files/{id<\d+>}/download', name: 'dropbox_file_download', methods: ['GET'])]
395
    public function download(int $id, Request $r): Response
396
    {
397
        [$cid] = $this->context($r);
398
399
        $file = $this->fileRepo->find($id);
400
        if (!$file || (int) $file->getCId() !== $cid) {
401
            throw $this->createNotFoundException('File not found');
402
        }
403
404
        // Resolve the resource file attached to this dropbox entry
405
        $resourceNode = $file->getResourceNode();
406
        $resourceFile = $resourceNode?->getFirstResourceFile();
407
408
        if (!$resourceFile) {
409
            throw $this->createNotFoundException('Resource file not found');
410
        }
411
412
        // Display name: prefer the dropbox visible title; fallback to original name
413
        $downloadName = trim($file->getTitle() ?: $resourceFile->getOriginalName() ?: 'file.bin');
414
415
        // Guess mime
416
        $mime = $resourceFile->getMimeType() ?: 'application/octet-stream';
417
        if ($mime === 'application/octet-stream' && class_exists(MimeTypes::class)) {
418
            $types = new MimeTypes();
419
            $guess = $types->guessMimeType($downloadName);
420
            if ($guess) {
421
                $mime = $guess;
422
            }
423
        }
424
425
        // Stream from ResourceNode FS (no tmp-path fallback)
426
        $stream = $this->resourceNodeRepository->getResourceNodeFileStream($resourceNode, $resourceFile);
427
        if (!\is_resource($stream)) {
428
            throw $this->createNotFoundException('Resource stream not available');
429
        }
430
431
        $size = (int) $resourceFile->getSize();
432
433
        $response = new StreamedResponse(function () use ($stream) {
434
            // Stream file in chunks
435
            while (!feof($stream)) {
436
                $buffer = fread($stream, 8192);
437
                if ($buffer === false) {
438
                    break;
439
                }
440
                echo $buffer;
441
                @ob_flush();
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for ob_flush(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

441
                /** @scrutinizer ignore-unhandled */ @ob_flush();

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
442
                flush();
443
            }
444
            fclose($stream);
445
        });
446
447
        $disposition = $response->headers->makeDisposition(
448
            ResponseHeaderBag::DISPOSITION_ATTACHMENT,
449
            $downloadName
450
        );
451
452
        $response->headers->set('Content-Type', $mime);
453
        if ($size > 0) {
454
            $response->headers->set('Content-Length', (string) $size);
455
            $response->headers->set('Accept-Ranges', 'none'); // simple download (no Range)
456
        }
457
        $response->headers->set('Content-Disposition', $disposition);
458
459
        return $response;
460
    }
461
462
    #[Route('/files/{id<\d+>}', name: 'dropbox_file_get', methods: ['GET'])]
463
    public function getFile(int $id, Request $r): JsonResponse
464
    {
465
        [$cid] = $this->context($r);
466
        $row = $this->fileRepo->find($id);
467
        if (!$row || (int) $row->getCId() !== $cid) {
468
            return $this->json(['message' => 'File not found'], 404);
469
        }
470
        return $this->json([
471
            'id' => $row->getIid(),
472
            'title' => $row->getTitle(),
473
            'description' => $row->getDescription(),
474
            'categoryId' => $row->getCatId(),
475
        ]);
476
    }
477
478
    #[Route('/files/{id<\d+>}/update', name: 'dropbox_file_update', methods: ['POST'])]
479
    public function updateFile(int $id, Request $r): JsonResponse
480
    {
481
        [$cid, $sid] = $this->context($r);
482
        $uid = (int) $this->getUser()?->getId();
483
484
        $fileRow = $this->fileRepo->find($id);
485
        if (!$fileRow || (int) $fileRow->getCId() !== $cid || (int) $fileRow->getUploaderId() !== $uid) {
486
            return $this->json(['message' => 'File not found or not allowed'], 404);
487
        }
488
489
        /** @var UploadedFile|null $new */
490
        $new = $r->files->get('newFile');
491
        $newCat = $r->request->get('categoryId');
492
        $newCatId = ($newCat !== null) ? (int) $newCat : $fileRow->getCatId();
493
494
        $shouldRename = (bool) $r->request->get('renameTitle');
495
        $explicitNewTitle = trim((string) $r->request->get('newTitle', ''));
496
497
        if ($new instanceof UploadedFile) {
498
            $origClientName = $new->getClientOriginalName() ?: 'upload.bin';
499
            $origBase = pathinfo($origClientName, PATHINFO_FILENAME);
500
            $origExt  = pathinfo($origClientName, PATHINFO_EXTENSION) ?: 'bin';
501
502
            // Safe physical name
503
            $safeBase = $this->slugger->slug($origBase)->lower();
504
            $safeName = sprintf('%s-%s.%s', $safeBase, bin2hex(random_bytes(4)), $origExt);
505
506
            $coursePath = sprintf('%s/course_%d/dropbox', sys_get_temp_dir(), $cid);
507
            @mkdir($coursePath, 0775, true);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for mkdir(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

507
            /** @scrutinizer ignore-unhandled */ @mkdir($coursePath, 0775, true);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
508
            $new->move($coursePath, $safeName);
509
510
            $fileRow->setFilename($safeName);
511
            $fileRow->setFilesize((int) filesize($coursePath.'/'.$safeName));
512
            $fileRow->setLastUploadDate(new \DateTime());
513
514
            // Rename title WITH extension when requested
515
            if ($shouldRename) {
516
                $finalTitle = $explicitNewTitle !== '' ? $explicitNewTitle : $origClientName;
517
                $finalTitle = rtrim($finalTitle);
518
                $finalTitle = rtrim($finalTitle, ". ");
519
                if ($finalTitle === '') {
520
                    $finalTitle = $origClientName;
521
                }
522
                // Max 255 chars
523
                if (mb_strlen($finalTitle) > 255) {
524
                    $finalTitle = mb_substr($finalTitle, 0, 255);
525
                }
526
                $fileRow->setTitle($finalTitle);
527
            }
528
        }
529
530
        if ($newCatId !== $fileRow->getCatId()) {
531
            $fileRow->setCatId($newCatId);
532
        }
533
534
        $this->em->persist($fileRow);
535
        $this->em->flush();
536
537
        return $this->json([
538
            'ok' => true,
539
            'id' => (int) $fileRow->getIid(),
540
            'categoryId' => (int) $fileRow->getCatId(),
541
            'title' => (string) $fileRow->getTitle(),
542
        ]);
543
    }
544
545
    #[Route('/categories/{id<\d+>}/zip', name: 'dropbox_category_zip', methods: ['GET'])]
546
    public function downloadCategoryZip(int $id, Request $r): Response
547
    {
548
        [$cid, $sid] = $this->context($r);
549
        $uid   = (int) $this->getUser()?->getId();
550
        $area  = (string) $r->query->get('area', 'sent');
551
        $catId = (int) $id;
552
553
        if (!\in_array($area, ['sent','received'], true)) {
554
            return $this->json(['message' => 'Invalid area'], 400);
555
        }
556
557
        // Fetch candidate rows
558
        $conn = $this->em->getConnection();
559
        $sid  = (int) ($sid ?? 0);
560
561
        if ($area === 'sent') {
562
            $sql = <<<SQL
563
            SELECT f.iid, f.title, f.filename, f.filesize
564
            FROM c_dropbox_file f
565
            WHERE f.c_id = :cid
566
              AND f.session_id = :sid
567
              AND f.uploader_id = :uid
568
              AND f.cat_id = :cat
569
            ORDER BY f.last_upload_date DESC, f.iid DESC
570
        SQL;
571
            $rows = $conn->fetchAllAssociative($sql, [
572
                'cid' => $cid, 'sid' => $sid, 'uid' => $uid, 'cat' => $catId,
573
            ]);
574
            $zipLabel = 'sent';
575
        } else {
576
            $sql = <<<SQL
577
            SELECT f.iid, f.title, f.filename, f.filesize
578
            FROM c_dropbox_person p
579
            INNER JOIN c_dropbox_file f
580
              ON f.iid = p.file_id
581
             AND f.c_id = p.c_id
582
            WHERE p.c_id = :cid
583
              AND p.user_id = :uid
584
              AND f.session_id = :sid
585
              AND f.cat_id = :cat
586
            ORDER BY f.last_upload_date DESC, f.iid DESC
587
        SQL;
588
            $rows = $conn->fetchAllAssociative($sql, [
589
                'cid' => $cid, 'uid' => $uid, 'sid' => $sid, 'cat' => $catId,
590
            ]);
591
            $zipLabel = 'received';
592
        }
593
594
        if (!$rows) {
595
            return $this->json(['message' => 'No files in this category'], 404);
596
        }
597
598
        // Prepare ZIP
599
        $coursePath = sprintf('%s/course_%d/dropbox', sys_get_temp_dir(), $cid);
600
        $tmpZipPath = tempnam(sys_get_temp_dir(), 'dbxzip_');
601
        if ($tmpZipPath === false) {
602
            return $this->json(['message' => 'Unable to create temp file'], 500);
603
        }
604
        $finalZipPath = $tmpZipPath . '.zip';
605
        @rename($tmpZipPath, $finalZipPath);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for rename(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

605
        /** @scrutinizer ignore-unhandled */ @rename($tmpZipPath, $finalZipPath);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
606
607
        $zip = new \ZipArchive();
608
        if (true !== $zip->open($finalZipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE)) {
609
            @unlink($finalZipPath);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

609
            /** @scrutinizer ignore-unhandled */ @unlink($finalZipPath);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
610
            return $this->json(['message' => 'Unable to open zip archive'], 500);
611
        }
612
613
        $added = 0;
614
615
        // Add files – try physical temp path, then ResourceNode FS
616
        foreach ($rows as $row) {
617
            $safePhysical = (string) ($row['filename'] ?? '');
618
            $downloadName = trim((string) ($row['title'] ?? '')) ?: ($safePhysical ?: 'file.bin');
619
            $downloadName = str_replace(['\\', '/', "\0"], '_', $downloadName);
620
621
            // Ensure unique entry name
622
            $entryName = $downloadName;
623
            $i = 1;
624
            while ($zip->locateName($entryName, \ZipArchive::FL_NOCASE | \ZipArchive::FL_NODIR) !== false) {
625
                $pi = pathinfo($downloadName);
626
                $base = $pi['filename'] ?? $downloadName;
627
                $ext  = isset($pi['extension']) && $pi['extension'] !== '' ? ('.'.$pi['extension']) : '';
628
                $entryName = $base . ' (' . (++$i) . ')' . $ext;
629
            }
630
631
            $addedThis = false;
632
633
            // (a) Try physical temp path
634
            if ($safePhysical !== '') {
635
                $fullPath = $coursePath . '/' . $safePhysical;
636
                if (is_file($fullPath)) {
637
                    $zip->addFile($fullPath, $entryName);
638
                    $added++; $addedThis = true;
639
                }
640
            }
641
642
            // (b) Fallback: ResourceNode filesystem
643
            if (!$addedThis) {
644
                // Load entity to reach ResourceNode
645
                $fileEntity = $this->fileRepo->find((int) $row['iid']);
646
                $resourceNode = $fileEntity?->getResourceNode();
647
                $resourceFile = $resourceNode?->getFirstResourceFile();
648
                if ($resourceFile) {
649
                    try {
650
                        $path = $this->resourceNodeRepository->getFilename($resourceFile);
651
                        $content = $this->resourceNodeRepository->getFileSystem()->read($path);
652
                        if ($content !== false && $content !== null) {
653
                            $zip->addFromString($entryName, $content);
654
                            $added++; $addedThis = true;
655
                        }
656
                    } catch (\Throwable $e) {
657
                        // ignore and continue
658
                    }
659
                }
660
            }
661
        }
662
663
        $zip->close();
664
665
        if ($added === 0) {
666
            @unlink($finalZipPath);
667
            return $this->json(['message' => 'No files found to include'], 404);
668
        }
669
670
        // Build download name
671
        $catTitle = 'Root';
672
        if ($catId !== 0) {
673
            $cat = $this->categoryRepo->findOneBy([
674
                'cId'       => $cid,
675
                'sessionId' => $sid,
676
                'userId'    => $uid,
677
                'catId'     => $catId,
678
                'sent'      => $area === 'sent',
679
                'received'  => $area === 'received',
680
            ]);
681
            if ($cat) { $catTitle = $cat->getTitle(); }
682
        }
683
        $slug = $this->slugger->slug($catTitle ?: 'category')->lower();
684
        $downloadZipName = sprintf('dropbox-%s-%s-%s.zip', $zipLabel, $slug, date('Ymd_His'));
685
686
        $resp = new BinaryFileResponse($finalZipPath);
687
        $resp->setContentDisposition(
688
            ResponseHeaderBag::DISPOSITION_ATTACHMENT,
689
            $downloadZipName
690
        );
691
        $resp->deleteFileAfterSend(true);
692
693
        return $resp;
694
    }
695
696
    private function userFullName(int $userId): string
697
    {
698
        if ($userId <= 0) {
699
            return 'Unknown user';
700
        }
701
        if (isset($this->userNameCache[$userId])) {
702
            return $this->userNameCache[$userId];
703
        }
704
        $conn = $this->em->getConnection();
705
        $row = $conn->fetchAssociative('SELECT firstname, lastname FROM user WHERE id = :id', ['id' => $userId]);
706
        $name = trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? '')) ?: ('User #'.$userId);
707
        return $this->userNameCache[$userId] = $name;
708
    }
709
}
710