apermo / linkstash
A WordPress linkstash plugin.
Requires
- php: >=8.1
Requires (Dev)
- apermo/apermo-coding-standards: ^3.0
- apermo/phpstan-wordpress-rules: ^0.2
- brain/monkey: ^2.6
- phpstan/extension-installer: ^1.4
- phpunit/phpunit: ^11.0
- szepeviktor/phpstan-wordpress: ^2.0
- yoast/phpunit-polyfills: ^3.0
This package is auto-updated.
Last update: 2026-05-03 15:14:38 UTC
README
A self-hosted WordPress plugin for collecting bookmarks. Inspired by linkding. Stores URL + title + notes + tags as a custom post type and exposes a token-protected REST API so a browser extension can save links from anywhere.
Per-bookmark public/private visibility, idempotent save (safe to re-submit),
and CORS configured for chrome-extension://* origins out of the box.
Requirements
- PHP 8.1+
- WordPress 6.4+
- Composer (development only — runtime has no Composer dependencies)
- Node.js 20+ and npm (activates husky pre-commit hook, runs Playwright)
- DDEV (for local development)
Installation
- Clone or download this repository into
wp-content/plugins/linkstash/. - Run
composer install --no-devto generate the autoloader. - Activate the plugin through the WordPress "Plugins" screen.
- Visit Settings → LinkStash to generate an API token (see Authentication below).
Authentication
LinkStash accepts two equivalent authentication schemes; pick whichever fits your client.
WordPress Application Passwords (Basic Auth)
Available in WordPress core. Generate one under Users → Profile → Application Passwords and pass it as Basic Auth:
curl -u "your-username:xxxx xxxx xxxx xxxx xxxx xxxx" \
https://example.tld/wp-json/linkstash/v1/bookmarks
LinkStash Bearer Tokens
Better suited for browser extensions: generate at Settings → LinkStash → API Tokens. The plain token is shown once at creation time — copy it immediately. Send it as:
curl -H "Authorization: Bearer <token>" \
https://example.tld/wp-json/linkstash/v1/bookmarks
Each token is bound to a WordPress user; permission checks run against that
user's capabilities (edit_posts for write endpoints).
REST API
Base path: /wp-json/linkstash/v1.
| Method | Path | Description |
|---|---|---|
GET |
/bookmarks |
List bookmarks (filters: tag, q, unread, archived, public/private, page, per_page) |
POST |
/bookmarks |
Create a bookmark (idempotent — same URL returns existing record with X-LinkStash-Existing: 1) |
GET |
/bookmarks/{id} |
Fetch a single bookmark |
PATCH |
/bookmarks/{id} |
Update fields |
DELETE |
/bookmarks/{id} |
Delete a bookmark |
GET |
/tags |
List tags with bookmark counts |
GET |
/check?url=... |
Returns {exists: bool, id?: int} for a given URL |
Examples
Save a bookmark; let the server fetch the title and description:
curl -X POST https://example.tld/wp-json/linkstash/v1/bookmarks \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.tld/article","tags":["reading"],"public":true}'
Check whether a URL is already saved (browser-extension "already saved" badge):
curl -H "Authorization: Bearer <token>" \ "https://example.tld/wp-json/linkstash/v1/check?url=https://example.tld/article"
Search and filter:
curl -H "Authorization: Bearer <token>" \ "https://example.tld/wp-json/linkstash/v1/bookmarks?tag=reading&unread=1"
Public versus private bookmarks
Bookmarks use WordPress's native post_status:
publish(public) — readable without authentication via the REST API.private— only the owner (and users withedit_others_posts) can read.
Anonymous GET /bookmarks returns only public bookmarks. Authenticated users
see their own bookmarks plus any public bookmarks owned by other users. POST,
PATCH, DELETE always require authentication.
CORS
By default LinkStash sends CORS headers permitting chrome-extension://*
origins. Add additional origins via the linkstash_allowed_origins filter:
add_filter( 'linkstash_allowed_origins', static function ( array $origins ): array { $origins[] = 'https://my-frontend.example.tld'; return $origins; } );
To narrow the default allow-list once you know your extension's specific ID — defense-in-depth on top of the Bearer requirement — return only that origin:
add_filter( 'linkstash_allowed_origins', static function (): array { return [ 'chrome-extension://abcdefghijklmnopqrstuvwxyzabcdef' ]; } );
Outbound HTTP
LinkStash makes one outbound HTTP request per saved bookmark — to
the bookmarked URL itself, via wp_safe_remote_get (5 s timeout, up
to three redirects, all re-validated). The fetched body is parsed
for <title> and <meta name="description" / og:description>; on
failure the bookmark still saves and an "unreachable" warning is
shown on next edit. wp_safe_remote_get blocks loopback and private
IP ranges, so a hostile URL can't be used to probe internal services.
No third-party services are contacted. No analytics, no telemetry. The companion Chrome extension talks only to the host you configure on its options page.
Development
composer install npm install # activates husky pre-commit hook composer cs # PHPCS composer cs:fix # PHPCBF composer analyse # PHPStan composer test:unit # unit tests (Brain Monkey) composer test:integration # integration tests (wp-phpunit) npm run test:e2e # Playwright E2E
Local WordPress environment
ddev start && ddev orchestrate