777 lines
36 KiB
PHP
777 lines
36 KiB
PHP
<?php
|
|
|
|
class pluginBluditGithub extends Plugin
|
|
{
|
|
private $importing = false;
|
|
public function init()
|
|
{
|
|
// Bludit discovers every class declared while including plugin.php.
|
|
// Load helpers only after that discovery pass has finished.
|
|
require_once __DIR__ . "/importer.php";
|
|
$this->dbFields = [
|
|
'token' => '',
|
|
'owner' => '',
|
|
'repo' => '',
|
|
'branch' => 'main',
|
|
'path' => '',
|
|
'exportDraftsAuto' => false,
|
|
'exportPublishedAuto' => true,
|
|
];
|
|
}
|
|
|
|
public function form()
|
|
{
|
|
$token = $this->getValue('token');
|
|
$owner = $this->getValue('owner');
|
|
$repo = $this->getValue('repo');
|
|
$branch = $this->getValue('branch');
|
|
$path = $this->getValue('path');
|
|
$exportDraftsAuto = $this->getValue('exportDraftsAuto') ? 'checked' : '';
|
|
$exportPublishedAuto = $this->getValue('exportPublishedAuto') ? 'checked' : '';
|
|
$status = $this->getStatus();
|
|
|
|
$html = '<div>';
|
|
$html .= '<label>GitHub Personal Access Token</label>';
|
|
$html .= '<input name="token" type="password" value="' . htmlspecialchars($token) . '" placeholder="ghp_...">';
|
|
$html .= '<span class="tip">Requires <strong>public_repo</strong> scope (or <strong>repo</strong> for private repositories). <a href="https://github.com/settings/tokens/new" target="_blank">Generate token</a></span>';
|
|
$html .= '</div>';
|
|
|
|
$html .= '<div>';
|
|
$html .= '<label>Repository Owner</label>';
|
|
$html .= '<input name="owner" type="text" value="' . htmlspecialchars($owner) . '" placeholder="username or org">';
|
|
$html .= '</div>';
|
|
|
|
$html .= '<div>';
|
|
$html .= '<label>Repository Name</label>';
|
|
$html .= '<input name="repo" type="text" value="' . htmlspecialchars($repo) . '" placeholder="my-blog">';
|
|
$html .= '</div>';
|
|
|
|
$html .= '<div>';
|
|
$html .= '<label>Branch</label>';
|
|
$html .= '<input name="branch" type="text" value="' . htmlspecialchars($branch) . '" placeholder="main">';
|
|
$html .= '</div>';
|
|
|
|
$html .= '<div>';
|
|
$html .= '<label>Path Prefix <em>(optional)</em></label>';
|
|
$html .= '<input name="path" type="text" value="' . htmlspecialchars($path) . '" placeholder="Leave empty to export at repo root">';
|
|
$html .= '<span class="tip">Each article is exported as <code>{key}/content/index.md</code>. Set a prefix (e.g. <code>articles</code>) to nest under a subfolder.</span>';
|
|
$html .= '</div>';
|
|
|
|
$html .= '<div style="margin-top:1.5em;padding-top:1em;border-top:1px solid #eee">';
|
|
$html .= '<label>Auto-export settings</label>';
|
|
$html .= '<div>';
|
|
$html .= '<label><input type="hidden" name="exportDraftsAuto" value="0"><input name="exportDraftsAuto" type="checkbox" value="1" ' . $exportDraftsAuto . '> Auto-export draft articles on save</label>';
|
|
$html .= '</div>';
|
|
$html .= '<div>';
|
|
$html .= '<label><input type="hidden" name="exportPublishedAuto" value="0"><input name="exportPublishedAuto" type="checkbox" value="1" ' . $exportPublishedAuto . '> Auto-export published articles on save</label>';
|
|
$html .= '</div>';
|
|
$html .= '</div>';
|
|
|
|
$html .= '<div style="margin-top:1.5em;padding-top:1em;border-top:1px solid #eee">';
|
|
$html .= '<label>Bulk Export</label>';
|
|
$html .= '<input name="bulkExportAll" type="submit" class="btn btn-secondary" value="Export everything">';
|
|
$html .= '<input name="bulkExportPublished" type="submit" class="btn btn-secondary" value="Export published only" style="margin-left:0.5em">';
|
|
$html .= '</div>';
|
|
|
|
$html .= $this->importForm();
|
|
|
|
if ($status) {
|
|
$isError = stripos($status, 'error') !== false;
|
|
$alertClass = $isError ? 'alert-danger' : 'alert-success';
|
|
$html .= '<div class="alert ' . $alertClass . '" role="alert" style="margin-top:1em">' . htmlspecialchars($status) . '</div>';
|
|
}
|
|
|
|
return $html;
|
|
}
|
|
|
|
public function post()
|
|
{
|
|
parent::post();
|
|
|
|
try {
|
|
if (!empty($_POST['previewImport'])) {
|
|
$this->previewImport($_POST['importSlug'] ?? '', !empty($_POST['importAsDraft']));
|
|
} elseif (!empty($_POST['confirmImport'])) {
|
|
$this->confirmImport($_POST['importConfirmation'] ?? '');
|
|
} elseif (!empty($_POST['cancelImport'])) {
|
|
Session::remove('bluditGithubImport');
|
|
$this->setStatus('Import preview cancelled.');
|
|
} else {
|
|
Session::remove('bluditGithubImport');
|
|
if (!empty($_POST['bulkExportAll'])) {
|
|
$this->bulkExport(true);
|
|
} elseif (!empty($_POST['bulkExportPublished'])) {
|
|
$this->bulkExport(false);
|
|
}
|
|
}
|
|
} catch (Throwable $e) {
|
|
$this->setStatus('Error: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
public function afterPageCreate($key)
|
|
{
|
|
try {
|
|
if ($this->importing || empty($key) || !$this->isConfigured()) {
|
|
return;
|
|
}
|
|
$page = new Page($key);
|
|
$isDraft = $page->getValue('type') === 'draft';
|
|
$shouldAutoExport = $isDraft ? $this->getValue('exportDraftsAuto') : $this->getValue('exportPublishedAuto');
|
|
|
|
if ($shouldAutoExport) {
|
|
$this->exportPage($page);
|
|
}
|
|
} catch (Throwable $e) {
|
|
$this->setStatus('Error (afterPageCreate): ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
public function afterPageModify($key)
|
|
{
|
|
try {
|
|
if ($this->importing || empty($key) || !$this->isConfigured()) {
|
|
return;
|
|
}
|
|
$page = new Page($key);
|
|
$isDraft = $page->getValue('type') === 'draft';
|
|
$shouldAutoExport = $isDraft ? $this->getValue('exportDraftsAuto') : $this->getValue('exportPublishedAuto');
|
|
|
|
if ($shouldAutoExport) {
|
|
$this->exportPage($page);
|
|
}
|
|
} catch (Throwable $e) {
|
|
$this->setStatus('Error (afterPageModify): ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
public function afterPageDelete($key)
|
|
{
|
|
try {
|
|
if ($this->importing || empty($key) || !$this->isConfigured()) {
|
|
return;
|
|
}
|
|
|
|
$prefix = trim($this->getValue('path'), '/');
|
|
$base = ($prefix ? $prefix . '/' : '') . $key;
|
|
|
|
// Delete content/index.md
|
|
$mdPath = $base . '/content/index.md';
|
|
$sha = $this->getFileSha($mdPath);
|
|
if ($sha) {
|
|
$this->githubRequest('DELETE', '/contents/' . $mdPath, [
|
|
'message' => 'Delete: ' . $key,
|
|
'sha' => $sha,
|
|
'branch' => $this->getValue('branch'),
|
|
]);
|
|
}
|
|
|
|
// Delete article image and attachment files (one directory level, like the exporter).
|
|
foreach (['img', 'files'] as $directory) {
|
|
$items = $this->githubRequest('GET', '/contents/' . $base . '/' . $directory);
|
|
if (!is_array($items)) { continue; }
|
|
foreach ($items as $item) {
|
|
if (($item['type'] ?? '') === 'file') {
|
|
$this->githubRequest('DELETE', '/contents/' . $item['path'], [
|
|
'message' => 'Delete asset: ' . $key . '/' . $directory . '/' . $item['name'],
|
|
'sha' => $item['sha'],
|
|
'branch' => $this->getValue('branch'),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
} catch (Throwable $e) {
|
|
$this->setStatus('Error (afterPageDelete): ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
// Import previews are bound to the current admin session and expire after 30 minutes.
|
|
private function importForm()
|
|
{
|
|
$plan = Session::get('bluditGithubImport');
|
|
$e = function ($v) { return htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8'); };
|
|
$draftChecked = !is_array($plan) || !empty($plan['asDraft']) ? ' checked' : '';
|
|
$html = '<div style="margin-top:1.5em;padding-top:1em;border-top:1px solid #eee">';
|
|
$html .= '<label>Import one article from GitHub</label>';
|
|
$html .= '<input name="importSlug" type="text" value="' . $e($plan['slug'] ?? '') . '" placeholder="load-balancing-my-homelab">';
|
|
$html .= '<label><input type="checkbox" name="importAsDraft" value="1"' . $draftChecked . '> Import as draft (including existing articles)</label>';
|
|
$html .= '<p class="tip">Preview reads the configured branch without changing articles. Confirmation replaces the selected page only. No automatic pulling or deletion.</p>';
|
|
$html .= '<button name="previewImport" value="1" type="submit" class="btn btn-secondary">Preview import</button>';
|
|
if (is_array($plan)) {
|
|
$html .= '<hr><h4>Import preview: ' . $e($plan['slug']) . '</h4>';
|
|
$html .= '<p>' . ($plan['local'] === null ? 'Create a new page' : 'Replace the existing page') . ' from commit <code>' . $e($plan['commit']) . '</code>.</p>';
|
|
$html .= '<dl>';
|
|
foreach (['title', 'description', 'date', 'category', 'tags', 'type'] as $field) {
|
|
$value = $plan['args'][$field] ?? '(preserve existing value)';
|
|
if ($field === 'type') { $value = $plan['effectiveType']; }
|
|
$html .= '<dt>' . $e($field === 'type' ? 'Resulting status' : $field) . '</dt><dd>' . $e($value) . '</dd>';
|
|
}
|
|
$html .= '</dl><p>Assets to download:</p><ul>';
|
|
foreach ($plan['assets'] as $asset) {
|
|
$html .= '<li><code>' . $e($asset['relative']) . '</code> (' . (int)$asset['size'] . ' bytes)</li>';
|
|
}
|
|
$html .= '</ul>';
|
|
if ($plan['local'] !== null) {
|
|
$html .= '<details><summary>Current local content</summary><textarea readonly rows="10" style="width:100%">' . $e($plan['local']['content']) . '</textarea></details>';
|
|
}
|
|
$html .= '<label>Incoming content (escaped; not executed)</label>';
|
|
$html .= '<textarea readonly rows="15" style="width:100%">' . $e($plan['content']) . '</textarea>';
|
|
$html .= '<input type="hidden" name="importConfirmation" value="' . $e($plan['confirmation']) . '">';
|
|
$html .= '<p>A protected local backup is saved before replacing an existing page. Other page metadata is preserved.</p>';
|
|
$html .= '<button name="confirmImport" value="1" type="submit" class="btn btn-primary">Confirm this import</button> ';
|
|
$html .= '<button name="cancelImport" value="1" type="submit" class="btn btn-secondary">Cancel preview</button>';
|
|
}
|
|
return $html . '</div>';
|
|
}
|
|
|
|
private function importConfigHash()
|
|
{
|
|
$values = [];
|
|
foreach (['owner', 'repo', 'branch', 'path', 'token'] as $key) { $values[] = $this->getValue($key); }
|
|
return hash('sha256', json_encode($values));
|
|
}
|
|
|
|
private function importLocalState($slug)
|
|
{
|
|
$current = new Pages();
|
|
if (!$current->exists($slug)) { return null; }
|
|
$content = file_get_contents(PATH_PAGES . $slug . DS . FILENAME);
|
|
if ($content === false) { throw new RuntimeException('Cannot read the existing page content.'); }
|
|
return ['row' => $current->getPageDB($slug), 'content' => $content];
|
|
}
|
|
|
|
private function previewImport($slug, $asDraft)
|
|
{
|
|
global $pages, $categories;
|
|
Session::remove('bluditGithubImport');
|
|
if (!$this->isConfigured()) { throw new RuntimeException('Configure the GitHub repository first.'); }
|
|
BluditGithubImport::slug($slug);
|
|
// Refuse Bludit slug normalization/collisions instead of importing into a different page.
|
|
if ($pages->generateKey($slug, false, false, $pages->exists($slug) ? $slug : '') !== $slug) {
|
|
throw new RuntimeException('Bludit would change this slug. Choose an exact, supported page key.');
|
|
}
|
|
$prefix = trim($this->getValue('path'), '/');
|
|
$base = ($prefix === '' ? '' : BluditGithubImport::path($prefix) . '/') . $slug . '/';
|
|
$commit = $this->githubRequest('GET', '/commits/' . rawurlencode($this->getValue('branch')));
|
|
if (!is_array($commit) || !preg_match('/\A[a-f0-9]{40}\z/', $commit['sha'] ?? '') || empty($commit['commit']['tree']['sha'])) {
|
|
throw new RuntimeException('Cannot resolve the configured GitHub branch. Check the repository, token and branch.');
|
|
}
|
|
$tree = $this->githubRequest('GET', '/git/trees/' . rawurlencode($commit['commit']['tree']['sha']) . '?recursive=1');
|
|
if (!is_array($tree) || !isset($tree['tree']) || !empty($tree['truncated'])) {
|
|
throw new RuntimeException('Cannot read a complete repository tree.');
|
|
}
|
|
$entries = [];
|
|
foreach ($tree['tree'] as $item) { $entries[$item['path']] = $item; }
|
|
$document = $entries[$base . 'content/index.md'] ?? null;
|
|
if (!$document) { throw new RuntimeException('Article content/index.md not found on the configured branch.'); }
|
|
$raw = $this->importBlob($document, 1024 * 1024);
|
|
$parsed = BluditGithubImport::document($raw, $slug);
|
|
$meta = $parsed['meta'];
|
|
$local = $this->importLocalState($slug);
|
|
$uuid = $local === null ? bin2hex(random_bytes(16)) : $local['row']['uuid'];
|
|
if (!is_string($uuid) || !preg_match('/\A[a-zA-Z0-9_-]+\z/', $uuid)) {
|
|
throw new RuntimeException('Invalid Bludit page upload identifier.');
|
|
}
|
|
$args = ['slug' => $slug, 'title' => Sanitize::htmlDecode($meta['title'])];
|
|
foreach (['description', 'category'] as $field) {
|
|
if (array_key_exists($field, $meta)) { $args[$field] = Sanitize::htmlDecode($meta[$field]); }
|
|
}
|
|
if (!empty($args['category']) && !$categories->exists($args['category'])) {
|
|
throw new RuntimeException('Create the category in Bludit before importing: ' . $args['category']);
|
|
}
|
|
if (isset($meta['tags'])) {
|
|
foreach ($meta['tags'] as $tag) {
|
|
if (strpos($tag, ',') !== false) { throw new RuntimeException('Tags cannot contain commas.'); }
|
|
}
|
|
$args['tags'] = implode(',', $meta['tags']);
|
|
}
|
|
if (!empty($meta['date'])) {
|
|
$oldDate = $local['row']['date'] ?? '';
|
|
$args['date'] = substr($oldDate, 0, 10) === $meta['date'] ? $oldDate : $meta['date'] . ' 00:00:00';
|
|
}
|
|
$args['type'] = $asDraft ? 'draft' : (($meta['status'] ?? '') ?: ($local['row']['type'] ?? 'draft'));
|
|
$effectiveType = $args['type'];
|
|
if ($effectiveType === 'published' && ($args['date'] ?? ($local['row']['date'] ?? '')) > date('Y-m-d H:i:s')) {
|
|
$effectiveType = 'scheduled';
|
|
}
|
|
$links = BluditGithubImport::links($parsed['content']);
|
|
if (!empty($meta['cover'])) {
|
|
if (strpos($meta['cover'], '../') === 0) {
|
|
$coverLinks = BluditGithubImport::links('<img src="' . $meta['cover'] . '">');
|
|
$links += $coverLinks;
|
|
} elseif (!preg_match('~\Ahttps?://[^\s<>"\x27]+\z~', $meta['cover'])) {
|
|
throw new RuntimeException('Cover must be ../img/filename or an HTTP(S) URL.');
|
|
}
|
|
}
|
|
$assets = [];
|
|
$total = 0;
|
|
foreach ($links as $url => $relative) {
|
|
$entry = $entries[$base . $relative] ?? null;
|
|
if (!$entry || ($entry['type'] ?? '') !== 'blob' || !in_array($entry['mode'] ?? '', ['100644', '100755'], true)) {
|
|
throw new RuntimeException('Missing asset or unsupported symlink: ' . $relative);
|
|
}
|
|
$ext = BluditGithubImport::extension($relative);
|
|
$total += (int)($entry['size'] ?? 0);
|
|
if (($entry['size'] ?? 0) > 10 * 1024 * 1024 || $total > 32 * 1024 * 1024 || count($assets) >= 100) {
|
|
throw new RuntimeException('Import exceeds the asset limits (100 files, 10 MiB/file, 32 MiB total).');
|
|
}
|
|
$assets[$url] = ['relative' => $relative, 'sha' => $entry['sha'], 'mode' => $entry['mode'], 'type' => 'blob', 'size' => $entry['size'],
|
|
'filename' => 'gh-' . $entry['sha'] . '-' . substr(sha1($relative), 0, 12) . '.' . $ext];
|
|
}
|
|
$plan = ['slug' => $slug, 'commit' => $commit['sha'], 'config' => $this->importConfigHash(), 'created' => time(),
|
|
'confirmation' => bin2hex(random_bytes(24)), 'local' => $local, 'uuid' => $uuid, 'args' => $args,
|
|
'effectiveType' => $effectiveType, 'asDraft' => $asDraft, 'content' => $parsed['content'], 'cover' => $meta['cover'] ?? null, 'assets' => $assets];
|
|
Session::set('bluditGithubImport', $plan);
|
|
$this->setStatus('Preview ready. Review the incoming content and resulting status, then confirm to import.');
|
|
}
|
|
|
|
private function importBlob($entry, $limit)
|
|
{
|
|
if (($entry['type'] ?? '') !== 'blob' || !in_array($entry['mode'] ?? '', ['100644', '100755'], true) || !preg_match('/\A[a-f0-9]{40}\z/', $entry['sha'] ?? '') || ($entry['size'] ?? 0) > $limit) {
|
|
throw new RuntimeException('Invalid Git blob or file too large.');
|
|
}
|
|
return BluditGithubImport::blob($this->githubRequest('GET', '/git/blobs/' . $entry['sha']), $entry['sha'], $limit);
|
|
}
|
|
|
|
private function writeProtectedJson($file, $value)
|
|
{
|
|
$json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
|
$tmp = tempnam($this->workspace(), '.github-');
|
|
if ($tmp === false) { throw new RuntimeException('Cannot create a protected plugin workspace file.'); }
|
|
try {
|
|
if (!chmod($tmp, 0600) || file_put_contents($tmp, "<?php http_response_code(403); exit; ?>\n" . $json) === false || !rename($tmp, $file)) {
|
|
throw new RuntimeException('Cannot save protected plugin workspace data.');
|
|
}
|
|
} finally { if (file_exists($tmp)) { unlink($tmp); } }
|
|
}
|
|
|
|
private function assetMap($slug)
|
|
{
|
|
$file = $this->workspace() . 'assets-' . hash('sha256', $slug) . '.php';
|
|
if (!is_file($file)) { return []; }
|
|
$raw = file_get_contents($file);
|
|
$data = json_decode(substr($raw, strpos($raw, "\n") + 1), true);
|
|
if (!is_array($data)) { throw new RuntimeException('Cannot read imported asset mapping.'); }
|
|
return $data;
|
|
}
|
|
|
|
private function confirmImport($confirmation)
|
|
{
|
|
global $pages;
|
|
$plan = Session::get('bluditGithubImport');
|
|
if (!is_array($plan) || !is_string($confirmation) || !hash_equals($plan['confirmation'], $confirmation) || time() - $plan['created'] > 1800 || !hash_equals($plan['config'], $this->importConfigHash())) {
|
|
Session::remove('bluditGithubImport');
|
|
throw new RuntimeException('Import preview expired or settings changed. Preview again.');
|
|
}
|
|
$lock = fopen($this->workspace() . 'import.lock', 'c');
|
|
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
|
|
if ($lock) { fclose($lock); }
|
|
throw new RuntimeException('Another import is running. Retry later.');
|
|
}
|
|
$installed = [];
|
|
$attempted = false;
|
|
$backup = null;
|
|
try {
|
|
if ($this->importLocalState($plan['slug']) !== $plan['local']) {
|
|
throw new RuntimeException('The local page changed since preview. Preview again before replacing it.');
|
|
}
|
|
$files = [];
|
|
foreach ($plan['assets'] as $asset) {
|
|
$data = $this->importBlob($asset, 10 * 1024 * 1024);
|
|
$ext = BluditGithubImport::extension($asset['relative']);
|
|
if (in_array($ext, ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'ico'], true)) {
|
|
$image = @getimagesizefromstring($data);
|
|
$types = ['png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp', 'avif' => 'image/avif', 'ico' => 'image/vnd.microsoft.icon'];
|
|
if (!$image || ($image['mime'] ?? '') !== $types[$ext]) { throw new RuntimeException('Invalid image contents: ' . $asset['relative']); }
|
|
}
|
|
$files[$asset['filename']] = $data;
|
|
}
|
|
// Downloads may take time; catch edits made while waiting for GitHub.
|
|
if ($this->importLocalState($plan['slug']) !== $plan['local']) {
|
|
throw new RuntimeException('The local page changed during asset download. Preview again.');
|
|
}
|
|
if ($plan['local'] !== null) {
|
|
$backup = $this->workspace() . 'backup-' . date('Ymd-His') . '-' . bin2hex(random_bytes(8)) . '.php';
|
|
$this->writeProtectedJson($backup, ['slug' => $plan['slug'], 'commit' => $plan['commit'], 'page' => $plan['local'], 'assetMap' => $this->assetMap($plan['slug'])]);
|
|
}
|
|
$dir = PATH_UPLOADS_PAGES . $plan['uuid'] . DS;
|
|
if (is_link(rtrim($dir, DS)) || (!is_dir($dir) && !mkdir($dir, 0755, true))) { throw new RuntimeException('Cannot prepare the page upload directory.'); }
|
|
foreach ($files as $name => $data) {
|
|
$dest = $dir . $name;
|
|
if (is_link($dest)) { throw new RuntimeException('Refusing a symlink in page uploads.'); }
|
|
if (is_file($dest)) {
|
|
if (file_get_contents($dest) !== $data) { throw new RuntimeException('An imported asset filename already has different contents.'); }
|
|
continue;
|
|
}
|
|
$tmp = tempnam($dir, '.github-');
|
|
if ($tmp === false) { throw new RuntimeException('Cannot stage an imported asset.'); }
|
|
try {
|
|
if (file_put_contents($tmp, $data) === false || !chmod($tmp, 0644) || !rename($tmp, $dest)) { throw new RuntimeException('Cannot install an imported asset.'); }
|
|
} finally { if (is_file($tmp)) { unlink($tmp); } }
|
|
$installed[] = $dest;
|
|
}
|
|
$url = rtrim(DOMAIN_UPLOADS_PAGES, '/') . '/' . $plan['uuid'] . '/';
|
|
$args = $plan['args'];
|
|
$args['content'] = BluditGithubImport::links($plan['content'], function ($relative) use ($plan, $url) {
|
|
return $url . $plan['assets'][$relative]['filename'];
|
|
});
|
|
if ($plan['cover'] !== null) {
|
|
$args['coverImage'] = isset($plan['assets'][$plan['cover']]) ? $url . $plan['assets'][$plan['cover']]['filename'] : $plan['cover'];
|
|
}
|
|
if ($plan['local'] === null) { $args['uuid'] = $plan['uuid']; } else { $args['key'] = $plan['slug']; }
|
|
if ($this->importLocalState($plan['slug']) !== $plan['local']) {
|
|
throw new RuntimeException('The local page changed before save. Preview again.');
|
|
}
|
|
$pages = new Pages();
|
|
$this->importing = true;
|
|
$attempted = true;
|
|
$key = $plan['local'] === null ? createPage($args) : editPage($args);
|
|
if ($key !== $plan['slug'] || !$pages->save()) { throw new RuntimeException('Bludit could not persist the imported page.'); }
|
|
$map = $this->assetMap($plan['slug']);
|
|
foreach ($plan['assets'] as $asset) { $map[$asset['filename']] = $asset['relative']; }
|
|
$this->writeProtectedJson($this->workspace() . 'assets-' . hash('sha256', $plan['slug']) . '.php', $map);
|
|
Session::remove('bluditGithubImport');
|
|
$this->setStatus('Imported ' . $key . ' from ' . substr($plan['commit'], 0, 12) . ' as ' . (new Page($key))->getValue('type') . '. No export was triggered.' . ($backup ? ' Backup: ' . basename($backup) : ''));
|
|
} catch (Throwable $e) {
|
|
if ($attempted) {
|
|
try {
|
|
if ($plan['local'] === null) {
|
|
if ($pages->exists($plan['slug']) && !$pages->delete($plan['slug'])) { throw new RuntimeException('Cannot remove partial new page.'); }
|
|
} else {
|
|
if (file_put_contents(PATH_PAGES . $plan['slug'] . DS . FILENAME, $plan['local']['content']) === false) { throw new RuntimeException('Cannot restore page content.'); }
|
|
$pages->db[$plan['slug']] = $plan['local']['row'];
|
|
if (!$pages->save()) { throw new RuntimeException('Cannot restore page metadata.'); }
|
|
}
|
|
reindexCategories();
|
|
reindexTags();
|
|
} catch (Throwable $restore) {
|
|
throw new RuntimeException('Import failed and automatic restoration failed. Restore the protected backup ' . ($backup ? basename($backup) : '(new page)') . ': ' . $restore->getMessage(), 0, $e);
|
|
}
|
|
}
|
|
foreach ($installed as $file) { if (is_file($file)) { unlink($file); } }
|
|
throw $e;
|
|
} finally {
|
|
$this->importing = false;
|
|
flock($lock, LOCK_UN);
|
|
fclose($lock);
|
|
}
|
|
}
|
|
|
|
// --- Private ---
|
|
|
|
|
|
private function exportPage($page, $saveStatus = true)
|
|
{
|
|
$key = $page->getValue('key');
|
|
$rawContent = $page->content();
|
|
|
|
// Export images using URLs found in the content (before rewriting)
|
|
$this->exportImages($key, $rawContent . "\n" . $page->coverImage());
|
|
|
|
$content = $this->buildMarkdown($page, $key, $rawContent);
|
|
$filePath = $this->getFilePath($key);
|
|
$sha = $this->getFileSha($filePath);
|
|
|
|
$data = [
|
|
'message' => ($sha ? 'Update' : 'Add') . ': ' . $page->title(),
|
|
'content' => base64_encode($content),
|
|
'branch' => $this->getValue('branch'),
|
|
];
|
|
if ($sha) {
|
|
$data['sha'] = $sha;
|
|
}
|
|
|
|
$result = $this->githubRequest('PUT', '/contents/' . $filePath, $data);
|
|
|
|
if ($result !== false && $saveStatus) {
|
|
$this->setStatus(date('Y-m-d H:i:s') . ' — Exported: ' . $page->title());
|
|
}
|
|
}
|
|
|
|
private function defaultAssetPath($filename)
|
|
{
|
|
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
|
return (in_array($ext, ['txt', 'pdf', 'csv', 'json', 'zip'], true) ? 'files/' : 'img/') . $filename;
|
|
}
|
|
|
|
private function exportImages($key, $content)
|
|
{
|
|
preg_match_all(
|
|
'/https?:\/\/[^\/]+\/bl-content\/uploads\/pages\/([^\/]+)\/([^"\')\s<>]+)/',
|
|
$content,
|
|
$matches,
|
|
PREG_SET_ORDER
|
|
);
|
|
|
|
if (empty($matches)) {
|
|
return;
|
|
}
|
|
|
|
$prefix = trim($this->getValue('path'), '/');
|
|
$map = $this->assetMap($key);
|
|
$seen = [];
|
|
|
|
foreach ($matches as $match) {
|
|
$fullUrl = $match[0];
|
|
$uuid = $match[1];
|
|
$filename = $match[2];
|
|
|
|
if (isset($seen[$filename])) {
|
|
continue;
|
|
}
|
|
$seen[$filename] = true;
|
|
|
|
$imageData = $this->readImageData($key, $uuid, $filename, $fullUrl);
|
|
if ($imageData === null) {
|
|
continue;
|
|
}
|
|
|
|
$relative = $map[$filename] ?? $this->defaultAssetPath($filename);
|
|
BluditGithubImport::path($relative);
|
|
$repoPath = ($prefix ? $prefix . '/' : '') . $key . '/' . $relative;
|
|
$sha = $this->getFileSha($repoPath);
|
|
|
|
$data = [
|
|
'message' => ($sha ? 'Update' : 'Add') . ' asset: ' . $key . '/' . $relative,
|
|
'content' => base64_encode($imageData),
|
|
'branch' => $this->getValue('branch'),
|
|
];
|
|
if ($sha) {
|
|
$data['sha'] = $sha;
|
|
}
|
|
|
|
$this->githubRequest('PUT', '/contents/' . $repoPath, $data);
|
|
}
|
|
}
|
|
|
|
private function readImageData($key, $uuid, $filename, $fallbackUrl)
|
|
{
|
|
// Try filesystem first (UUID path, then page key symlink)
|
|
if (defined('PATH_UPLOADS_PAGES')) {
|
|
foreach ([PATH_UPLOADS_PAGES . $uuid . DS . $filename,
|
|
PATH_UPLOADS_PAGES . $key . DS . $filename] as $path) {
|
|
if (file_exists($path)) {
|
|
return file_get_contents($path);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fall back to fetching the image from the live URL
|
|
$ch = curl_init($fallbackUrl);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_FOLLOWLOCATION => true,
|
|
CURLOPT_TIMEOUT => 15,
|
|
CURLOPT_SSL_VERIFYPEER => true,
|
|
CURLOPT_USERAGENT => 'Bludit-GitHub-Export',
|
|
]);
|
|
$data = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
return ($data !== false && $httpCode === 200) ? $data : null;
|
|
}
|
|
|
|
private function bulkExport($includeDrafts = false)
|
|
{
|
|
if (!$this->isConfigured()) {
|
|
$this->setStatus('Error: GitHub settings not configured.');
|
|
return;
|
|
}
|
|
|
|
global $pages;
|
|
|
|
$list = $pages->getList(1, 9999, true, true, true, $includeDrafts, false);
|
|
$count = 0;
|
|
|
|
foreach ($list as $key) {
|
|
try {
|
|
$page = new Page($key);
|
|
$this->exportPage($page, false);
|
|
$count++;
|
|
} catch (Exception $e) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
$type = $includeDrafts ? 'everything' : 'published articles';
|
|
$this->setStatus(date('Y-m-d H:i:s') . " — Bulk export: {$count} {$type} pushed.");
|
|
}
|
|
|
|
private function buildMarkdown($page, $key, $rawContent = null)
|
|
{
|
|
$content = $rawContent ?? $page->content();
|
|
|
|
// Rewrite absolute Bludit upload URLs to relative img/ paths
|
|
// e.g. https://domain/bl-content/uploads/pages/UUID/image.png → ../img/image.png
|
|
$map = $this->assetMap($key);
|
|
$content = preg_replace_callback(
|
|
'/https?:\/\/[^\/]+\/bl-content\/uploads\/pages\/[^\/]+\/([^"\')\s<>]+)/',
|
|
function ($match) use ($map) {
|
|
return '../' . ($map[$match[1]] ?? $this->defaultAssetPath($match[1]));
|
|
},
|
|
$content
|
|
);
|
|
|
|
// Unwrap carousel code blocks: fenced blocks containing only image lines
|
|
// become plain image lists so GitHub renders them instead of showing raw text.
|
|
$content = preg_replace_callback(
|
|
'/^```[^\n]*\n(.*?)^```[ \t]*$/ms',
|
|
function ($m) {
|
|
$lines = explode("\n", $m[1]);
|
|
$imageLines = [];
|
|
foreach ($lines as $line) {
|
|
$trimmed = trim($line);
|
|
if ($trimmed === '') {
|
|
continue;
|
|
}
|
|
if (!preg_match('/^!\[[^\]]*\]\([^)]+\)$/', $trimmed)) {
|
|
return $m[0]; // contains non-image content — leave the block intact
|
|
}
|
|
$imageLines[] = $trimmed;
|
|
}
|
|
if (empty($imageLines)) {
|
|
return $m[0];
|
|
}
|
|
return implode("\n", $imageLines) . "\n";
|
|
},
|
|
$content
|
|
);
|
|
|
|
// Tags
|
|
$rawTags = $page->getValue('tags');
|
|
$tagLines = '';
|
|
if (!empty($rawTags)) {
|
|
$tagArray = is_array($rawTags) ? $rawTags : array_map('trim', explode(',', $rawTags));
|
|
foreach ($tagArray as $tag) {
|
|
$tag = trim((string)$tag);
|
|
if ($tag !== '') {
|
|
$tagLines .= "\n - " . json_encode($tag, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
}
|
|
}
|
|
}
|
|
|
|
$fm = "---\n";
|
|
$fm .= 'title: ' . json_encode(Sanitize::htmlDecode($page->title()), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
|
|
$fm .= 'date: ' . date('Y-m-d', strtotime($page->getValue('dateRaw'))) . "\n";
|
|
$fm .= 'slug: ' . $key . "\n";
|
|
$fm .= 'status: ' . $page->getValue('type') . "\n";
|
|
$fm .= 'tags:' . ($tagLines ?: ' []') . "\n";
|
|
|
|
$description = Sanitize::htmlDecode($page->getValue('description'));
|
|
if (!empty($description)) {
|
|
$fm .= 'description: ' . json_encode($description, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
|
|
}
|
|
|
|
$category = Sanitize::htmlDecode($page->getValue('category'));
|
|
if (!empty($category) && $category !== 'uncategorized') {
|
|
$fm .= 'category: ' . json_encode($category, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
|
|
}
|
|
|
|
$cover = $page->coverImage();
|
|
if (!empty($cover)) {
|
|
$cover = preg_replace_callback(
|
|
'/https?:\/\/[^\/]+\/bl-content\/uploads\/pages\/[^\/]+\/([^"\')\s<>]+)/',
|
|
function ($match) use ($map) { return '../' . ($map[$match[1]] ?? $this->defaultAssetPath($match[1])); },
|
|
$cover
|
|
);
|
|
$fm .= 'cover: ' . json_encode($cover, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
|
|
}
|
|
|
|
$fm .= "---\n\n";
|
|
|
|
return $fm . $content;
|
|
}
|
|
|
|
private function getFilePath($key)
|
|
{
|
|
$prefix = trim($this->getValue('path'), '/');
|
|
return ($prefix ? $prefix . '/' : '') . $key . '/content/index.md';
|
|
}
|
|
|
|
private function getFileSha($filePath)
|
|
{
|
|
$result = $this->githubRequest('GET', '/contents/' . $filePath);
|
|
return (is_array($result) && isset($result['sha'])) ? $result['sha'] : null;
|
|
}
|
|
|
|
protected function githubRequest($method, $endpoint, $data = null)
|
|
{
|
|
$token = $this->getValue('token');
|
|
$owner = $this->getValue('owner');
|
|
$repo = $this->getValue('repo');
|
|
|
|
if (!$token || !$owner || !$repo) {
|
|
return false;
|
|
}
|
|
|
|
if ($method === 'GET' && strpos($endpoint, '/contents/') === 0 && strpos($endpoint, '?') === false) {
|
|
$endpoint .= '?ref=' . rawurlencode($this->getValue('branch'));
|
|
}
|
|
$url = 'https://api.github.com/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . $endpoint;
|
|
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_HTTPHEADER => [
|
|
'Authorization: Bearer ' . $token,
|
|
'Accept: application/vnd.github+json',
|
|
'X-GitHub-Api-Version: 2022-11-28',
|
|
'User-Agent: Bludit-GitHub-Export',
|
|
'Content-Type: application/json',
|
|
],
|
|
CURLOPT_SSL_VERIFYPEER => true,
|
|
CURLOPT_TIMEOUT => 15,
|
|
]);
|
|
|
|
if ($method === 'PUT') {
|
|
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
|
} elseif ($method === 'DELETE') {
|
|
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
|
}
|
|
|
|
$response = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$curlError = curl_error($ch);
|
|
curl_close($ch);
|
|
|
|
if ($response === false) {
|
|
$this->setStatus('Error: cURL — ' . $curlError);
|
|
return false;
|
|
}
|
|
|
|
// 404 on GET = file/dir doesn't exist yet, not an error
|
|
if ($httpCode === 404) {
|
|
return null;
|
|
}
|
|
|
|
$decoded = json_decode($response, true);
|
|
|
|
if ($httpCode >= 400) {
|
|
$message = isset($decoded['message']) ? $decoded['message'] : 'HTTP ' . $httpCode;
|
|
$this->setStatus('Error: GitHub API (' . $httpCode . ') — ' . $message);
|
|
return false;
|
|
}
|
|
|
|
return $decoded;
|
|
}
|
|
|
|
private function isConfigured()
|
|
{
|
|
return $this->getValue('token') && $this->getValue('owner') && $this->getValue('repo');
|
|
}
|
|
|
|
private function setStatus($message)
|
|
{
|
|
file_put_contents($this->workspace() . 'status.txt', $message);
|
|
}
|
|
|
|
private function getStatus()
|
|
{
|
|
$file = $this->workspace() . 'status.txt';
|
|
return file_exists($file) ? file_get_contents($file) : '';
|
|
}
|
|
}
|