Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/media-endpoint-extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@dwk/micropub": minor
---

Implement the proposed media-endpoint extensions (#363, roadmap #354), gated
behind `extensions.proposed`: media `q=source` (newest-first listing and
by-URL lookup, `media` scope required), the `{ "url": ... }` upload response
body, and recoverable `action=delete`/`action=undelete` via an R2 `.trash/`
prefix with scope-pair enforcement and strict URL ownership validation.
Upload metadata is now always recorded in a new `micropub_media` D1 table
(best-effort while the group is off, fail-closed when on); the new
`mediaTrashRetentionDays` config (default 30) drives trash-row pruning, with
blob purge delegated to an R2 lifecycle rule.
6 changes: 6 additions & 0 deletions packages/micropub/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ toggled by maturity group via the `extensions` config
search over an injected `venues` D1 store, independent from post storage.
`geo`'s reverse-geocoded suggestion is a placeholder (echoes the query
coordinates) until a real lookup is wired in.
- **Media-endpoint extensions** (#363) — proposed-only media `q=source`
(listing + by-URL, `media` scope), `{ url }` upload response body, and
recoverable `action=delete`/`undelete` via an R2 `.trash/` prefix with
scope-pair enforcement. Upload metadata is always recorded in the
`micropub_media` D1 table (fail-closed only when the group is on).

## Spec

Expand Down Expand Up @@ -80,6 +85,7 @@ src/mf2.ts # mf2 body parsing (form + JSON), update operations, source v
src/pagination.ts # offset-based pagination parsing/validation (pure, reusable)
src/source-filters.ts # proposed source-list filter and cursor parsing (pure)
src/venues.ts # proposed q=geo venue store (D1) + query parsing
src/media.ts # proposed media-endpoint extensions: metadata store (D1), URL ownership validation, q=source parsing
src/auth.ts # token extraction, scope checking, DPoP enforcement
src/event.ts # h-event post type: markup rendering, h-event → CalendarEvent
src/fediverse.ts # h-entry → PostInput adapter + syndication to @dwk/activitypub's /publish (#278; wire-format contract, no AP import)
Expand Down
7 changes: 7 additions & 0 deletions packages/micropub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ The handler fails loudly at startup if any of these are missing:
filtered lists use deterministic keyset cursors.
- **Opt-in Location/Venue** (`q=geo`): a read-only proximity search over an
injected venue store, independent from post storage. See below.
- **Opt-in media-endpoint extensions**: with `extensions.proposed` on, the
media endpoint gains a `q=source` listing (newest-first, `media` scope
required) and by-URL lookup, a `{ "url": ... }` JSON body on upload, and
recoverable `action=delete`/`action=undelete` (requiring both the action
scope and `media`). Deleted blobs move to an R2 `.trash/` prefix retained
for `mediaTrashRetentionDays` (default 30); configure an R2 lifecycle rule
on that prefix to purge the bytes.

### Location/Venue (`q=geo`) extension

Expand Down
12 changes: 12 additions & 0 deletions packages/micropub/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,14 @@ export interface MicropubConfig {
readonly fediverse?: FediverseSyndicationConfig;
/** Maximum accepted media upload size in bytes. Defaults to 25 MiB. */
readonly maxMediaBytes?: number;
/**
* Days soft-deleted media stays recoverable under the R2 `.trash/` prefix
* before `undelete` permanently fails. Defaults to 30. Purging the trash
* *bytes* is delegated to an R2 lifecycle rule the composed deployment
* configures on the prefix; this window only drives the opportunistic
* pruning of expired metadata rows (proposed media-endpoint extensions).
*/
readonly mediaTrashRetentionDays?: number;
/**
* Whether to check each token against the issued-token store (revocation).
* Defaults to `true` — staleness here is a security bug, so the check hits the
Expand Down Expand Up @@ -224,6 +232,7 @@ export interface ResolvedConfig {
readonly syndicateTo: () => Promise<readonly SyndicationTarget[]>;
readonly fediverse?: FediverseSyndicationConfig;
readonly maxMediaBytes: number;
readonly mediaTrashRetentionDays: number;
readonly checkRevocation: boolean;
readonly checkDpopReplay: boolean;
readonly generatePostUrl: GeneratePostUrl;
Expand All @@ -232,6 +241,7 @@ export interface ResolvedConfig {
}

const DEFAULT_MAX_MEDIA_BYTES = 25 * 1024 * 1024;
const DEFAULT_MEDIA_TRASH_RETENTION_DAYS = 30;

/** Lowercase, dash-separated slug derived from arbitrary text (max 80 chars). */
function slugify(text: string): string {
Expand Down Expand Up @@ -358,6 +368,8 @@ export function resolveConfig(config: MicropubConfig): ResolvedConfig {
syndicateTo: normalizeSyndicateTo(config.syndicateTo),
...(config.fediverse ? { fediverse: config.fediverse } : {}),
maxMediaBytes: config.maxMediaBytes ?? DEFAULT_MAX_MEDIA_BYTES,
mediaTrashRetentionDays:
config.mediaTrashRetentionDays ?? DEFAULT_MEDIA_TRASH_RETENTION_DAYS,
checkRevocation: config.checkRevocation ?? true,
checkDpopReplay: config.checkDpopReplay ?? true,
generatePostUrl:
Expand Down
Loading
Loading