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.
This commit is contained in:
interlope committed 2026-11-01 18:33:07 +01:00
1 parent feefb08a85
commit 3dfd367814
6 files changed
+848 -80

No files matched your search

+110 -41
View File
@@ -1,75 +1,144 @@
# bludit-github
# bludit-github-sync
A [Bludit](https://www.bludit.com) plugin that automatically exports your blog articles to a GitHub repository as Markdown files.
Export Bludit articles to GitHub, and import an individual article back through an explicit preview and confirmation step.
## What it does
Saving an article in Bludit can automatically export it. Changes committed to GitHub **do not automatically publish to Bludit**: use the import controls when you are ready to apply them.
Whenever you create, update, or delete an article in Bludit, the plugin pushes the change to a GitHub repository via the GitHub API. Each article is exported as its own folder, with the markdown file inside `content/` and images inside `img/`:
## Repository layout
```
```text
my-article/
content/
index.md
img/
photo.png
screenshot.jpg
another-post/
content/
index.md
screenshot.png
files/
deployment-log.txt
```
Each `index.md` contains a YAML frontmatter block followed by the article content. Image references are written as relative paths from the `content/` subfolder:
`index.md` contains metadata followed by the HTML or Markdown body:
```markdown
---
title: "My Article"
date: 2026-05-01
date: 2026-11-01
slug: my-article
status: published
tags:
- php
- bludit
description: "Optional meta description"
category: tech
- homelab
- network
description: "An optional meta description."
category: network
---
Article content with images like ![](../img/photo.png)
Article content with ![Screenshot](../img/screenshot.png)
and a [deployment log](../files/deployment-log.txt).
```
## Installation
HTML `src` and `href` references use the same relative paths. External HTTP(S) links remain unchanged; imports never download them. Literal fenced code blocks and inline/HTML code examples are not treated as asset links.
1. Copy the `bludit-github` folder into your Bludit `bl-plugins/` directory.
2. Go to **Admin → Plugins** and enable **GitHub Export**.
3. Open the plugin settings and fill in your GitHub credentials.
## Install or upgrade
1. Copy **both `plugin.php` and `importer.php`**, plus `metadata.json` and `languages/`, into the existing plugin directory under `bl-plugins/`. Keep the installed directory name unchanged when upgrading, so Bludit keeps the plugin's settings and workspace.
2. Enable **GitHub Sync** under **Admin → Plugins** and open its settings. The old display name was **GitHub Export**.
3. Configure the repository owner, name, branch, optional path prefix and token. Existing settings are retained on upgrade.
The plugin configuration screen uses Bludit's normal administrator authorization and POST/CSRF protection. There is no public import endpoint or webhook.
## Settings
| Field | Description |
|---|---|
| Personal Access Token | A GitHub token with `public_repo` scope (or `repo` if the repository is private). [Generate one here](https://github.com/settings/tokens/new). |
| Repository Owner | Your GitHub username or organization name. |
| Repository Name | The target repository (e.g. `my-blog`). |
| Branch | Branch to push to (default: `main`). |
| Path Prefix | Optional subfolder prefix. Leave empty to export at the repo root. |
| Auto-export draft articles on save | When checked, draft articles are automatically pushed to GitHub when saved or modified. **Default is OFF**. |
| Auto-export published articles on save | When checked, published articles are automatically pushed to GitHub when saved or modified. **Default is ON**. |
| Personal Access Token | A classic token with `public_repo` for public repositories or `repo` for private repositories; alternatively a fine-grained token scoped to this repository with Contents read/write for exports. Import also needs read access to repository commits, trees and blobs. |
| Repository Owner | GitHub user or organization. |
| Repository Name | Target repository, such as `blog`. |
| Branch | Branch used for both exports and import previews; default `main`. Branch names containing slashes are supported. |
| Path Prefix | Optional subfolder above the article folders. Leave empty for the repository root. |
| Auto-export draft articles on save | Default off. |
| Auto-export published articles on save | Default on. |
## Bulk export
Use **Save** after configuring the repository. Import preview also saves the displayed settings before reading GitHub. Changing repository settings after preview requires a fresh preview.
The settings page includes two export buttons:
- **Export everything** — pushes all articles (both drafts and published) to GitHub in one go.
- **Export published only** — pushes only published articles to GitHub.
## Import one article
Useful on first install or after changing the target repository.
For example, to import the article stored as `load-balancing-my-homelab/content/index.md`:
## Behavior
1. Push the article and its referenced assets to the configured GitHub branch.
2. Open the plugin settings and enter `load-balancing-my-homelab` under **Import one article from GitHub**. With a path prefix, still enter only the article folder name.
3. Choose the status behavior **before previewing**:
- **Import as draft** is checked by default, including for an existing published article. Confirming with this option changes the page to a draft.
- Uncheck it to use a nonempty repository `status`. A blank or omitted status preserves an existing page's type; a new page remains a draft. Future published dates are scheduled by Bludit.
4. Click **Preview import**. Review create versus replace, commit, metadata, resulting status, incoming source text and the assets to download. Existing content can be expanded for comparison. The incoming HTML is escaped in the preview, not executed.
5. Click **Confirm this import**. The plugin downloads and verifies the assets, saves a protected backup when replacing a page, then uses Bludit's `createPage()` or `editPage()` functions to save the article and rebuild category/tag indexes.
6. Check the imported draft or live page in Bludit, including its images and attachment links.
- **On article save** (if auto-export is enabled): `content/index.md` is created or updated, and all images referenced in the content are uploaded to `img/`.
- **On article delete** (if auto-export is enabled): both `content/index.md` and all files in `img/` are deleted (GitHub removes the empty folders automatically).
- **Manual bulk export:** Use the "Export everything" or "Export published only" button to push articles regardless of auto-export setting.
- **Errors** are displayed in the plugin settings panel and never interfere with Bludit's own save process.
The preview is pinned to the Git commit it displayed; newer GitHub commits require another preview. It belongs to the current administrator session, expires after 30 minutes and can only be confirmed once. **Cancel preview** discards it without editing an article.
## Requirements
Confirmation checks the persisted local page metadata and body again, including after downloads. An intervening local edit requires a new preview. Imports are serialized with each other, but do not lock out Bludit's normal editor: avoid editing the same page during confirmation. Bludit's page storage is not a transactional database.
- Bludit 3.20+
- PHP with `curl` extension enabled
### Metadata and content
- Supported fields: `title`, `date`, `slug`, `status`, `tags`, `description`, `category`, `cover`.
- A title and nonempty body are required. If present, `slug` must match the selected folder. Only a single top-level page key is supported, not nested pages or renames; Bludit slug normalization/collisions are refused.
- Dates use `YYYY-MM-DD`. An unchanged date preserves the existing time; a changed date uses midnight in the site's timezone.
- Supported statuses: `draft`, `published`, `static`, `sticky`, `scheduled`, or blank for legacy exports. Normal Bludit scheduling applies.
- Tags use `tags: []` or an indented ` - tag` list. Omitted metadata is preserved on update, apart from the explicit draft option and required title. Blank fields clear their corresponding values.
- A nonempty category key must already exist in Bludit; create it in the administration interface first. Tags are rebuilt through Bludit after save.
- Existing author, UUID and unrelated metadata are retained. New articles belong to the logged-in administrator.
- Front matter supports the small scalar/list format emitted by this plugin, not general YAML. Nested objects, aliases, tags, multiline scalars, unknown fields and duplicate fields are rejected. Quote strings containing YAML punctuation with JSON-style double quotes.
- A `cover` may be a repository `../img/…` reference or an HTTP(S) URL. A repository cover image is imported with the other assets.
- The body is stored as supplied, with asset destinations rewritten to Bludit uploads. The site's usual Markdown configuration controls Markdown rendering. Confirmed HTML is trusted author content, just like content saved in Bludit's editor; previewing it does not sanitize the eventual article.
### Assets
Referenced files under the selected article's `img/` and `files/` directories are fetched through GitHub's blob API and checked against their Git SHA-1 object IDs. Unreferenced files are ignored. This also allows an unused SVG source diagram to remain in Git while its PNG rendition is imported.
Supported extensions: PNG, JPEG, GIF, WebP, AVIF, ICO, TXT, PDF, CSV, JSON and ZIP. Images must pass PHP's image-type validation; support for some formats depends on the PHP runtime. Executable files, HTML and SVG are refused. Absolute external URLs, symlinks, traversal, percent-encoded paths and paths containing whitespace are not fetched. Use simple filenames and inline Markdown links or quoted HTML attributes; reference-style Markdown links and `srcset` are not imported.
Limits: 1 MiB for the article document; at most 100 referenced assets, 10 MiB per asset and 32 MiB total. The GitHub recursive tree must be complete; truncated trees are rejected. Server memory and request timeouts must also accommodate the import. Git LFS pointer files are not hydrated.
Assets receive generated filenames under the page's UUID uploads directory, preserving existing uploads. A protected per-page mapping makes later exports restore the original repository `img/` and `files/` paths. Old uploads are not pruned automatically.
## Backups and failures
Before updating an existing article, the plugin writes `backup-*.php` in its Bludit workspace, containing the prior page database row, raw content and imported asset mapping. The file begins with a PHP access-denial guard and is created with restrictive permissions. Keep the site's normal full backups too; these per-page snapshots are not a backup of the entire site or all uploads.
Failed downloads leave page content unchanged. If saving or a later page hook fails, the plugin attempts to restore the previous metadata and content, or remove a partially created page. External side effects from other plugins cannot be rolled back. An unsuccessful automatic restoration is reported explicitly with the backup filename.
Backups and asset mapping files remain in the workspace across plugin upgrades. Do not delete mappings while using import/export round trips. Backups are retained until an administrator removes them. To recover manually, an administrator can read the JSON after the guard line on the server and restore the recorded body and metadata through Bludit; this release does not have a restore button.
## Export
- Saving a page exports it when its auto-export setting is enabled.
- **Export everything** exports drafts and published articles; **Export published only** exports the published selection.
- Bludit upload URLs become relative `../img/` or `../files/` references. Imported asset mappings retain their original paths.
- Exported `status` comes from Bludit's actual page `type`. Earlier plugin versions exported an empty `status` field; imports accept that legacy format.
- Contents API reads use the configured branch, so export SHA lookups do not accidentally use the repository's default branch.
- Deleting a Bludit page retains the existing automatic GitHub deletion behavior: its document and direct files in `img/` and `files/` are removed. This is unrelated to importing: missing repository pages never trigger local deletion. Nested repository asset directories are not recursively deleted.
- The plugin's own export hooks are suppressed during import. Other Bludit plugins still receive normal page hooks.
Do not save an older local copy over a newer GitHub article before importing: ordinary exports still replace the repository's current file. This is a manual two-way workflow, not automatic conflict merging or continuous deployment.
## Requirements and checks
- Bludit 3.20+; integration tests run against **Bludit 3.22.0**.
- PHP 7.4+ with cURL; the site's usual Bludit PHP extensions, including mbstring.
- Writable page storage, uploads and plugin workspace, plus access to `api.github.com` over HTTPS.
No Composer dependencies or YAML extension are required. To run the tests against a separate, unmodified Bludit checkout:
```bash
php -l plugin.php
php -l importer.php
BLUDIT_ROOT=/path/to/bludit-3.22.0 php tests/import.php
```
Tests use temporary local storage and a fake GitHub transport. They exercise real Bludit page creation/editing, asset rewriting and export round trips, rejected paths, escaped previews, stale metadata/configuration, failed downloads and rollback after a failing page hook. They do not modify a live site or contact GitHub.
Optionally exercise a real exported article as a fixture too:
```bash
BLUDIT_ROOT=/path/to/bludit-3.22.0 \
BLOG_ARTICLE_DIR=/path/to/blog/load-balancing-my-homelab \
php tests/import.php
```
+153
View File
@@ -0,0 +1,153 @@
<?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;
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"plugin-data": {
"name": "GitHub Export",
"description": "Export Bludit articles to a GitHub repository as Markdown files with YAML frontmatter."
}
"plugin-data": {
"name": "GitHub Sync",
"description": "Export Bludit articles to GitHub and preview/import selected articles, metadata, images and attachments back into Bludit."
}
}
+8 -8
View File
@@ -1,10 +1,10 @@
{
"author": "spleenftw",
"email": "",
"website": "https://github.com/interlope/bludit-github",
"version": "1.0.0",
"releaseDate": "2026-06-01",
"license": "MIT",
"compatible": "3.22.0",
"notes": "Export Bludit articles to a GitHub repository as Markdown files with YAML frontmatter."
"author": "spleenftw",
"email": "",
"website": "https://github.com/Spleenftw/bludit-github-sync",
"version": "1.1.0",
"releaseDate": "2026-11-01",
"license": "MIT",
"compatible": "3.22.0",
"notes": "Export Bludit articles to GitHub and preview/import selected articles, metadata, images and attachments back into Bludit."
}
+339 -27
View File
@@ -1,7 +1,10 @@
<?php
require_once __DIR__ . "/importer.php";
class pluginBluditGithub extends Plugin
{
private $importing = false;
public function init()
{
$this->dbFields = [
@@ -69,6 +72,8 @@ class pluginBluditGithub extends Plugin
$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';
@@ -82,17 +87,31 @@ class pluginBluditGithub extends Plugin
{
parent::post();
if (!empty($_POST['bulkExportAll'])) {
$this->bulkExport(true);
} elseif (!empty($_POST['bulkExportPublished'])) {
$this->bulkExport(false);
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 (empty($key) || !$this->isConfigured()) {
if ($this->importing || empty($key) || !$this->isConfigured()) {
return;
}
$page = new Page($key);
@@ -110,7 +129,7 @@ class pluginBluditGithub extends Plugin
public function afterPageModify($key)
{
try {
if (empty($key) || !$this->isConfigured()) {
if ($this->importing || empty($key) || !$this->isConfigured()) {
return;
}
$page = new Page($key);
@@ -128,7 +147,7 @@ class pluginBluditGithub extends Plugin
public function afterPageDelete($key)
{
try {
if (empty($key) || !$this->isConfigured()) {
if ($this->importing || empty($key) || !$this->isConfigured()) {
return;
}
@@ -146,13 +165,14 @@ class pluginBluditGithub extends Plugin
]);
}
// Delete all files in img/
$items = $this->githubRequest('GET', '/contents/' . $base . '/img');
if (is_array($items)) {
// 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') {
if (($item['type'] ?? '') === 'file') {
$this->githubRequest('DELETE', '/contents/' . $item['path'], [
'message' => 'Delete image: ' . $key . '/img/' . $item['name'],
'message' => 'Delete asset: ' . $key . '/' . $directory . '/' . $item['name'],
'sha' => $item['sha'],
'branch' => $this->getValue('branch'),
]);
@@ -164,6 +184,278 @@ class pluginBluditGithub extends Plugin
}
}
// 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 ---
@@ -173,7 +465,7 @@ class pluginBluditGithub extends Plugin
$rawContent = $page->content();
// Export images using URLs found in the content (before rewriting)
$this->exportImages($key, $rawContent);
$this->exportImages($key, $rawContent . "\n" . $page->coverImage());
$content = $this->buildMarkdown($page, $key, $rawContent);
$filePath = $this->getFilePath($key);
@@ -195,6 +487,12 @@ class pluginBluditGithub extends Plugin
}
}
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(
@@ -209,6 +507,7 @@ class pluginBluditGithub extends Plugin
}
$prefix = trim($this->getValue('path'), '/');
$map = $this->assetMap($key);
$seen = [];
foreach ($matches as $match) {
@@ -226,11 +525,13 @@ class pluginBluditGithub extends Plugin
continue;
}
$repoPath = ($prefix ? $prefix . '/' : '') . $key . '/img/' . $filename;
$relative = $map[$filename] ?? $this->defaultAssetPath($filename);
BluditGithubImport::path($relative);
$repoPath = ($prefix ? $prefix . '/' : '') . $key . '/' . $relative;
$sha = $this->getFileSha($repoPath);
$data = [
'message' => ($sha ? 'Update' : 'Add') . ' image: ' . $key . '/img/' . $filename,
'message' => ($sha ? 'Update' : 'Add') . ' asset: ' . $key . '/' . $relative,
'content' => base64_encode($imageData),
'branch' => $this->getValue('branch'),
];
@@ -302,9 +603,12 @@ class pluginBluditGithub extends Plugin
// Rewrite absolute Bludit upload URLs to relative img/ paths
// e.g. https://domain/bl-content/uploads/pages/UUID/image.png → ../img/image.png
$content = preg_replace(
$map = $this->assetMap($key);
$content = preg_replace_callback(
'/https?:\/\/[^\/]+\/bl-content\/uploads\/pages\/[^\/]+\/([^"\')\s<>]+)/',
'../img/$1',
function ($match) use ($map) {
return '../' . ($map[$match[1]] ?? $this->defaultAssetPath($match[1]));
},
$content
);
@@ -341,31 +645,36 @@ class pluginBluditGithub extends Plugin
foreach ($tagArray as $tag) {
$tag = trim((string)$tag);
if ($tag !== '') {
$tagLines .= "\n - " . $tag;
$tagLines .= "\n - " . json_encode($tag, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
}
}
$fm = "---\n";
$fm .= 'title: "' . str_replace('"', '\"', $page->title()) . "\"\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('status') . "\n";
$fm .= 'status: ' . $page->getValue('type') . "\n";
$fm .= 'tags:' . ($tagLines ?: ' []') . "\n";
$description = $page->getValue('description');
$description = Sanitize::htmlDecode($page->getValue('description'));
if (!empty($description)) {
$fm .= 'description: "' . str_replace('"', '\"', $description) . "\"\n";
$fm .= 'description: ' . json_encode($description, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
}
$category = $page->getValue('category');
$category = Sanitize::htmlDecode($page->getValue('category'));
if (!empty($category) && $category !== 'uncategorized') {
$fm .= 'category: ' . $category . "\n";
$fm .= 'category: ' . json_encode($category, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
}
$cover = $page->coverImage();
if (!empty($cover)) {
$fm .= 'cover: ' . $cover . "\n";
$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";
@@ -385,7 +694,7 @@ class pluginBluditGithub extends Plugin
return (is_array($result) && isset($result['sha'])) ? $result['sha'] : null;
}
private function githubRequest($method, $endpoint, $data = null)
protected function githubRequest($method, $endpoint, $data = null)
{
$token = $this->getValue('token');
$owner = $this->getValue('owner');
@@ -395,7 +704,10 @@ class pluginBluditGithub extends Plugin
return false;
}
$url = 'https://api.github.com/repos/' . $owner . '/' . $repo . $endpoint;
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, [
+234
View File
@@ -0,0 +1,234 @@
<?php
if (PHP_SAPI !== 'cli') { http_response_code(403); exit; }
// Run against an unmodified Bludit source checkout: BLUDIT_ROOT=/path/to/bludit php tests/import.php
error_reporting(E_ALL);
set_error_handler(function ($level, $message, $file, $line) {
if (!(error_reporting() & $level)) { return false; }
throw new ErrorException($message, 0, $level, $file, $line);
});
$root = getenv('BLUDIT_ROOT');
if (!$root || !is_file($root . '/bl-kernel/pages.class.php')) { throw new RuntimeException('Set BLUDIT_ROOT to a Bludit 3.x source checkout.'); }
$dir = sys_get_temp_dir() . '/bludit-import-test-' . bin2hex(random_bytes(8)) . '/';
foreach (['pages', 'uploads', 'workspace'] as $sub) { mkdir($dir . $sub, 0700, true); }
define('BLUDIT', true);
define('DS', DIRECTORY_SEPARATOR);
define('PATH_PAGES', $dir . 'pages/');
define('PATH_UPLOADS_PAGES', $dir . 'uploads/');
define('DOMAIN_UPLOADS_PAGES', 'https://example.test/bl-content/uploads/pages/');
define('FILENAME', 'index.txt');
define('DB_PAGES', $dir . 'pages.php');
define('DB_DATE_FORMAT', 'Y-m-d H:i:s');
define('CHARSET', 'UTF-8');
define('DEBUG_MODE', false);
define('LOG_SEP', ' | ');
define('LOG_TYPE_ERROR', 'ERROR');
define('LOG_TYPE_INFO', 'INFO');
define('CHECK_SYMBOLIC_LINKS', true);
define('EXTREME_FRIENDLY_URL', false);
define('DIR_PERMISSIONS', 0755);
define('ORDER_BY', 'date');
foreach (['text', 'sanitize', 'filesystem', 'valid', 'date', 'session'] as $name) { require $root . '/bl-kernel/helpers/' . $name . '.class.php'; }
class Log { public static function set(...$args) {} }
require $root . '/bl-kernel/abstract/dbjson.class.php';
require $root . '/bl-kernel/pages.class.php';
require $root . '/bl-kernel/pagex.class.php';
require $root . '/bl-kernel/functions.php';
class Plugin {
public $dbFields = [];
public $config = ['owner' => 'owner', 'repo' => 'blog', 'branch' => 'articles/new', 'path' => '', 'token' => 'test-token', 'exportPublishedAuto' => true];
public function getValue($key) { return $this->config[$key] ?? ''; }
public function workspace() { global $dir; return $dir . 'workspace/'; }
public function post() {}
}
require dirname(__DIR__) . '/plugin.php';
class TestIndex { public $count = 0; public function exists($key) { return in_array($key, ['network', 'uncategorized'], true); } public function reindex() { $this->count++; return true; } }
class TestLog { public function add($args) {} }
class Theme {
public static $fail = false;
public static function plugins($hook, $args) {
global $plugin;
$plugin->$hook(...$args);
if (self::$fail) { throw new RuntimeException('Injected failure in a different page hook'); }
}
}
class TestPlugin extends pluginBluditGithub {
public $requests = [];
public $blobs = [];
public $tree = [];
public $failBlob = null;
public function fixture($slug, $raw, $assets = []) {
$this->tree = []; $this->blobs = [];
foreach (['content/index.md' => $raw] + $assets as $path => $data) {
$sha = sha1('blob ' . strlen($data) . "\0" . $data);
$this->blobs[$sha] = ['encoding' => 'base64', 'content' => base64_encode($data)];
$this->tree[] = ['path' => ($this->config['path'] ? $this->config['path'] . '/' : '') . $slug . '/' . $path, 'mode' => '100644', 'type' => 'blob', 'size' => strlen($data), 'sha' => $sha];
}
}
protected function githubRequest($method, $endpoint, $data = null) {
$this->requests[] = [$method, $endpoint, $data];
if (strpos($endpoint, '/commits/') === 0) { return ['sha' => str_repeat('a', 40), 'commit' => ['tree' => ['sha' => str_repeat('b', 40)]]]; }
if (strpos($endpoint, '/git/trees/') === 0) { return ['tree' => $this->tree, 'truncated' => false]; }
if (strpos($endpoint, '/git/blobs/') === 0) {
$sha = substr($endpoint, 11);
return $sha === $this->failBlob ? false : ($this->blobs[$sha] ?? false);
}
if ($method === 'GET') { return null; }
return ['content' => ['sha' => str_repeat('c', 40)]];
}
public function invoke($name, ...$args) { $m = new ReflectionMethod(pluginBluditGithub::class, $name); if (PHP_VERSION_ID < 80100) { $m->setAccessible(true); } return $m->invoke($this, ...$args); }
}
function check($ok, $message) { if (!$ok) { throw new RuntimeException($message); } }
function rejects($fn, $contains) {
try { $fn(); } catch (Throwable $e) { check(strpos($e->getMessage(), $contains) !== false, 'Unexpected error: ' . $e->getMessage()); return; }
throw new RuntimeException('Expected rejection containing: ' . $contains);
}
function document($slug, $body, $extra = '') {
return "---\ntitle: \"Article & test\"\nslug: $slug\ndate: 2026-01-01\nstatus: published\ntags:\n - homelab\n - \"quote: test\"\ncategory: network\ndescription: \"Example\"\n$extra---\n\n$body";
}
$pages = new Pages(); $categories = new TestIndex(); $tags = new TestIndex(); $syslog = new TestLog();
$site = new class { public function sortBy() { return 'date'; } public function uriFilters($unused = null) { return ''; } };
$L = new class { public function g($s) { return $s; } public function getunicodeChars() { return []; } public function currentLanguage() { return 'en'; } };
Session::set('username', 'admin');
$plugin = new TestPlugin();
try {
// Parser and filesystem boundaries.
foreach (['../bad', 'a/b', '', 'x%2fy', 'x\\y'] as $slug) { rejects(function () use ($slug) { BluditGithubImport::slug($slug); }, 'folder name'); }
rejects(function () { BluditGithubImport::document(document('one', 'body', "title: duplicate\n"), 'one'); }, 'duplicate');
rejects(function () { BluditGithubImport::document(str_replace('2026-01-01', '2026-02-30', document('one', 'body')), 'one'); }, 'valid YYYY');
rejects(function () { BluditGithubImport::document(document('wrong', 'body'), 'one'); }, 'slug must match');
rejects(function () { BluditGithubImport::document("---\ntitle: !php/object\n---\nbody", 'one'); }, 'Unsupported YAML');
rejects(function () { BluditGithubImport::links('<img src="../img/../../config.php">'); }, 'path segment');
rejects(function () { BluditGithubImport::links('[asset](../files/x%2ephp)'); }, 'Unsafe');
rejects(function () { BluditGithubImport::extension('img/image.svg'); }, 'Unsupported asset type');
check(BluditGithubImport::links('<img src="https://untrusted.test/x.png">') === [], 'External assets must not be fetched');
check(BluditGithubImport::links("```html\n<img src=\"../img/example.png\">\n```\n<code>![example](../img/code.png)</code>") === [], 'Do not import literal code examples');
$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jGZkAAAAASUVORK5CYII=');
$body = '<p>Hello</p><img src="../img/picture.png"><a href="../files/log.txt">Log</a>';
$plugin->fixture('article', document('article', $body), ['img/picture.png' => $png, 'files/log.txt' => 'deployment log']);
$plugin->invoke('previewImport', 'article', true);
check(!$pages->exists('article'), 'Preview must not create a page');
check(glob(PATH_UPLOADS_PAGES . '*') === [], 'Preview must not write uploads');
$plan = Session::get('bluditGithubImport');
check(strpos($plugin->form(), '&lt;p&gt;Hello') !== false, 'Preview must escape incoming HTML');
check($plugin->requests[0][1] === '/commits/articles%2Fnew', 'Branch names with slash must be encoded');
$plugin->invoke('confirmImport', $plan['confirmation']);
$page = new Page('article');
check($page->getValue('type') === 'draft', 'Default import must be a draft');
check($page->getValue('category') === 'network', 'Category must be imported');
check(strpos($page->contentRaw(), '../img/') === false, 'Image links must be rewritten');
check(strpos($page->contentRaw(), DOMAIN_UPLOADS_PAGES . $page->uuid()) !== false, 'Use actual page UUID in uploads');
check(count(glob(PATH_UPLOADS_PAGES . $page->uuid() . '/gh-*')) === 2, 'Install image and attachment');
check($categories->count > 0 && $tags->count > 0, 'Use core indexing functions');
check(count(array_filter($plugin->requests, function ($r) { return $r[0] !== 'GET'; })) === 0, 'Import must not push through export hooks');
check(Session::get('bluditGithubImport') === false, 'Successful confirmation must be single-use');
$exported = $plugin->invoke('buildMarkdown', $page, 'article', $page->contentRaw());
check(strpos($exported, '../img/picture.png') !== false && strpos($exported, '../files/log.txt') !== false, 'Round-trip original repository paths');
check(strpos($exported, 'status: draft') !== false, 'Export uses Bludit type, not missing status field');
$roundTripMeta = BluditGithubImport::document($exported, 'article');
check($roundTripMeta['meta']['title'] === 'Article & test', 'Do not accumulate HTML entities in exported titles');
$plugin->invoke('exportImages', 'article', $page->contentRaw());
$puts = array_filter($plugin->requests, function ($r) { return $r[0] === 'PUT'; });
check(count($puts) === 2, 'Export uploads both images and attachments');
check(strpos(json_encode($puts), 'files\\/log.txt') !== false, 'Attachment stays in files on export');
// Existing page update, backup, default metadata preservation and author retention.
$before = new Page('article');
$plugin->requests = [];
$plugin->fixture('article', "---\ntitle: Updated\nslug: article\nstatus: \n---\n<p>Updated</p>");
$plugin->invoke('previewImport', 'article', false);
$plan = Session::get('bluditGithubImport');
$plugin->invoke('confirmImport', $plan['confirmation']);
$page = new Page('article');
check($page->title() === 'Updated' && $page->uuid() === $before->uuid(), 'Update same page, keep UUID');
check($page->getValue('username') === 'admin' && $page->getValue('category') === 'network', 'Preserve omitted fields');
check($page->getValue('type') === 'draft', 'Legacy blank status preserves existing type');
$backups = glob($plugin->workspace() . 'backup-*.php');
check(count($backups) === 1 && strpos(file_get_contents($backups[0]), '<?php http_response_code(403); exit; ?>') === 0, 'Protected backup before overwrite');
// Stale metadata written by another request, not only a changed content file.
$plugin->invoke('previewImport', 'article', true);
$plan = Session::get('bluditGithubImport');
$other = new Pages(); $other->edit(['key' => 'article', 'description' => 'concurrent edit']);
rejects(function () use ($plugin, $plan) { $plugin->invoke('confirmImport', $plan['confirmation']); }, 'changed since preview');
$pages = new Pages();
check($pages->getPageDB('article')['description'] === 'concurrent edit', 'Concurrent metadata must survive');
$plugin->invoke('previewImport', 'article', true); $plan = Session::get('bluditGithubImport');
$plugin->config['branch'] = 'different';
rejects(function () use ($plugin, $plan) { $plugin->invoke('confirmImport', $plan['confirmation']); }, 'settings changed');
$plugin->config['branch'] = 'articles/new';
$plugin->fixture('category', str_replace('category: network', 'category: missing-category', document('category', 'body')));
rejects(function () use ($plugin) { $plugin->invoke('previewImport', 'category', true); }, 'Create the category');
// Missing/unsafe assets and transport failures leave the current page alone.
$plugin->fixture('missing', document('missing', '<img src="../img/missing.png">'));
rejects(function () use ($plugin) { $plugin->invoke('previewImport', 'missing', true); }, 'Missing asset');
$plugin->fixture('symlink', document('symlink', 'body')); $plugin->tree[0]['mode'] = '120000';
rejects(function () use ($plugin) { $plugin->invoke('previewImport', 'symlink', true); }, 'Invalid Git blob');
$plugin->fixture('download', document('download', '<a href="../files/log.txt">file</a>'), ['files/log.txt' => 'log']);
$plugin->invoke('previewImport', 'download', true); $plan = Session::get('bluditGithubImport');
$plugin->failBlob = $plan['assets']['../files/log.txt']['sha'];
rejects(function () use ($plugin, $plan) { $plugin->invoke('confirmImport', $plan['confirmation']); }, 'base64 blob');
check(!(new Pages())->exists('download'), 'Failed download must not create a page');
$plugin->failBlob = null;
// A later hook can fail after core already wrote the page: restore both DB and content.
$old = $plugin->invoke('importLocalState', 'article');
$plugin->fixture('article', document('article', 'replacement'));
$plugin->invoke('previewImport', 'article', true); $plan = Session::get('bluditGithubImport');
Theme::$fail = true;
rejects(function () use ($plugin, $plan) { $plugin->invoke('confirmImport', $plan['confirmation']); }, 'Injected failure');
Theme::$fail = false;
check($plugin->invoke('importLocalState', 'article') === $old, 'Failed update must restore complete prior state');
$plugin->fixture('new-failure', document('new-failure', 'replacement'));
$plugin->invoke('previewImport', 'new-failure', true); $plan = Session::get('bluditGithubImport');
Theme::$fail = true;
rejects(function () use ($plugin, $plan) { $plugin->invoke('confirmImport', $plan['confirmation']); }, 'Injected failure');
Theme::$fail = false;
check(!(new Pages())->exists('new-failure'), 'Failed creation must remove partial page');
// Covers and explicit publication work with path prefixes and keep the original repository path.
$plugin->config['path'] = 'articles';
$plugin->fixture('cover-page', document('cover-page', 'Body', "cover: ../img/cover.png\n"), ['img/cover.png' => $png]);
$plugin->invoke('previewImport', 'cover-page', false); $plan = Session::get('bluditGithubImport');
check($plan['effectiveType'] === 'published' && $plan['asDraft'] === false, 'Explicit published status shown in preview');
$plugin->invoke('confirmImport', $plan['confirmation']);
$page = new Page('cover-page');
check($page->getValue('type') === 'published' && strpos($page->coverImage(), 'gh-') !== false, 'Import cover and explicit publication');
$exported = $plugin->invoke('buildMarkdown', $page, 'cover-page', $page->contentRaw());
check(strpos($exported, 'cover: "../img/cover.png"') !== false, 'Cover path round-trips');
$plugin->config['path'] = '';
$plugin->fixture('invalid-image', document('invalid-image', '<img src="../img/fake.png">'), ['img/fake.png' => '<?php echo "bad";']);
$plugin->invoke('previewImport', 'invalid-image', true); $plan = Session::get('bluditGithubImport');
rejects(function () use ($plugin, $plan) { $plugin->invoke('confirmImport', $plan['confirmation']); }, 'Invalid image');
check(!(new Pages())->exists('invalid-image'), 'Disguised executable must not be imported');
$plugin->fixture('expired', document('expired', 'body'));
$plugin->invoke('previewImport', 'expired', true); $plan = Session::get('bluditGithubImport');
$plan['created'] = time() - 1801; Session::set('bluditGithubImport', $plan);
rejects(function () use ($plugin, $plan) { $plugin->invoke('confirmImport', $plan['confirmation']); }, 'expired');
// Optional end-to-end fixture using an actual exported article directory.
if (($articleDir = getenv('BLOG_ARTICLE_DIR'))) {
$slug = basename(rtrim($articleDir, '/'));
$raw = file_get_contents($articleDir . '/content/index.md');
$parsed = BluditGithubImport::document($raw, $slug);
$fixtureAssets = [];
foreach (BluditGithubImport::links($parsed['content']) as $relative) {
$fixtureAssets[$relative] = file_get_contents($articleDir . '/' . $relative);
}
$plugin->fixture($slug, $raw, $fixtureAssets);
$plugin->invoke('previewImport', $slug, true); $plan = Session::get('bluditGithubImport');
$plugin->invoke('confirmImport', $plan['confirmation']);
$actual = new Page($slug);
check($actual->getValue('type') === 'draft', 'Actual article remains draft in test');
$roundTrip = $plugin->invoke('buildMarkdown', $actual, $slug, $actual->contentRaw());
foreach ($fixtureAssets as $relative => $data) {
check(strpos($roundTrip, '../' . $relative) !== false, 'Actual article asset round-trip: ' . $relative);
}
echo 'PASS: actual article, ' . count($fixtureAssets) . " referenced assets imported and round-tripped\n";
}
echo "PASS: parser, preview, create/update with real Bludit Pages, images, attachments, export round-trip, stale previews, failed downloads and rollback\n";
} finally {
Filesystem::deleteRecursive($dir);
}