521 lines · 21.2 KB
Raw Download
1
<?php
2
require_once __DIR__ . '/../includes/auth.php';
3
require_once __DIR__ . '/../includes/db.php';
4
require_once __DIR__ . '/../includes/helpers.php';
5
6
require_login();
7
8
$error   = '';
9
$notice  = '';
10
11
// Determine which repo we're uploading to (from POST on submit, else GET).
12
$repoSlug = $_POST['repo'] ?? $_GET['repo'] ?? '';
13
$repoSlug = strtolower(trim($repoSlug));
14
$repo     = $repoSlug !== '' ? get_repository_by_slug($repoSlug) : null;
15
16
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
17
    csrf_verify();
18
    $action = $_POST['action'] ?? 'upload';
19
20
    if (!$repo) {
21
        $error = 'Choose a repository first.';
22
23
    } elseif ($action === 'create_folder') {
24
        // Manually create a (possibly nested) folder, e.g. "folder1/folder2".
25
        $folder = safe_relpath($_POST['folder'] ?? '');
26
        if ($folder === '') {
27
            $error = 'Enter a valid folder name (e.g. src/utils).';
28
        } else {
29
            @mkdir(UPLOAD_DIR . '/' . $repo['slug'] . '/' . $folder, 0775, true);
30
            create_folder((int) $repo['id'], $folder);
31
            header('Location: ' . url('/admin/upload.php?repo=' . urlencode($repo['slug']) . '&foldercreated=1'));
32
            exit;
33
        }
34
35
    } elseif ($action === 'upload_zip') {
36
        // Upload a single .zip and unpack it, recreating its folder structure.
37
        if (!class_exists('ZipArchive')) {
38
            $error = 'Zip support is not available on this server (the PHP zip extension is disabled).';
39
        } elseif (empty($_FILES['zip']['name']) || ($_FILES['zip']['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
40
            $error = 'Choose a .zip file to upload.';
41
        } elseif ($_FILES['zip']['error'] !== UPLOAD_ERR_OK || !is_uploaded_file($_FILES['zip']['tmp_name'])) {
42
            $error = 'The archive could not be uploaded (it may be too large; check upload_max_filesize).';
43
        } else {
44
            $tmp    = $_FILES['zip']['tmp_name'];
45
            $dir    = UPLOAD_DIR . '/' . $repo['slug'];
46
            $target = safe_relpath($_POST['target'] ?? ''); // optional destination folder
47
            $zip    = new ZipArchive();
48
49
            if ($zip->open($tmp) !== true) {
50
                $error = 'That file is not a valid zip archive.';
51
            } else {
52
                if (!is_dir($dir)) {
53
                    @mkdir($dir, 0775, true);
54
                }
55
56
                // Collect entries, dropping macOS/Windows junk.
57
                $entries = [];
58
                for ($i = 0; $i < $zip->numFiles; $i++) {
59
                    $stat = $zip->statIndex($i);
60
                    if ($stat === false) {
61
                        continue;
62
                    }
63
                    $name = $stat['name'];
64
                    $base = basename(rtrim($name, '/'));
65
                    if (strpos($name, '__MACOSX/') === 0 || $base === '.DS_Store' || $base === 'Thumbs.db') {
66
                        continue;
67
                    }
68
                    $entries[] = $stat;
69
                }
70
71
                // Detect a single wrapping top-level folder to strip (e.g. "myrepo-main/").
72
                $stripPrefix = null;
73
                foreach ($entries as $stat) {
74
                    $first = explode('/', $stat['name'], 2)[0];
75
                    if ($first === '') {
76
                        continue;
77
                    }
78
                    if ($stripPrefix === null) {
79
                        $stripPrefix = $first;
80
                    } elseif ($stripPrefix !== $first) {
81
                        $stripPrefix = null; // more than one root: keep structure as-is
82
                        break;
83
                    }
84
                }
85
86
                $saved    = 0;
87
                $skipped  = [];
88
                $totalOut = 0;
89
                $aborted  = false;
90
91
                foreach ($entries as $stat) {
92
                    $name  = $stat['name'];
93
                    $isDir = substr($name, -1) === '/';
94
95
                    // Strip the common wrapping folder if there is one.
96
                    $name = ($stripPrefix !== null)
97
                        ? (string) substr($name, strlen($stripPrefix) + 1)
98
                        : $name;
99
100
                    $rel = safe_relpath($name);
101
                    if ($rel === '') {
102
                        continue; // e.g. the stripped root folder itself, or junk
103
                    }
104
                    if ($target !== '') {
105
                        $rel = $target . '/' . $rel;
106
                    }
107
108
                    if ($isDir) {
109
                        @mkdir($dir . '/' . $rel, 0775, true);
110
                        create_folder((int) $repo['id'], $rel);
111
                        continue;
112
                    }
113
114
                    $size = (int) $stat['size'];
115
                    if ($size > MAX_FILE_SIZE) {
116
                        $skipped[] = $name . ' (too large)';
117
                        continue;
118
                    }
119
                    $totalOut += $size;
120
                    if ($totalOut > MAX_ZIP_TOTAL) {
121
                        $aborted = true;
122
                        break;
123
                    }
124
125
                    $dest    = $dir . '/' . $rel;
126
                    $destDir = dirname($dest);
127
                    if (!is_dir($destDir)) {
128
                        @mkdir($destDir, 0775, true);
129
                    }
130
131
                    // Copy via the zip stream wrapper (never extractTo(), which
132
                    // would honor the archive's raw, unsanitised path).
133
                    $stream = $zip->getStream($stat['name']);
134
                    if ($stream === false) {
135
                        $skipped[] = $name . ' (unreadable)';
136
                        continue;
137
                    }
138
                    $out = @fopen($dest, 'wb');
139
                    if ($out === false) {
140
                        fclose($stream);
141
                        $skipped[] = $name . ' (could not save)';
142
                        continue;
143
                    }
144
                    // Manual chunked copy — some shared hosts disable
145
                    // stream_copy_to_file() via disable_functions.
146
                    while (!feof($stream)) {
147
                        $chunk = fread($stream, 1 << 16);
148
                        if ($chunk === false) {
149
                            break;
150
                        }
151
                        fwrite($out, $chunk);
152
                    }
153
                    fclose($out);
154
                    fclose($stream);
155
156
                    create_file((int) $repo['id'], $rel, $repo['slug'] . '/' . $rel, (int) filesize($dest));
157
                    $dirRel = dirname($rel);
158
                    if ($dirRel !== '.' && $dirRel !== '') {
159
                        create_folder((int) $repo['id'], $dirRel);
160
                    }
161
                    $saved++;
162
                }
163
164
                $zip->close();
165
166
                if ($aborted) {
167
                    $error = 'Archive too large: extraction stopped after ' . human_size(MAX_ZIP_TOTAL) . '. ' . $saved . ' file(s) were imported.';
168
                } elseif ($saved > 0 && empty($skipped)) {
169
                    header('Location: ' . url('/admin/upload.php?repo=' . urlencode($repo['slug']) . '&uploaded=' . $saved));
170
                    exit;
171
                } else {
172
                    $notice = $saved . ' file(s) imported from archive.';
173
                    if ($skipped) {
174
                        $error = 'Skipped: ' . implode(', ', $skipped);
175
                    }
176
                }
177
            }
178
        }
179
180
    } elseif (empty($_FILES['files']) || !is_array($_FILES['files']['name'])) {
181
        $error = 'No files were selected.';
182
183
    } else {
184
        $dir    = UPLOAD_DIR . '/' . $repo['slug'];
185
        $target = safe_relpath($_POST['target'] ?? ''); // optional destination folder
186
        if (!is_dir($dir)) {
187
            @mkdir($dir, 0775, true);
188
        }
189
190
        $saved   = 0;
191
        $skipped = [];
192
        $count   = count($_FILES['files']['name']);
193
194
        for ($i = 0; $i < $count; $i++) {
195
            $err = $_FILES['files']['error'][$i];
196
            if ($err === UPLOAD_ERR_NO_FILE) {
197
                continue;
198
            }
199
            $origName = $_FILES['files']['name'][$i];
200
            $tmp      = $_FILES['files']['tmp_name'][$i];
201
            $size     = (int) $_FILES['files']['size'][$i];
202
203
            if ($err !== UPLOAD_ERR_OK) {
204
                $skipped[] = $origName . ' (upload error)';
205
                continue;
206
            }
207
            if ($size > MAX_FILE_SIZE) {
208
                $skipped[] = $origName . ' (too large)';
209
                continue;
210
            }
211
            if (!is_uploaded_file($tmp)) {
212
                $skipped[] = $origName . ' (invalid)';
213
                continue;
214
            }
215
216
            // Preserve folder structure: use the relative path the browser sent
217
            // (folder upload) when present, else fall back to the basename.
218
            $rel = isset($_POST['relpaths'][$i]) ? safe_relpath((string) $_POST['relpaths'][$i]) : '';
219
            if ($rel === '') {
220
                $rel = safe_filename($origName);
221
            }
222
            // Nest under the chosen target folder, if any.
223
            if ($target !== '') {
224
                $rel = $target . '/' . $rel;
225
            }
226
227
            $dest    = $dir . '/' . $rel;
228
            $destDir = dirname($dest);
229
            if (!is_dir($destDir)) {
230
                @mkdir($destDir, 0775, true);
231
            }
232
233
            if (move_uploaded_file($tmp, $dest)) {
234
                create_file((int) $repo['id'], $rel, $repo['slug'] . '/' . $rel, $size);
235
                // Register the file's folder (and ancestors) so the tree stays complete.
236
                $dirRel = dirname($rel);
237
                if ($dirRel !== '.' && $dirRel !== '') {
238
                    create_folder((int) $repo['id'], $dirRel);
239
                }
240
                $saved++;
241
            } else {
242
                $skipped[] = $origName . ' (could not save)';
243
            }
244
        }
245
246
        if ($saved > 0 && empty($skipped)) {
247
            header('Location: ' . url('/admin/upload.php?repo=' . urlencode($repo['slug']) . '&uploaded=' . $saved));
248
            exit;
249
        }
250
        $notice = $saved . ' file(s) uploaded.';
251
        if ($skipped) {
252
            $error = 'Skipped: ' . implode(', ', $skipped);
253
        }
254
    }
255
}
256
257
if (isset($_GET['uploaded']))      { $notice = ((int) $_GET['uploaded']) . ' file(s) uploaded.'; }
258
if (isset($_GET['foldercreated'])) { $notice = 'Folder created.'; }
259
if (isset($_GET['deleted']))       { $notice = 'Deleted.'; }
260
261
$allRepos = get_repositories();
262
$files    = $repo ? get_files_by_repo((int) $repo['id']) : [];
263
$folders  = $repo ? get_folders_by_repo((int) $repo['id']) : [];
264
265
$page_title = $repo ? ('Upload files to ' . $repo['name']) : 'Upload files';
266
require __DIR__ . '/../includes/header.php';
267
?>
268
<nav class="crumbs"><a href="<?= e(url('/admin/dashboard.php')) ?>">dashboard</a> / upload</nav>
269
<h1><?= $repo ? 'Upload files to ' . e($repo['name']) : 'Upload files' ?></h1>
270
271
<?php if ($notice): ?><p class="notice"><?= e($notice) ?></p><?php endif; ?>
272
<?php if ($error): ?><p class="error"><?= e($error) ?></p><?php endif; ?>
273
274
<form method="post" enctype="multipart/form-data" class="card form" id="upload-form">
275
    <?= csrf_field() ?>
276
    <input type="hidden" name="action" value="upload">
277
    <label>Repository
278
        <select name="repo" required onchange="if(this.value){window.location='<?= e(url('/admin/upload.php?repo=')) ?>'+encodeURIComponent(this.value);}">
279
            <option value="">— choose —</option>
280
            <?php foreach ($allRepos as $r): ?>
281
                <option value="<?= e($r['slug']) ?>" <?= ($repo && $repo['slug'] === $r['slug']) ? 'selected' : '' ?>>
282
                    <?= e($r['name']) ?> (/<?= e($r['slug']) ?>)
283
                </option>
284
            <?php endforeach; ?>
285
        </select>
286
    </label>
287
288
    <?php if ($repo): ?>
289
    <label>Destination folder (optional)
290
        <select name="target">
291
            <option value="">(repository root)</option>
292
            <?php foreach ($folders as $fo): ?>
293
                <option value="<?= e($fo['path']) ?>"><?= e($fo['path']) ?>/</option>
294
            <?php endforeach; ?>
295
        </select>
296
    </label>
297
    <?php endif; ?>
298
299
    <div id="dropzone" class="dropzone">
300
        <p>Drag &amp; drop files or a folder here</p>
301
        <div class="row dropzone-buttons">
302
            <label class="btn-secondary file-pick">Choose files
303
                <input type="file" name="files[]" id="file-input" multiple hidden>
304
            </label>
305
            <label class="btn-secondary file-pick">Choose folder
306
                <input type="file" id="folder-input" webkitdirectory multiple hidden>
307
            </label>
308
        </div>
309
        <ul id="file-list" class="file-names"></ul>
310
    </div>
311
312
    <button type="submit">Upload</button>
313
    <p class="muted">Max <?= e(human_size(MAX_FILE_SIZE)) ?> per file. Folder structure is preserved.</p>
314
</form>
315
316
<?php if ($repo): ?>
317
<section class="card">
318
    <h2>Upload a zip</h2>
319
    <form method="post" enctype="multipart/form-data" class="form">
320
        <?= csrf_field() ?>
321
        <input type="hidden" name="action" value="upload_zip">
322
        <input type="hidden" name="repo" value="<?= e($repo['slug']) ?>">
323
        <label>Destination folder (optional)
324
            <select name="target">
325
                <option value="">(repository root)</option>
326
                <?php foreach ($folders as $fo): ?>
327
                    <option value="<?= e($fo['path']) ?>"><?= e($fo['path']) ?>/</option>
328
                <?php endforeach; ?>
329
            </select>
330
        </label>
331
        <label>Zip archive
332
            <input type="file" name="zip" accept=".zip,application/zip" required>
333
        </label>
334
        <button type="submit">Upload &amp; unpack</button>
335
        <p class="muted">The archive is unpacked into the repository, preserving its folders. A single wrapping top-level folder is stripped automatically.</p>
336
    </form>
337
</section>
338
339
<section class="card">
340
    <h2>Create folder</h2>
341
    <form method="post" class="form">
342
        <?= csrf_field() ?>
343
        <input type="hidden" name="action" value="create_folder">
344
        <input type="hidden" name="repo" value="<?= e($repo['slug']) ?>">
345
        <label>Folder path
346
            <input type="text" name="folder" placeholder="folder1/folder2/folder3" required>
347
        </label>
348
        <p class="muted">Use “/” to nest. Each level is created automatically.</p>
349
        <button type="submit">Create folder</button>
350
    </form>
351
</section>
352
353
<section>
354
    <h2>Contents of /<?= e($repo['slug']) ?></h2>
355
    <?php if (empty($files) && empty($folders)): ?>
356
        <p class="empty">Empty. Upload files or create a folder above.</p>
357
    <?php else: ?>
358
        <table class="file-table">
359
            <thead><tr><th>Name</th><th>Size</th><th></th></tr></thead>
360
            <tbody>
361
            <?php foreach ($folders as $fo): ?>
362
                <tr>
363
                    <td><span class="dir">&#128193;</span> <?= e($fo['path']) ?>/</td>
364
                    <td class="muted">folder</td>
365
                    <td>
366
                        <form method="post" action="<?= e(url('/admin/delete.php')) ?>" class="inline"
367
                              onsubmit="return confirm('Delete folder &quot;<?= e($fo['path']) ?>&quot; and everything inside it?');">
368
                            <?= csrf_field() ?>
369
                            <input type="hidden" name="type" value="folder">
370
                            <input type="hidden" name="id" value="<?= (int) $fo['id'] ?>">
371
                            <input type="hidden" name="repo" value="<?= e($repo['slug']) ?>">
372
                            <button type="submit" class="link-btn danger">Delete</button>
373
                        </form>
374
                    </td>
375
                </tr>
376
            <?php endforeach; ?>
377
            <?php foreach ($files as $f): ?>
378
                <tr>
379
                    <td><a href="<?= e(blob_url($repo['slug'], $f['filename'])) ?>"><?= e($f['filename']) ?></a></td>
380
                    <td class="muted"><?= e(human_size((int) $f['filesize'])) ?></td>
381
                    <td>
382
                        <form method="post" action="<?= e(url('/admin/delete.php')) ?>" class="inline"
383
                              onsubmit="return confirm('Delete <?= e($f['filename']) ?>?');">
384
                            <?= csrf_field() ?>
385
                            <input type="hidden" name="type" value="file">
386
                            <input type="hidden" name="id" value="<?= (int) $f['id'] ?>">
387
                            <input type="hidden" name="repo" value="<?= e($repo['slug']) ?>">
388
                            <button type="submit" class="link-btn danger">Delete</button>
389
                        </form>
390
                    </td>
391
                </tr>
392
            <?php endforeach; ?>
393
            </tbody>
394
        </table>
395
    <?php endif; ?>
396
</section>
397
<?php endif; ?>
398
399
<script>
400
// Vanilla JS enhancement. Supports picking/dropping individual files OR whole
401
// folders. For folders we read each file's relative path and send it in a
402
// parallel relpaths[] field so the server can recreate the directory tree.
403
(function () {
404
    var dz = document.getElementById('dropzone');
405
    var fileInput = document.getElementById('file-input');
406
    var folderInput = document.getElementById('folder-input');
407
    var list = document.getElementById('file-list');
408
    var form = document.getElementById('upload-form');
409
    if (!dz || !form || !fileInput) return;
410
411
    var selected = []; // array of { file: File, path: string }
412
413
    function render() {
414
        list.innerHTML = '';
415
        selected.forEach(function (item) {
416
            var li = document.createElement('li');
417
            li.textContent = item.path;
418
            list.appendChild(li);
419
        });
420
        if (selected.length) {
421
            var summary = document.createElement('li');
422
            summary.className = 'muted';
423
            summary.textContent = selected.length + ' file(s) ready to upload';
424
            list.appendChild(summary);
425
        }
426
    }
427
428
    function addFileList(files, useRelative) {
429
        for (var i = 0; i < files.length; i++) {
430
            var f = files[i];
431
            var p = (useRelative && f.webkitRelativePath) ? f.webkitRelativePath : f.name;
432
            selected.push({ file: f, path: p });
433
        }
434
        render();
435
    }
436
437
    fileInput.addEventListener('change', function () {
438
        addFileList(fileInput.files, false);
439
        fileInput.value = '';
440
    });
441
    folderInput.addEventListener('change', function () {
442
        addFileList(folderInput.files, true);
443
        folderInput.value = '';
444
    });
445
446
    ['dragenter', 'dragover'].forEach(function (ev) {
447
        dz.addEventListener(ev, function (e) { e.preventDefault(); dz.classList.add('over'); });
448
    });
449
    ['dragleave', 'drop'].forEach(function (ev) {
450
        dz.addEventListener(ev, function (e) { e.preventDefault(); dz.classList.remove('over'); });
451
    });
452
453
    dz.addEventListener('drop', function (e) {
454
        var items = e.dataTransfer && e.dataTransfer.items;
455
        if (items && items.length && items[0].webkitGetAsEntry) {
456
            var pending = 0, queued = false;
457
            function done() { if (queued && pending === 0) render(); }
458
            for (var i = 0; i < items.length; i++) {
459
                var entry = items[i].webkitGetAsEntry();
460
                if (entry) { pending++; traverse(entry, '', function () { pending--; done(); }); }
461
            }
462
            queued = true; done();
463
        } else if (e.dataTransfer && e.dataTransfer.files) {
464
            addFileList(e.dataTransfer.files, false);
465
        }
466
    });
467
468
    // Recursively walk a dropped directory entry, collecting files + paths.
469
    function traverse(entry, prefix, cb) {
470
        if (entry.isFile) {
471
            entry.file(function (file) {
472
                selected.push({ file: file, path: prefix + entry.name });
473
                cb();
474
            }, cb);
475
        } else if (entry.isDirectory) {
476
            var reader = entry.createReader();
477
            var all = [];
478
            (function readBatch() {
479
                reader.readEntries(function (batch) {
480
                    if (batch.length) {
481
                        all = all.concat(batch);
482
                        readBatch(); // readEntries returns files in chunks
483
                    } else if (!all.length) {
484
                        cb();
485
                    } else {
486
                        var remaining = all.length;
487
                        all.forEach(function (child) {
488
                            traverse(child, prefix + entry.name + '/', function () {
489
                                if (--remaining === 0) cb();
490
                            });
491
                        });
492
                    }
493
                }, cb);
494
            })();
495
        } else { cb(); }
496
    }
497
498
    form.addEventListener('submit', function (e) {
499
        if (!selected.length) return; // nothing staged: let server-side validation handle it
500
        if (typeof DataTransfer === 'undefined') return; // very old browser: submit as-is
501
        e.preventDefault();
502
503
        // Rebuild the file input and parallel relpaths[] hidden fields, in order.
504
        form.querySelectorAll('input[name="relpaths[]"]').forEach(function (n) { n.remove(); });
505
        var dt = new DataTransfer();
506
        selected.forEach(function (item) {
507
            dt.items.add(item.file);
508
            var h = document.createElement('input');
509
            h.type = 'hidden';
510
            h.name = 'relpaths[]';
511
            h.value = item.path;
512
            form.appendChild(h);
513
        });
514
        fileInput.files = dt.files;
515
        form.submit();
516
    });
517
})();
518
</script>
519
<?php
520
require __DIR__ . '/../includes/footer.php';
521