Files
bludit-github-sync/importer.php
T
interlope 3dfd367814 Add BluditGithubImport class for GitHub article import functionality
- Implemented methods for slug validation, path validation, scalar parsing, and document processing.
- Added link and image handling with safety checks for content.
- Introduced blob verification for GitHub responses and asset type validation.
- Created unit tests for the import functionality, covering various edge cases and ensuring robust error handling.
- Included tests for metadata preservation, asset handling, and concurrent edits.
2026-11-01 18:33:07 +01:00

154 lines
6.8 KiB
PHP

<?php
/** Parsing and path validation for the deliberately small export format. No YAML execution. */
class BluditGithubImport
{
public static function slug($slug)
{
if (!is_string($slug) || !preg_match('/\A[a-z0-9][a-z0-9_-]{0,149}\z/D', $slug)) {
throw new RuntimeException('Use a single article folder name (lowercase letters, numbers, hyphens or underscores).');
}
return $slug;
}
public static function path($path)
{
if (!is_string($path) || $path === '' || preg_match('/[\\\\\x00-\x20\x7f?#%:]/', $path)) {
throw new RuntimeException('Unsafe repository path.');
}
foreach (explode('/', $path) as $part) {
if ($part === '' || $part === '.' || $part === '..') {
throw new RuntimeException('Unsafe repository path segment.');
}
}
return $path;
}
public static function scalar($value)
{
$value = trim($value);
if ($value === '') {
return '';
}
if ($value[0] === '"') {
$decoded = json_decode($value, true);
if (!is_string($decoded) || json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException('Invalid quoted front matter value. Use JSON-style double quotes.');
}
return $decoded;
}
if ($value[0] === "'") {
if (substr($value, -1) !== "'") {
throw new RuntimeException('Unclosed front matter quote.');
}
return str_replace("''", "'", substr($value, 1, -1));
}
if (preg_match('/\A[!&*>{\[|]/', $value) || strpos($value, ' #') !== false) {
throw new RuntimeException('Unsupported YAML syntax. Use plain scalars, quoted strings or a tags list.');
}
return $value;
}
public static function document($raw, $slug)
{
self::slug($slug);
if (!preg_match('//u', $raw)) { throw new RuntimeException('Article must be UTF-8 text.'); }
$raw = str_replace("\r\n", "\n", $raw);
$raw = preg_replace('/\A\xEF\xBB\xBF/', '', $raw);
if (!preg_match('/\A---\n(.*?)\n---[ \t]*(?:\n|\z)(.*)\z/s', $raw, $match)) {
throw new RuntimeException('Expected YAML front matter between --- lines.');
}
$meta = [];
$inTags = false;
foreach (explode("\n", $match[1]) as $line) {
if (trim($line) === '' || preg_match('/^\s*#/', $line)) {
continue;
}
if ($inTags && preg_match('/^ - (.+)$/', $line, $tag)) {
$meta['tags'][] = self::scalar($tag[1]);
continue;
}
$inTags = false;
if (!preg_match('/^([a-z]+):[ \t]*(.*)$/', $line, $field)) {
throw new RuntimeException('Unsupported front matter line: ' . $line);
}
$name = $field[1];
if (!in_array($name, ['title', 'date', 'slug', 'status', 'tags', 'description', 'category', 'cover'], true) || array_key_exists($name, $meta)) {
throw new RuntimeException('Unknown or duplicate front matter field: ' . $name);
}
if ($name === 'tags') {
$value = trim($field[2]);
if ($value !== '' && $value !== '[]') {
throw new RuntimeException('Use tags: [] or an indented list of tags.');
}
$meta['tags'] = [];
$inTags = $value === '';
} else {
$meta[$name] = self::scalar($field[2]);
}
}
if (empty($meta['title']) || (isset($meta['slug']) && $meta['slug'] !== $slug)) {
throw new RuntimeException('A title is required and slug must match the selected article folder.');
}
if (isset($meta['date']) && $meta['date'] !== '') {
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $meta['date']);
if (!$date || $date->format('Y-m-d') !== $meta['date']) {
throw new RuntimeException('Date must be a valid YYYY-MM-DD date.');
}
}
if (!in_array($meta['status'] ?? '', ['', 'draft', 'published', 'static', 'sticky', 'scheduled'], true)) {
throw new RuntimeException('Unsupported article status.');
}
if (trim($match[2]) === '') {
throw new RuntimeException('Empty article content is not imported.');
}
return ['meta' => $meta, 'content' => $match[2]];
}
/** Only link/image destinations, not arbitrary prose or external URLs. */
public static function links($content, $rewrite = null)
{
$found = [];
$pattern = '~((?:\b(?:src|href)\s*=\s*["\x27])|(?:!?\[[^\]\r\n]*\]\(\s*))([^"\x27\s<>\)]+)~i';
// Literal code examples must not be treated as assets to fetch or rewrite.
$chunks = preg_split('~(<pre\b[^>]*>.*?</pre>|<code\b[^>]*>.*?</code>|^```[^\n]*\n.*?^```[ \t]*$|^\~\~\~[^\n]*\n.*?^\~\~\~[ \t]*$|`[^`\r\n]+`)~ims', $content, -1, PREG_SPLIT_DELIM_CAPTURE);
foreach ($chunks as $index => $chunk) {
if ($index % 2 !== 0) { continue; }
$chunks[$index] = preg_replace_callback($pattern, function ($m) use (&$found, $rewrite) {
$url = $m[2];
if (strpos($url, '../') !== 0) {
return $m[0];
}
if (!preg_match('~\A\.\./(img|files)/(.+)\z~', $url, $parts)) {
throw new RuntimeException('Unsupported relative asset link: ' . $url);
}
self::path($parts[1] . '/' . $parts[2]);
$found[$url] = $parts[1] . '/' . $parts[2];
return $m[1] . ($rewrite ? $rewrite($url) : $url);
}, $chunk);
}
return $rewrite ? implode('', $chunks) : $found;
}
public static function extension($path)
{
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if (!in_array($ext, ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'ico', 'txt', 'pdf', 'csv', 'json', 'zip'], true)) {
throw new RuntimeException('Unsupported asset type: ' . $path . '. HTML, scripts and SVG are not imported.');
}
return $ext;
}
public static function blob($response, $sha, $limit)
{
if (!is_array($response) || ($response['encoding'] ?? '') !== 'base64' || !isset($response['content'])) {
throw new RuntimeException('GitHub did not return a base64 blob.');
}
$data = base64_decode(str_replace(["\n", "\r"], '', $response['content']), true);
if ($data === false || strlen($data) > $limit || sha1('blob ' . strlen($data) . "\0" . $data) !== $sha) {
throw new RuntimeException('Blob verification failed or file exceeds the import size limit.');
}
return $data;
}
}