| 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 |
|