srijan/kirby-micropub
Composer 安装命令:
composer require srijan/kirby-micropub
包简介
Micropub server endpoint for Kirby 5 — create, update, delete, and query posts from IndieWeb clients
README 文档
README
A Micropub server endpoint for Kirby 5. It lets IndieWeb clients (iA Writer, Quill, the Micro.blog app, micropublish.net, …) create, update, delete/undelete, and query posts under a configured parent page — including q=config, q=source (single post and post lists), q=syndicate-to, and a media endpoint. Update, delete/undelete, and q=source are the pieces the old kirby3-micropublisher (unmaintained, Kirby 3) never had.
Status: 0.x — the option surface and event payload shapes are unstable pre-1.0. Pin a version and read the changelog before upgrading.
Requires PHP >= 8.2 and Kirby 5. Authentication is bearer-token only: tokens are verified against an external IndieAuth token endpoint or a config-defined static token list. The plugin does not host an authorization server.
Installation
Composer (recommended)
composer require srijan/kirby-micropub
Git submodule
git submodule add <repository-url> site/plugins/micropub
Zip download — copy the release zip's contents to site/plugins/micropub.
One caveat for submodule and plain source-zip installs: the HTML→Markdown converter (league/html-to-markdown) is a Composer dependency. Composer installs and release zips ship it; a bare source checkout does not. Without it everything works except HTML content — a create/update sending content: {"html": …} gets a clear spec-shaped invalid_request error instead of a conversion. To enable it, install the dependency into the plugin's vendor/ directory (the plugin's own composer.json redirects default installs to vendor-dev/, which is only used for running the test suite):
cd site/plugins/micropub
COMPOSER_VENDOR_DIR=vendor composer install --no-dev
or add league/html-to-markdown to your site's own composer.json.
Quick start
- Configure at least one way to authenticate — the default token endpoint (
tokens.indieauth.com) or a static token:
// site/config/config.php return [ 'srijan.micropub' => [ 'parent' => 'notes', // posts are created under this page 'template' => 'note', // with this template // For clients with manual token entry (e.g. iA Writer). // Tokens must be >= 32 random characters; value is the scope list. 'tokens' => [ 'REPLACE-WITH-32-PLUS-RANDOM-CHARACTERS…' => 'create update delete media', ], ], ];
- Advertise the endpoint (and, for IndieAuth-flow clients like Quill, an authorization and token endpoint) in your site's
<head>:
<link rel="micropub" href="<?= url('micropub') ?>"> <link rel="authorization_endpoint" href="https://indieauth.com/auth"> <link rel="token_endpoint" href="https://tokens.indieauth.com/token">
- Make sure the post blueprint has the fields the default mapping writes (or remap them — see Content-model mapping):
# site/blueprints/pages/note.yml title: Note fields: text: { label: Text, type: textarea } tags: { label: Tags, type: tags } date: { label: Date, type: date, time: true } type: { label: Type, type: text } interactionUrl: { label: Interaction URL, type: url } extraProperties: { label: Extra properties, type: hidden } micropubDeleted: extends: fields/micropub-deleted
The mf2 name property maps to the page's own title, which every Kirby page already has — no extra blueprint field needed.
- Point a Micropub client at your site. The endpoint lives at
/micropub, the media endpoint at/micropub/media.
Options reference
All options are prefixed srijan.micropub. in site/config/config.php.
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
bool | true |
Master switch; when false both routes answer 404. |
cache |
bool | true |
Plugin cache (kirby()->cache('srijan.micropub')) storing token verification results and the remote rate-limit window. |
tokenEndpoint |
string | 'https://tokens.indieauth.com/token' |
External IndieAuth token endpoint used to verify bearer tokens. https:// only; anything else fails closed. |
me |
string|null | null |
Site identity URL that verified tokens' me must match (canonicalized). Defaults to $site->url(). |
tokens |
array | [] |
Static tokens: token string => scopes (space-separated string or array). Tokens shorter than 32 characters never match. |
defaultStatus |
string | 'listed' |
Status for new posts when the client sends no post-status: 'listed' or 'draft'. |
delete |
string | 'draft' |
Delete mode: 'draft' soft-deletes (sets the deleted flag, unpublishes, serves 410); 'hard' permanently deletes (no undelete). |
parent |
string | 'notes' |
Id of the parent page all Micropub posts live under. Only pages directly below it are reachable for update/delete/source. |
template |
string | 'note' |
Template for created posts; also the fence when resolving post URLs. |
mediaParent |
string | 'micropub-media' |
Slug of the auto-created media holding page. |
media.allowedMimes |
array | jpeg/png/gif/webp/avif | Upload MIME allowlist: mime => stored extension map or a plain list of MIME strings. Can only narrow within the hard denylist floor (see Media endpoint). |
media.parentTitle |
string | 'Micropub Media' |
Title of the auto-created media holding page. |
media.staticPublish |
bool | true |
Publish uploads to the public webroot immediately so the webserver (and static page caches) serve them directly with clean image MIME types. |
syndicateTo |
array | [] |
Syndication targets returned verbatim for q=config / q=syndicate-to; entries are spec-shaped ['uid' => …, 'name' => …]. |
tombstone |
bool | true |
Serve 410 Gone for soft-deleted posts via a route:after hook; false disables the hook entirely. |
maxBodyBytes |
int | 1000000 |
Maximum request body size (413 above it). Multipart bodies are capped at maxBodyBytes + maxUploadBytes instead. |
maxUploadBytes |
int | 10485760 |
Maximum size per uploaded file. |
sourceLimitMax |
int | 100 |
Upper bound for the limit parameter of q=source post lists (client default is 20). |
fields |
array | see below | mf2 property → content field map, merged over the defaults. Set a mapping to false to disable it. |
postTypes |
array | see below | mf2 interaction property → stored type value. Replaces the default wholesale; key order is detection precedence. |
honorMpSlug |
bool | true |
Use the client's mp-slug as the first slug candidate. |
slug |
Closure|null | null |
Closure replacing the whole slug chain; receives the normalized mf2 array. Its return is still slugged and conflict-checked. |
createAttrs |
Closure|null | null |
Closure receiving (normalized mf2, attrs); its return is recursively merged over the final createChild() attrs. |
photoEmbed |
Closure|null | null |
Closure rendering photo embed text: fn (string $url, ?string $filename, string $alt): string. Default: a (image: …) kirbytag for owned files, a markdown image for external URLs. |
cacheTtlSeconds |
int | 120 |
TTL for cached token verification results (clamped to 30–300 s, and bounded by the token's own exp). |
remoteCallsPerWindow |
int | 30 |
Maximum outbound token-endpoint verifications per 5-minute window; above it, unverifiable tokens get a 401 without a remote call. 0 disables the cap. |
Default fields map:
| Logical property | Default field | Written with |
|---|---|---|
content |
text |
Sanitized markdown (HTML converted, kirbytags and raw HTML escaped). |
name |
title |
Plain text — the page's own title (slug-derived when the client sends no name). |
category |
tags |
Comma-joined tag list. |
published |
date |
Y-m-d H:i:s in the site timezone; defaults to now. |
interactionUrl |
interactionUrl |
The URL of the matched interaction property (http(s) only). |
extraProperties |
extraProperties |
JSON stash of unmapped properties (raw client input — see warning below). |
deletedFlag |
micropubDeleted |
Soft-delete toggle. |
type |
type |
Detected post type value. |
Default postTypes taxonomy (first present property wins):
| mf2 property | Stored type value |
|---|---|
in-reply-to |
reply |
repost-of |
repost |
like-of |
like |
bookmark-of |
bookmark |
Posts with no interaction property get the type note.
Content-model mapping
The plugin has no opinion about your blueprint — retarget every write through the fields map. The defaults match a Kirby starterkit-style blueprint (text, title, tags, date). Example: a site that stores post content in a markdown field and keeps the display name in a dedicated heading field (separate from the page title):
'srijan.micropub' => [ 'fields' => [ 'content' => 'markdown', 'name' => 'heading', ], ],
Keys are the plugin's fixed logical roles; values are your field names. Your entries merge over the defaults, so you only list what differs. Two mappings targeting the same field throw at construction (fail closed — otherwise a create-scoped property could alias, say, the soft-delete flag).
Disabling a mapping. Set a mapping to false and that property no longer has a dedicated field. On create its values fall into the extraProperties stash (JSON), and q=source re-emits them from there, so nothing the client sent is lost.
Warning: stash values are raw, unsanitized client input — kept byte-for-byte on purpose so
q=sourceround-trips them. Never renderextraPropertiesunescaped in a template.
Custom post-type taxonomy. postTypes replaces the default wholesale (merging would scramble the precedence order):
'srijan.micropub' => [ 'postTypes' => [ 'in-reply-to' => 'response', // checked first 'like-of' => 'favorite', ], ],
Remapping deletedFlag. The shipped blueprint fragment (fields/micropub-deleted) is hardwired to the field name micropubDeleted — blueprint YAML cannot read plugin config. If you remap fields.deletedFlag, define your own toggle field under the new name instead of extending the fragment. Disabling the mapping ('deletedFlag' => false) disables soft delete entirely: delete requests then fail with invalid_request unless you also set 'delete' => 'hard'.
Slug behavior
For new posts the slug is resolved through an ordered chain — first candidate that is non-empty after slugging and doesn't collide with an existing sibling (drafts included) wins:
- the client's
mp-slugcommand (only whenhonorMpSlugistrue) - the
nameproperty - a 40-character excerpt of
content - a
YmdHistimestamp (appends a counter on collision, so it never fails)
Every candidate passes through Kirby's Str::slug(), so hostile input (../etc, control characters) can never reach the filesystem raw.
A slug closure replaces steps 1–3; it receives the normalized mf2 array and its return is still slugged and conflict-checked, with the timestamp as backstop:
'srijan.micropub' => [ 'slug' => fn (array $mf2) => 'note-' . date('Ymd'), ],
Page-model interplay. The plugin passes slug and title to createChild(), but a site page model that overrides create() runs after that and wins. If your model generates its own slugs (date-sequence schemes and the like), the plugin-resolved slug is advisory only — set honorMpSlug => false so clients' mp-slug suggestions aren't silently accepted-then-ignored.
Events
Subscribe with ordinary Kirby hooks; payloads bind by argument name.
Note: event names intentionally use the short
micropub.prefix (notsrijan.micropub.) and could collide if another plugin adopts the same prefix — this is a pre-1.0 API and may change.
| Event | Payload | Fires |
|---|---|---|
micropub.create:after |
page, mf2 (normalized request array) |
After a post is fully created (photos attached, status set). |
micropub.update:before |
page, operations (replace/add/delete ops) |
Before update ops are applied. |
micropub.update:after |
page, operations |
After update ops are applied. |
micropub.delete:before |
page, mode ('draft'|'hard') |
Before a delete. |
micropub.delete:after |
page, mode |
After a delete. |
micropub.statusChange:before |
page, from, to |
Before every endpoint-driven changeStatus — create publishing, update post-status, soft-delete unpublish, undelete relist. |
micropub.statusChange:after |
page, from, to, succeeded (bool) |
Always — it fires from a finally, with succeeded => false when the change (or a downstream hook) threw, so subscribers can restore state staged in :before. The exception then propagates unchanged. |
Undelete is a status change, not a delete: it fires statusChange events, never delete ones. All micropub.* events fire impersonated as kirby, so hooks may update the page. A :before subscriber can abort the operation by throwing; a throwing :after subscriber is logged and swallowed — the operation is already committed by then, and a 500 would make clients retry it.
Worked example: suppressing re-syndication on undelete. Suppose a syndication (POSSE) plugin posts a page to an external network whenever it becomes listed, and marks it done by writing the result URL into a field. Undeleting a Micropub post relists it — which that plugin would read as "new post, syndicate it again". Stage around it with the paired events and a shared variable (syndicationUrl / enableExternalPosting are example field names — use your plugin's):
// site/config/config.php $externalPostingState = null; return [ 'hooks' => [ 'micropub.statusChange:before' => function ($page, $from, $to) use (&$externalPostingState) { // A previously syndicated post coming back to listed = undelete; // switch external posting off for the duration of the change. if ($to === 'listed' && $page->syndicationUrl()->isNotEmpty()) { $externalPostingState = $page->enableExternalPosting()->value(); $page->update(['enableExternalPosting' => false]); } }, 'micropub.statusChange:after' => function ($page, $from, $to, $succeeded) use (&$externalPostingState) { // Runs even when the change failed, so the flag is always restored. if ($externalPostingState !== null) { $page->update(['enableExternalPosting' => $externalPostingState]); $externalPostingState = null; } }, ], ];
Media endpoint
POST /micropub/media with multipart/form-data and a file part named file; requires the media (or create) scope. The flow:
- The upload is validated: real MIME type sniffed from the bytes (the client-declared type and filename are never trusted), size checked against
maxUploadBytes. - It's stored under a random hex filename on a hidden holding page (
mediaParent, created automatically; unlisted so its files serve statically, while its own HTML answers 404). - The response is
201 Createdwith the file URL in theLocationheader. - When a later create request references that URL in a
photoproperty, the file is claimed — moved onto the new post. Only files actually owned by the holding page (and on this site's host) are ever moved; any other URL is treated as an external reference and is never touched or fetched server-side. Multipartphotouploads directly on the main endpoint work too.
MIME allowlist and the denylist floor. media.allowedMimes defaults to JPEG/PNG/GIF/WebP/AVIF. Config can only narrow it: a hard denylist floor — SVG, all application/* and text/* types, and executable/markup extensions (php, html, js, …) — applies regardless of config and can never be re-enabled. Uploaded files are served from your site's origin, so anything a browser or the PHP SAPI could execute would be a stored-XSS / code-execution vector.
media.staticPublish. Uploads are published to the public webroot immediately, so the very first fetch (a client verifying the returned URL) is served by the webserver as a clean image/jpeg instead of falling through Kirby's PHP media route — and so setups with a static page cache serve media without hitting PHP. Set it to false to skip that step.
Security notes
- Fail-closed token verification. The token endpoint must be
https://; remote calls have short timeouts; results — including definitive rejections — are cached briefly by token hash, and outbound verifications are capped per 5-minute window (remoteCallsPerWindow), so a token-spray can't amplify into the remote endpoint. Transient outages are never cached as rejections. mecanonicalization. The token'smemust canonicalize to the same https origin as the site's. URLs with userinfo, paths, queries, or fragments (https://example.com@evil.com,https://example.com/~user) never canonicalize and are rejected.- Scopes.
create,update,delete,media— each action checks its own scope; the legacypostscope is an alias forcreate, andcreatealso grants media upload. - Static tokens must be at least 32 random characters (shorter ones never match) and are compared with
hash_equals. Give each one the narrowest scope list that works, and rotate them like passwords. - Size caps. Request bodies are capped by
maxBodyBytes, uploads bymaxUploadBytes(multipart bodies by their sum). - Single-parent resolver fence. Update, delete, and
q=sourceURLs only resolve to pages exactly one level below the configuredparentwith the configuredtemplate. The homepage, other sections, and deeper descendants are unreachable through the endpoint. - Write-time sanitization. Content is stored as markdown with kirbytag syntax and raw HTML escaped and non-http(s) link destinations defanged; names/headings are stripped to plain text; interaction URLs must be http(s).
- The extras stash is raw.
extraPropertiespreserves client input byte-for-byte forq=sourceround-tripping — never render it unescaped.
Client setup
- iA Writer (manual token entry): add a Micropub account with your site URL and paste a static token from the
tokensoption. - Quill, Micro.blog, and other IndieAuth-flow clients: sign in with your domain. The client discovers your
rel="micropub",rel="authorization_endpoint", andrel="token_endpoint"links, sends you through the authorization flow, and gets a token from the token endpoint — which this plugin then verifies remotely.
Testing
composer install && composer test
Dev dependencies (including PHPUnit) land in vendor-dev/, keeping vendor/ free for the runtime-dependency fallback described under Installation.
License
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 1
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-07-09