461 lines · 14.6 KB
Raw Download
1
<?php
2
/**
3
 * Social preview ("OpenGraph") card rendering.
4
 *
5
 * Every repository gets a 1200x630 PNG drawn with GD and cached on disk at
6
 * uploads/og/{slug}-{hash}.png. The hash digests the repository's mutable
7
 * state and is carried in the public URL — /{slug}/og-{hash}.png — because
8
 * social crawlers cache og:image by URL essentially forever. Changing the
9
 * repository changes the URL, which is the only reliable way to make Facebook,
10
 * Slack, X and friends fetch a fresh card.
11
 */
12
13
require_once __DIR__ . '/helpers.php';
14
15
const OG_WIDTH   = 1200;
16
const OG_HEIGHT  = 630;
17
const OG_PAD     = 64;
18
19
/** Palette — GitHub Primer dark, matching assets/style.css. */
20
const OG_COLORS = [
21
    'bg'     => [0x0d, 0x11, 0x17],
22
    'panel'  => [0x16, 0x1b, 0x22],
23
    'fg'     => [0xe6, 0xed, 0xf3],
24
    'muted'  => [0x8b, 0x94, 0x9e],
25
    'body'   => [0xc9, 0xd1, 0xd9],
26
    'border' => [0x30, 0x36, 0x3d],
27
    'accent' => [0x58, 0xa6, 0xff],
28
];
29
30
/** Dot colours for the language badge (GitHub Linguist-ish). */
31
const OG_LANG_COLORS = [
32
    'PHP' => '#4F5D95', 'JavaScript' => '#F1E05A', 'TypeScript' => '#3178C6',
33
    'Python' => '#3572A5', 'Java' => '#B07219', 'CSS' => '#563D7C',
34
    'SCSS' => '#C6538C', 'Sass' => '#A53B70', 'Less' => '#1D365D',
35
    'HTML' => '#E34C26', 'Vue' => '#41B883', 'Svelte' => '#FF3E00',
36
    'JSON' => '#8B949E', 'C' => '#555555', 'C++' => '#F34B7D', 'C#' => '#178600',
37
    'Go' => '#00ADD8', 'Ruby' => '#701516', 'Rust' => '#DEA584',
38
    'Swift' => '#F05138', 'Kotlin' => '#A97BFF', 'SQL' => '#E38C00',
39
    'Shell' => '#89E051', 'XML' => '#0060AC', 'YAML' => '#CB171E', 'TOML' => '#9C4221',
40
];
41
42
// ---- Capability / assets ----------------------------------------------------
43
44
/** Absolute path to one of the bundled TrueType faces. */
45
function og_font(string $style = 'regular'): string
46
{
47
    $files = [
48
        'regular' => 'DejaVuSans.ttf',
49
        'bold'    => 'DejaVuSans-Bold.ttf',
50
        'mono'    => 'DejaVuSansMono-Bold.ttf',
51
    ];
52
    return __DIR__ . '/../assets/fonts/' . ($files[$style] ?? $files['regular']);
53
}
54
55
/**
56
 * True when this server can actually draw a card: GD compiled with FreeType
57
 * and PNG support, plus the bundled fonts present. Callers use this to decide
58
 * whether to advertise an og:image at all.
59
 */
60
function og_available(): bool
61
{
62
    static $ok = null;
63
    if ($ok !== null) {
64
        return $ok;
65
    }
66
    if (!function_exists('imagettftext') || !function_exists('imagepng')) {
67
        return $ok = false;
68
    }
69
    foreach (['regular', 'bold', 'mono'] as $style) {
70
        if (!is_readable(og_font($style))) {
71
            return $ok = false;
72
        }
73
    }
74
    return $ok = true;
75
}
76
77
// ---- Text measuring / drawing ----------------------------------------------
78
79
/** Rendered width of a string, in pixels. */
80
function og_text_width(string $text, string $font, float $size): int
81
{
82
    if ($text === '') {
83
        return 0;
84
    }
85
    $box = imagettfbbox($size, 0, $font, $text);
86
    if ($box === false) {
87
        return 0;
88
    }
89
    return (int) ceil(max($box[2], $box[4]) - min($box[0], $box[6]));
90
}
91
92
/**
93
 * Draw a string with its left edge at $x and its baseline at $y.
94
 *
95
 * @param \GdImage|resource $im
96
 * @param int               $color
97
 */
98
function og_text($im, string $text, string $font, float $size, int $x, int $y, $color): void
99
{
100
    if ($text !== '') {
101
        imagettftext($im, $size, 0, $x, $y, $color, $font, $text);
102
    }
103
}
104
105
/** Clip a string to $maxWidth, appending an ellipsis when it does not fit. */
106
function og_ellipsize(string $text, string $font, float $size, int $maxWidth): string
107
{
108
    if (og_text_width($text, $font, $size) <= $maxWidth) {
109
        return $text;
110
    }
111
    $chars = preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: [];
112
    $out   = '';
113
    foreach ($chars as $char) {
114
        if (og_text_width($out . $char . '…', $font, $size) > $maxWidth) {
115
            break;
116
        }
117
        $out .= $char;
118
    }
119
    return rtrim($out) . '…';
120
}
121
122
/**
123
 * Word-wrap $text to at most $maxLines lines of $maxWidth pixels. Overflow is
124
 * folded onto the last line and clipped with an ellipsis, so the card never
125
 * silently drops the fact that there was more text.
126
 */
127
function og_wrap(string $text, string $font, float $size, int $maxWidth, int $maxLines): array
128
{
129
    $text = trim((string) preg_replace('/\s+/u', ' ', $text));
130
    if ($text === '' || $maxLines < 1) {
131
        return [];
132
    }
133
134
    $words = explode(' ', $text);
135
    $lines = [];
136
    $line  = '';
137
138
    foreach ($words as $i => $word) {
139
        $try = $line === '' ? $word : $line . ' ' . $word;
140
        if ($line !== '' && og_text_width($try, $font, $size) > $maxWidth) {
141
            $lines[] = $line;
142
            if (count($lines) === $maxLines) {
143
                // Out of room: tack the remainder onto the last line so the
144
                // ellipsis pass below shows that it was cut short.
145
                $lines[$maxLines - 1] .= ' ' . implode(' ', array_slice($words, $i));
146
                break;
147
            }
148
            $line = $word;
149
        } else {
150
            $line = $try;
151
        }
152
    }
153
    if (count($lines) < $maxLines && $line !== '') {
154
        $lines[] = $line;
155
    }
156
157
    // Also catches single words longer than the line box.
158
    return array_map(
159
        static fn(string $l): string => og_ellipsize($l, $font, $size, $maxWidth),
160
        $lines
161
    );
162
}
163
164
/**
165
 * Allocate a colour from an [r, g, b] triple.
166
 *
167
 * @param \GdImage|resource $im
168
 */
169
function og_color($im, array $rgb)
170
{
171
    return imagecolorallocate($im, $rgb[0], $rgb[1], $rgb[2]);
172
}
173
174
/**
175
 * Allocate a colour from a "#rrggbb" string.
176
 *
177
 * @param \GdImage|resource $im
178
 */
179
function og_hex_color($im, string $hex)
180
{
181
    $hex = ltrim($hex, '#');
182
    return imagecolorallocate(
183
        $im,
184
        (int) hexdec(substr($hex, 0, 2)),
185
        (int) hexdec(substr($hex, 2, 2)),
186
        (int) hexdec(substr($hex, 4, 2))
187
    );
188
}
189
190
/**
191
 * Filled rectangle with rounded corners.
192
 *
193
 * @param \GdImage|resource $im
194
 * @param int               $color
195
 */
196
function og_rounded_rect($im, int $x1, int $y1, int $x2, int $y2, int $r, $color): void
197
{
198
    imagefilledrectangle($im, $x1 + $r, $y1, $x2 - $r, $y2, $color);
199
    imagefilledrectangle($im, $x1, $y1 + $r, $x2, $y2 - $r, $color);
200
    $d = $r * 2;
201
    imagefilledellipse($im, $x1 + $r, $y1 + $r, $d, $d, $color);
202
    imagefilledellipse($im, $x2 - $r, $y1 + $r, $d, $d, $color);
203
    imagefilledellipse($im, $x1 + $r, $y2 - $r, $d, $d, $color);
204
    imagefilledellipse($im, $x2 - $r, $y2 - $r, $d, $d, $color);
205
}
206
207
// ---- Card rendering ---------------------------------------------------------
208
209
/**
210
 * Draw a card and return the GD image. Takes a plain array so it stays
211
 * independent of the database:
212
 *
213
 *   brand       string  small monospace line at the top
214
 *   title       string  repository name (up to 2 lines)
215
 *   subtitle    string  e.g. "/my-repo"
216
 *   description string  optional, up to 3 lines
217
 *   language    string  optional, shown as a badge
218
 *   stats       array   short strings joined with separators along the bottom
219
 */
220
function og_render_card(array $card)
221
{
222
    $im = imagecreatetruecolor(OG_WIDTH, OG_HEIGHT);
223
    imagealphablending($im, true);
224
225
    $bg     = og_color($im, OG_COLORS['bg']);
226
    $panel  = og_color($im, OG_COLORS['panel']);
227
    $fg     = og_color($im, OG_COLORS['fg']);
228
    $muted  = og_color($im, OG_COLORS['muted']);
229
    $body   = og_color($im, OG_COLORS['body']);
230
    $border = og_color($im, OG_COLORS['border']);
231
    $accent = og_color($im, OG_COLORS['accent']);
232
233
    imagefilledrectangle($im, 0, 0, OG_WIDTH, OG_HEIGHT, $bg);
234
    imagefilledrectangle($im, 0, 0, OG_WIDTH, 11, $accent);
235
236
    $regular = og_font('regular');
237
    $bold    = og_font('bold');
238
    $mono    = og_font('mono');
239
    $maxW    = OG_WIDTH - (OG_PAD * 2);
240
241
    // Brand line.
242
    $y = 118;
243
    og_text($im, og_ellipsize((string) ($card['brand'] ?? ''), $mono, 22, $maxW), $mono, 22, OG_PAD, $y, $muted);
244
245
    // Title — up to two lines, shrinking once if a long name would wrap.
246
    $titleSize = 58;
247
    $title = trim((string) ($card['title'] ?? ''));
248
    if (og_text_width($title, $bold, $titleSize) > $maxW * 2) {
249
        $titleSize = 46;
250
    }
251
    $y += 96;
252
    foreach (og_wrap($title, $bold, $titleSize, $maxW, 2) as $line) {
253
        og_text($im, $line, $bold, $titleSize, OG_PAD, $y, $fg);
254
        $y += (int) round($titleSize * 1.32);
255
    }
256
257
    // Slug.
258
    $subtitle = trim((string) ($card['subtitle'] ?? ''));
259
    if ($subtitle !== '') {
260
        $y += 4;
261
        og_text($im, og_ellipsize($subtitle, $mono, 24, $maxW), $mono, 24, OG_PAD, $y, $accent);
262
        $y += 34;
263
    }
264
265
    $footerY = OG_HEIGHT - 118;
266
267
    // Description — as many lines as clear the footer rule, up to three. A
268
    // title that wrapped to two lines eats into the space available here.
269
    $description = trim((string) ($card['description'] ?? ''));
270
    if ($description !== '') {
271
        $y += 30;
272
        $room = min(3, intdiv(max(0, $footerY - 16 - $y), 44) + 1);
273
        foreach (og_wrap($description, $regular, 27, $maxW, $room) as $line) {
274
            og_text($im, $line, $regular, 27, OG_PAD, $y, $body);
275
            $y += 44;
276
        }
277
    }
278
279
    // Footer rule + stat row — omitted entirely when there is nothing to show.
280
    $language = trim((string) ($card['language'] ?? ''));
281
    $stats    = array_values(array_filter(array_map('strval', (array) ($card['stats'] ?? []))));
282
    if ($language === '' && !$stats) {
283
        return $im;
284
    }
285
    imagefilledrectangle($im, OG_PAD, $footerY, OG_WIDTH - OG_PAD, $footerY, $border);
286
287
    $x        = OG_PAD;
288
    $baseline = $footerY + 62;
289
290
    if ($language !== '') {
291
        $badgeW = og_text_width($language, $regular, 22) + 66;
292
        og_rounded_rect($im, $x, $baseline - 30, $x + $badgeW, $baseline + 12, 21, $panel);
293
        $dot = og_hex_color($im, OG_LANG_COLORS[$language] ?? '#8B949E');
294
        imagefilledellipse($im, $x + 26, $baseline - 9, 16, 16, $dot);
295
        og_text($im, $language, $regular, 22, $x + 44, $baseline, $body);
296
        $x += $badgeW + 28;
297
    }
298
299
    foreach ($stats as $i => $stat) {
300
        if ($i > 0) {
301
            og_text($im, '·', $regular, 22, $x, $baseline, $border);
302
            $x += 24;
303
        }
304
        og_text($im, $stat, $regular, 22, $x, $baseline, $muted);
305
        $x += og_text_width($stat, $regular, 22) + 20;
306
    }
307
308
    return $im;
309
}
310
311
// ---- Repository cards -------------------------------------------------------
312
313
/**
314
 * Cache-busting digest of everything the card displays. Any change here yields
315
 * a new og:image URL, which is what forces crawlers to re-fetch.
316
 */
317
function repo_og_hash(array $repo): string
318
{
319
    $files = function_exists('get_files_by_repo') && !empty($repo['id'])
320
        ? get_files_by_repo((int) $repo['id'])
321
        : [];
322
323
    $key = implode('|', [
324
        (string) ($repo['slug'] ?? ''),
325
        (string) ($repo['name'] ?? ''),
326
        (string) ($repo['description'] ?? ''),
327
        repo_language_display($repo),
328
        (string) count($files),
329
        (string) array_sum(array_map(static fn($f) => (int) ($f['filesize'] ?? 0), $files)),
330
    ]);
331
332
    return substr(md5($key), 0, 12);
333
}
334
335
/** Absolute og:image URL for a repository, or null when GD cannot render. */
336
function repo_og_url(array $repo): ?string
337
{
338
    if (!og_available() || empty($repo['slug'])) {
339
        return null;
340
    }
341
    return abs_url('/' . $repo['slug'] . '/og-' . repo_og_hash($repo) . '.png');
342
}
343
344
/** Card fields for a repository row. */
345
function repo_og_card(array $repo): array
346
{
347
    $files = function_exists('get_files_by_repo') && !empty($repo['id'])
348
        ? get_files_by_repo((int) $repo['id'])
349
        : [];
350
351
    $count = count($files);
352
    $bytes = array_sum(array_map(static fn($f) => (int) ($f['filesize'] ?? 0), $files));
353
354
    $stats = [$count . ' ' . ($count === 1 ? 'file' : 'files')];
355
    if ($bytes > 0) {
356
        $stats[] = human_size($bytes);
357
    }
358
    $created = strtotime((string) ($repo['created_at'] ?? ''));
359
    if ($created) {
360
        $stats[] = date('M Y', $created);
361
    }
362
363
    return [
364
        'brand'       => "jefftml's </> code",
365
        'title'       => (string) ($repo['name'] ?? ''),
366
        'subtitle'    => '/' . (string) ($repo['slug'] ?? ''),
367
        'description' => (string) ($repo['description'] ?? ''),
368
        'language'    => repo_language_display($repo),
369
        'stats'       => $stats,
370
    ];
371
}
372
373
/** Absolute path of the on-disk cache entry for a rendered card. */
374
function repo_og_cache_path(string $slug, string $hash): string
375
{
376
    return UPLOAD_DIR . '/og/' . $slug . '-' . $hash . '.png';
377
}
378
379
/**
380
 * Render (or reuse) the cached PNG for a repository and return its path, or
381
 * null if it could not be written.
382
 */
383
function repo_og_file(array $repo, string $hash): ?string
384
{
385
    $path = repo_og_cache_path((string) $repo['slug'], $hash);
386
    if (is_readable($path) && filesize($path) > 0) {
387
        return $path;
388
    }
389
390
    $dir = dirname($path);
391
    if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
392
        return null;
393
    }
394
395
    $im = og_render_card(repo_og_card($repo));
396
    // Write to a temporary name first so a concurrent request never serves a
397
    // half-written PNG.
398
    $tmp = $path . '.' . getmypid() . '.tmp';
399
    $ok  = imagepng($im, $tmp, 6);
400
    unset($im);
401
    if (!$ok || !@rename($tmp, $path)) {
402
        @unlink($tmp);
403
        return null;
404
    }
405
406
    // Drop cards rendered from older states of this repository.
407
    foreach (glob($dir . '/' . $repo['slug'] . '-*.png') ?: [] as $old) {
408
        if ($old !== $path) {
409
            @unlink($old);
410
        }
411
    }
412
413
    return $path;
414
}
415
416
/**
417
 * Router entry point for /{slug}/og[-{hash}].png. Serves the current card
418
 * whatever hash was asked for — the hash is a cache key, not a lookup key —
419
 * but only promises immutability when it matches the current state.
420
 */
421
function render_repo_og(string $slug, string $requestedHash): void
422
{
423
    $repo = get_repository_by_slug($slug);
424
    if (!$repo || !og_available()) {
425
        og_error(404);
426
    }
427
428
    $hash = repo_og_hash($repo);
429
    $path = repo_og_file($repo, $hash);
430
    if ($path === null) {
431
        og_error(500);
432
    }
433
434
    $fresh = ($requestedHash === $hash);
435
    $etag  = '"' . $hash . '"';
436
437
    header('Content-Type: image/png');
438
    header('ETag: ' . $etag);
439
    header($fresh
440
        ? 'Cache-Control: public, max-age=31536000, immutable'
441
        : 'Cache-Control: public, max-age=300');
442
443
    if (trim((string) ($_SERVER['HTTP_IF_NONE_MATCH'] ?? '')) === $etag) {
444
        http_response_code(304);
445
        return;
446
    }
447
448
    header('Content-Length: ' . filesize($path));
449
    readfile($path);
450
}
451
452
/** Bail out of the image endpoint without emitting HTML chrome. */
453
function og_error(int $code): void
454
{
455
    http_response_code($code);
456
    header('Content-Type: text/plain; charset=utf-8');
457
    header('Cache-Control: no-store');
458
    echo $code === 404 ? "Not found\n" : "Unable to render the social card\n";
459
    exit;
460
}
461