491 lines · 18.2 KB
Raw Download
1
<?php
2
/**
3
 * Public repository views. Included by index.php with $slug set, plus $view
4
 * (one of 'tree', 'blob', 'raw') and $path (the folder or file path within the
5
 * repo). Not meant to be requested directly.
6
 */
7
8
require_once __DIR__ . '/includes/db.php';
9
require_once __DIR__ . '/includes/helpers.php';
10
require_once __DIR__ . '/includes/tree.php';
11
require_once __DIR__ . '/includes/markdown.php';
12
require_once __DIR__ . '/includes/og.php';
13
require_once __DIR__ . '/api/highlight.php';
14
15
$slug = $slug ?? '';
16
$repo = get_repository_by_slug($slug);
17
18
if (!$repo) {
19
    render_404();
20
    return;
21
}
22
23
$view = $view ?? 'tree';
24
$path = $path ?? '';
25
26
if ($view === 'raw') {
27
    render_raw_file($repo, $path);
28
} elseif ($view === 'blob') {
29
    render_file_view($repo, $path);
30
} elseif ($view === 'archive') {
31
    render_archive($repo);
32
} else {
33
    render_tree_view($repo, $path);
34
}
35
36
37
/**
38
 * Share metadata for a repository page: [title, description, image, url].
39
 * Every page of a repo advertises the same generated card — the card describes
40
 * the repository, not the individual file being viewed.
41
 */
42
function repo_share_meta(array $repo, string $path = '', bool $isFile = false): array
43
{
44
    $title = $repo['name'] . ($path !== '' ? ' / ' . $path : '');
45
    $desc  = trim((string) ($repo['description'] ?? ''));
46
    if ($desc === '') {
47
        $lang = repo_language_display($repo);
48
        $desc = $lang !== ''
49
            ? $lang . ' repository on jefftml\'s code'
50
            : 'A repository on jefftml\'s code';
51
    }
52
    $url = $isFile ? blob_url($repo['slug'], $path) : tree_url($repo['slug'], $path);
53
54
    return [$title, $desc, repo_og_url($repo), site_origin() . $url];
55
}
56
57
58
/** Map a repository's free-text language label to a highlighter language. */
59
function highlight_lang_for(array $repo, string $filename): string
60
{
61
    $lang = language_from_filename($filename);
62
    if ($lang !== 'plain') {
63
        return $lang;
64
    }
65
    $label = strtolower((string) ($repo['language'] ?? ''));
66
    $map = ['php' => 'php', 'javascript' => 'javascript', 'js' => 'javascript',
67
            'python' => 'python', 'java' => 'java'];
68
    return $map[$label] ?? 'plain';
69
}
70
71
72
/** Resolve a repo-relative file to an absolute path, or null if it escapes. */
73
function resolve_repo_file(array $repo, string $filename): ?string
74
{
75
    $repoDir = realpath(UPLOAD_DIR . '/' . $repo['slug']);
76
    $full    = realpath(UPLOAD_DIR . '/' . $repo['slug'] . '/' . $filename);
77
    if ($repoDir === false || $full === false || strpos($full, $repoDir . DIRECTORY_SEPARATOR) !== 0) {
78
        return null;
79
    }
80
    return $full;
81
}
82
83
84
/** Breadcrumb trail for a folder or file path within a repo. */
85
function render_crumbs(array $repo, string $path): void
86
{
87
    $slug     = $repo['slug'];
88
    $segments = $path === '' ? [] : explode('/', $path);
89
    $last     = count($segments) - 1;
90
    ?>
91
    <nav class="crumbs">
92
        <a href="<?= e(url('/')) ?>">repositories</a> /
93
        <a href="<?= e(tree_url($slug)) ?>"><?= e($slug) ?></a><?php
94
        $accum = [];
95
        foreach ($segments as $idx => $seg) {
96
            $accum[] = $seg;
97
            echo ' / ';
98
            if ($idx === $last) {
99
                echo '<strong>' . e($seg) . '</strong>';
100
            } else {
101
                echo '<a href="' . e(tree_url($slug, implode('/', $accum))) . '">' . e($seg) . '</a>';
102
            }
103
        }
104
        // A file with no folder still needs its own crumb.
105
        ?>
106
    </nav>
107
    <?php
108
}
109
110
111
function render_tree_view(array $repo, string $folder): void
112
{
113
    $folder  = trim(safe_relpath($folder), '/');
114
    $files   = get_files_by_repo((int) $repo['id']);
115
    $folders = get_folders_by_repo((int) $repo['id']);
116
117
    // A non-root path must correspond to a real folder.
118
    if ($folder !== '' && !in_array($folder, array_column($folders, 'path'), true)) {
119
        render_404();
120
        return;
121
    }
122
123
    // Navigate the full tree down to the requested folder.
124
    $tree = build_file_tree($files, $folders);
125
    $node = $tree;
126
    foreach (explode('/', $folder) as $seg) {
127
        if ($seg === '') {
128
            continue;
129
        }
130
        $node = $node['dirs'][$seg] ?? ['dirs' => [], 'files' => []];
131
    }
132
133
    $page_title = $repo['name'] . ($folder !== '' ? ' / ' . $folder : '');
134
    [$og_title, $og_description, $og_image, $og_url] = repo_share_meta($repo, $folder);
135
    require __DIR__ . '/includes/header.php';
136
137
    render_crumbs($repo, $folder);
138
    ?>
139
    <?php
140
    // Repository language badge: manual override if set, else auto-detected
141
    // from the repo's files (which are already loaded above).
142
    $repoLang = trim((string) ($repo['language'] ?? ''));
143
    if ($repoLang === '') {
144
        $repoLang = detect_language($files);
145
    }
146
    ?>
147
    <div class="repo-head">
148
        <h1><?= e($folder !== '' ? basename($folder) : $repo['name']) ?>
149
            <?php if ($folder === '' && $repoLang !== ''): ?><span class="badge"><?= e($repoLang) ?></span><?php endif; ?>
150
        </h1>
151
        <?php if (!empty($files)): ?>
152
            <a class="btn-secondary btn-download-zip" href="<?= e(archive_url($repo['slug'])) ?>">
153
                <span class="dl-icon" aria-hidden="true">&#8615;</span> Download ZIP
154
            </a>
155
        <?php endif; ?>
156
    </div>
157
    <?php if ($folder === '' && !empty($repo['description'])): ?>
158
        <p class="repo-desc"><?= e($repo['description']) ?></p>
159
    <?php endif; ?>
160
161
    <?php if (empty($node['dirs']) && empty($node['files']) && $folder === ''): ?>
162
        <p class="empty">No files in this repository yet.</p>
163
    <?php else: ?>
164
        <div class="tree-listing">
165
            <?php if ($folder !== ''): ?>
166
                <?php $parent = trim(dirname($folder), '/.'); ?>
167
                <div class="tree-up">
168
                    <span class="tree-icon">&#128193;</span>
169
                    <a href="<?= e(tree_url($repo['slug'], $parent)) ?>">..</a>
170
                </div>
171
            <?php endif; ?>
172
            <?= render_tree_nodes($node, $repo, $folder) ?>
173
        </div>
174
    <?php endif; ?>
175
176
    <?php
177
    // README rendered on the repository root only (GitHub-style).
178
    if ($folder === '') {
179
        foreach ($node['files'] as $file) {
180
            if (strcasecmp($file['name'], 'README.md') === 0) {
181
                $full = resolve_repo_file($repo, $file['path']);
182
                if ($full !== null) {
183
                    $md = file_get_contents($full);
184
                    if ($md !== false && $md !== '') {
185
                        echo '<article class="readme"><div class="readme-head">'
186
                           . '<span class="tree-icon">&#128196;</span> ' . e($file['name'])
187
                           . '</div><div class="readme-body markdown">'
188
                           . markdown_to_html($md) . '</div></article>';
189
                    }
190
                }
191
                break;
192
            }
193
        }
194
    }
195
196
    require __DIR__ . '/includes/footer.php';
197
}
198
199
200
function render_file_view(array $repo, string $blob): void
201
{
202
    $filename = safe_relpath($blob);
203
    $record   = get_file_by_name((int) $repo['id'], $filename);
204
205
    if (!$record) {
206
        render_404();
207
        return;
208
    }
209
210
    $full = resolve_repo_file($repo, $filename);
211
    if ($full === null) {
212
        render_404();
213
        return;
214
    }
215
216
    $isImage    = is_image_file($filename);
217
    $isSvg      = is_svg_file($filename);
218
    $isModel    = is_model_file($filename);
219
    $modelFmt   = $isModel ? model_format($filename) : '';
220
221
    // A model file renders in the 3D viewer; only small, text-based model
222
    // formats (OBJ / ASCII PLY / G-code) also keep a readable "Source" view.
223
    $modelSourceOk = $isModel && is_text_model_format($modelFmt)
224
                     && (int) $record['filesize'] <= 2 * 1024 * 1024;
225
226
    $hasRendered = $isImage || $isModel;                 // has a non-source view
227
    $showSource  = $isSvg || $modelSourceOk || (!$isImage && !$isModel);
228
229
    $lines = [];
230
    $lang  = 'plain';
231
    if ($showSource) {
232
        $code = file_get_contents($full);
233
        if ($code === false) {
234
            $code = '';
235
        }
236
        $lang  = highlight_lang_for($repo, $filename);
237
        $lines = explode("\n", str_replace("\r\n", "\n", $code));
238
    }
239
240
    // Sidebar file tree for the whole repository.
241
    $allFiles   = get_files_by_repo((int) $repo['id']);
242
    $allFolders = get_folders_by_repo((int) $repo['id']);
243
    $sidebar    = render_tree_nodes(
244
        build_file_tree($allFiles, $allFolders),
245
        $repo,
246
        '',
247
        ['active' => $filename, 'open' => ancestor_dirs($filename)]
248
    );
249
250
    $page_title = $repo['name'] . ' / ' . $filename;
251
    [$og_title, $og_description, $og_image, $og_url] = repo_share_meta($repo, $filename, true);
252
    require __DIR__ . '/includes/header.php';
253
254
    render_crumbs($repo, $filename);
255
    ?>
256
    <div class="file-layout">
257
        <aside class="file-sidebar">
258
            <input type="search" id="tree-search" class="tree-search" placeholder="Search files…" aria-label="Search files">
259
            <div class="file-sidebar-tree"><?= $sidebar ?></div>
260
        </aside>
261
        <div class="file-main">
262
            <div class="file-toolbar">
263
                <div class="file-meta muted">
264
                    <?php if ($showSource): ?><?= count($lines) ?> lines &middot; <?php endif; ?><?= e(human_size((int) $record['filesize'])) ?>
265
                </div>
266
                <div class="file-actions">
267
                    <?php if ($hasRendered && $showSource): ?>
268
                        <button type="button" class="btn-secondary" id="view-rendered">Rendered</button>
269
                        <button type="button" class="btn-secondary" id="view-source">Source</button>
270
                    <?php endif; ?>
271
                    <?php if ($isImage): ?>
272
                        <button type="button" class="btn-secondary" id="toggle-bg">Dark background</button>
273
                    <?php endif; ?>
274
                    <a class="btn-secondary" href="<?= e(raw_url($repo['slug'], $filename)) ?>" target="_blank" rel="noopener">Raw</a>
275
                    <?php if ($showSource): ?>
276
                        <button type="button" class="btn-secondary" id="copy-code">Copy Code</button>
277
                    <?php endif; ?>
278
                    <a class="btn-secondary" href="<?= e(raw_url($repo['slug'], $filename, true)) ?>">Download</a>
279
                </div>
280
            </div>
281
282
            <?php if ($isImage): ?>
283
                <div class="image-view" id="image-view">
284
                    <img src="<?= e(raw_url($repo['slug'], $filename)) ?>" alt="<?= e(basename($filename)) ?>">
285
                </div>
286
            <?php endif; ?>
287
288
            <?php if ($isModel): ?>
289
                <div class="model-view" id="model-view"
290
                     data-src="<?= e(raw_url($repo['slug'], $filename)) ?>"
291
                     data-format="<?= e($modelFmt) ?>"
292
                     data-name="<?= e(basename($filename)) ?>"
293
                     data-download="<?= e(raw_url($repo['slug'], $filename, true)) ?>">
294
                    <div class="model-status">Loading 3D view…</div>
295
                </div>
296
            <?php endif; ?>
297
298
            <?php if ($showSource): ?>
299
                <div class="code-view<?= $hasRendered ? ' hidden' : '' ?>" id="code-view">
300
                    <table class="code">
301
                        <tbody>
302
                        <?php foreach ($lines as $i => $line): ?>
303
                            <tr>
304
                                <td class="ln"><?= $i + 1 ?></td>
305
                                <td class="cl"><pre><?= $line === '' ? '' : highlight_code($line, $lang) ?></pre></td>
306
                            </tr>
307
                        <?php endforeach; ?>
308
                        </tbody>
309
                    </table>
310
                </div>
311
            <?php endif; ?>
312
        </div>
313
    </div>
314
315
    <script>
316
    (function () {
317
        // File-tree search: hide non-matching files and collapse empty folders.
318
        var box = document.getElementById('tree-search');
319
        var root = document.querySelector('.file-sidebar-tree');
320
        if (box && root) {
321
            box.addEventListener('input', function () {
322
                var q = box.value.trim().toLowerCase();
323
                root.querySelectorAll('li.tree-file').forEach(function (li) {
324
                    var name = (li.textContent || '').toLowerCase();
325
                    li.style.display = (q === '' || name.indexOf(q) !== -1) ? '' : 'none';
326
                });
327
                root.querySelectorAll('li.tree-dir').forEach(function (li) {
328
                    var anyVisible = li.querySelector('li.tree-file:not([style*="display: none"])');
329
                    li.style.display = (q === '' || anyVisible) ? '' : 'none';
330
                    var det = li.querySelector('details');
331
                    if (det && q !== '') { det.open = !!anyVisible; }
332
                });
333
            });
334
        }
335
        // Rendered / source toggle (SVG images and text-based model files).
336
        var rBtn = document.getElementById('view-rendered');
337
        var sBtn = document.getElementById('view-source');
338
        var rendered = document.getElementById('image-view') || document.getElementById('model-view');
339
        var code = document.getElementById('code-view');
340
        if (rBtn && sBtn && rendered && code) {
341
            rBtn.addEventListener('click', function () { rendered.classList.remove('hidden'); code.classList.add('hidden'); });
342
            sBtn.addEventListener('click', function () { rendered.classList.add('hidden'); code.classList.remove('hidden'); });
343
        }
344
        // Light / dark checkered background toggle for transparent images.
345
        var bgBtn = document.getElementById('toggle-bg');
346
        var imageView = document.getElementById('image-view');
347
        if (bgBtn && imageView) {
348
            bgBtn.addEventListener('click', function () {
349
                var dark = imageView.classList.toggle('image-view-dark');
350
                bgBtn.textContent = dark ? 'Light background' : 'Dark background';
351
            });
352
        }
353
        // Copy the source to the clipboard (line-number cells are excluded).
354
        var copyBtn = document.getElementById('copy-code');
355
        if (copyBtn && code) {
356
            copyBtn.addEventListener('click', function () {
357
                var cells = code.querySelectorAll('td.cl pre');
358
                var text = Array.prototype.map.call(cells, function (c) { return c.textContent; }).join('\n');
359
                var done = function () {
360
                    copyBtn.textContent = 'Copied!';
361
                    setTimeout(function () { copyBtn.textContent = 'Copy Code'; }, 1500);
362
                };
363
                if (navigator.clipboard && navigator.clipboard.writeText) {
364
                    navigator.clipboard.writeText(text).then(done, function () {});
365
                } else {
366
                    var ta = document.createElement('textarea');
367
                    ta.value = text;
368
                    ta.style.position = 'fixed';
369
                    ta.style.opacity = '0';
370
                    document.body.appendChild(ta);
371
                    ta.select();
372
                    try { document.execCommand('copy'); done(); } catch (e) {}
373
                    document.body.removeChild(ta);
374
                }
375
            });
376
        }
377
    })();
378
    </script>
379
    <?php if ($isModel): ?>
380
    <script src="<?= e(url('/assets/model-viewer.js')) ?>" defer></script>
381
    <?php endif; ?>
382
    <?php
383
    require __DIR__ . '/includes/footer.php';
384
}
385
386
387
/** Stream a stored file's raw bytes, optionally as a download attachment. */
388
function render_raw_file(array $repo, string $blob): void
389
{
390
    $filename = safe_relpath($blob);
391
    $record   = get_file_by_name((int) $repo['id'], $filename);
392
    if (!$record) {
393
        render_404();
394
        return;
395
    }
396
    $full = resolve_repo_file($repo, $filename);
397
    if ($full === null) {
398
        render_404();
399
        return;
400
    }
401
402
    $download = isset($_GET['dl']);
403
    header('Content-Type: ' . mime_for_filename($filename));
404
    header('X-Content-Type-Options: nosniff');
405
    header('Content-Length: ' . filesize($full));
406
    if ($download) {
407
        header('Content-Disposition: attachment; filename="' . basename($filename) . '"');
408
    }
409
    readfile($full);
410
}
411
412
413
/**
414
 * Build a .zip of every file in the repository and stream it as a download,
415
 * GitHub-style. All contents are nested under a top-level "<slug>/" folder so
416
 * the archive expands into a single tidy directory.
417
 */
418
function render_archive(array $repo): void
419
{
420
    if (!class_exists('ZipArchive')) {
421
        archive_error(500, 'Zip archives are unavailable on this server (the PHP zip extension is not installed).');
422
        return;
423
    }
424
425
    $files = get_files_by_repo((int) $repo['id']);
426
    if (empty($files)) {
427
        render_404();
428
        return;
429
    }
430
431
    // ZipArchive writes to a real file, so build it in a temp file, stream it,
432
    // then remove it. Building incrementally keeps memory use flat.
433
    $tmp = tempnam(sys_get_temp_dir(), 'repozip_');
434
    if ($tmp === false) {
435
        archive_error(500, 'Unable to create the archive.');
436
        return;
437
    }
438
439
    $zip = new ZipArchive();
440
    if ($zip->open($tmp, ZipArchive::OVERWRITE) !== true) {
441
        @unlink($tmp);
442
        archive_error(500, 'Unable to create the archive.');
443
        return;
444
    }
445
446
    $root  = $repo['slug'];                 // top-level folder inside the zip
447
    $added = 0;
448
449
    // Preserve empty folders (they exist as folder rows but hold no files).
450
    foreach (get_folders_by_repo((int) $repo['id']) as $folder) {
451
        $zip->addEmptyDir($root . '/' . $folder['path']);
452
    }
453
454
    foreach ($files as $file) {
455
        $full = resolve_repo_file($repo, $file['filename']);
456
        if ($full === null || !is_file($full)) {
457
            continue;               // skip records whose file is missing on disk
458
        }
459
        if ($zip->addFile($full, $root . '/' . $file['filename'])) {
460
            $added++;
461
        }
462
    }
463
464
    $zip->close();
465
466
    if ($added === 0) {
467
        @unlink($tmp);
468
        render_404();
469
        return;
470
    }
471
472
    $size = filesize($tmp);
473
    header('Content-Type: application/zip');
474
    header('Content-Disposition: attachment; filename="' . $repo['slug'] . '.zip"');
475
    header('X-Content-Type-Options: nosniff');
476
    if ($size !== false) {
477
        header('Content-Length: ' . $size);
478
    }
479
    readfile($tmp);
480
    @unlink($tmp);
481
}
482
483
484
/** Emit a plain-text error for the archive endpoint (no HTML chrome yet sent). */
485
function archive_error(int $code, string $message): void
486
{
487
    http_response_code($code);
488
    header('Content-Type: text/plain; charset=utf-8');
489
    echo $message;
490
}
491