承接 srijan/kirby-micropub 相关项目开发

从需求分析到上线部署,全程专人跟进,保证项目质量与交付效率

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

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

  1. 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',
        ],
    ],
];
  1. 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">
  1. 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.

  1. 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=source round-trips them. Never render extraProperties unescaped 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:

  1. the client's mp-slug command (only when honorMpSlug is true)
  2. the name property
  3. a 40-character excerpt of content
  4. a YmdHis timestamp (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 (not srijan.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:

  1. The upload is validated: real MIME type sniffed from the bytes (the client-declared type and filename are never trusted), size checked against maxUploadBytes.
  2. 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).
  3. The response is 201 Created with the file URL in the Location header.
  4. When a later create request references that URL in a photo property, 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. Multipart photo uploads 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.
  • me canonicalization. The token's me must 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 legacy post scope is an alias for create, and create also 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 by maxUploadBytes (multipart bodies by their sum).
  • Single-parent resolver fence. Update, delete, and q=source URLs only resolve to pages exactly one level below the configured parent with the configured template. 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. extraProperties preserves client input byte-for-byte for q=source round-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 tokens option.
  • Quill, Micro.blog, and other IndieAuth-flow clients: sign in with your domain. The client discovers your rel="micropub", rel="authorization_endpoint", and rel="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

MIT

统计信息

  • 总下载量: 0
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 0
  • 点击次数: 3
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-07-09

承接程序开发

PHP开发

VUE

Vue开发

前端开发

小程序开发

公众号开发

系统定制

数据库设计

云部署

网站建设

安全加固