- 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.
154 lines
6.8 KiB
PHP
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;
|
|
}
|
|
}
|