Introduce peer.paste

This commit is contained in:
Raven Scott
2026-05-27 22:23:30 -04:00
parent 396bba5bb2
commit e802bbcf64
8 changed files with 1031 additions and 0 deletions
+1
View File
@@ -24,6 +24,7 @@ This directory contains comprehensive documentation for the P2NS plugin system:
- **[peer.directory.md](peer.directory.md)** - Peer Directory plugin documentation - **[peer.directory.md](peer.directory.md)** - Peer Directory plugin documentation
- **[peer.visualize.md](peer.visualize.md)** - Peer Visualize plugin documentation - **[peer.visualize.md](peer.visualize.md)** - Peer Visualize plugin documentation
- **[file.drop.md](file.drop.md)** - File Drop plugin documentation - **[file.drop.md](file.drop.md)** - File Drop plugin documentation
- **[peer.paste.md](peer.paste.md)** - Peer Paste plugin documentation
- **[vis-network-migration.md](vis-network-migration.md)** - Migration guide for visualization libraries - **[vis-network-migration.md](vis-network-migration.md)** - Migration guide for visualization libraries
### Related Documentation ### Related Documentation
+59
View File
@@ -0,0 +1,59 @@
# Peer Paste Plugin
`peer.paste` provides temporary, replicated text snippets for the P2NS network.
## Overview
- Store paste metadata/content in HyperDB.
- Share via link (`/p/:id`) or API (`/api/pastes/:id/raw`).
- Supports expiration, burn-after-read, and max-read limits.
- Includes a lightweight web UI for create/list flows.
## Routes
- `POST /api/pastes` create a new paste
- `GET /api/pastes` list active pastes
- `GET /api/pastes/mine` list active pastes owned by local peer
- `GET /api/pastes/:id` get paste metadata
- `GET /api/pastes/:id/raw` consume/read paste content
- `PUT /api/pastes/:id` update paste (owner only; authenticated)
- `DELETE /api/pastes/:id` delete paste (owner only; authenticated)
- `GET /api/stats` get aggregate service stats
- `GET /api/health` lightweight plugin/db health check
- `POST /api/admin/cleanup` run cleanup immediately (authenticated)
- `GET /api/docs` interactive API documentation
- `GET /api/openapi.json` OpenAPI 3 JSON specification
- `GET /p/:id` human-readable paste page
## HyperDB Schema
Collection: `@peerpaste/pastes`
Fields:
- `id` (string, key)
- `title` (string)
- `content` (string)
- `language` (string)
- `ownerPeerId` (string)
- `destroyOnRead` (bool)
- `maxReads` (uint)
- `readCount` (uint)
- `createdAt` (uint)
- `expiresAt` (uint)
Index:
- `pastes-by-expiry` on `expiresAt`
## Lifecycle
- `onInit()` ensures DB readiness and starts hourly cleanup.
- Cleanup removes expired/consumed pastes and flushes DB.
- `onShutdown()` clears cleanup interval.
## Notes
- Content currently replicates via HyperDB, so use reasonable paste size limits.
- `POST` and `PUT` validate content and enforce a max length.
- Raw consumption increments read count and can auto-delete on read constraints.
- The `/api/docs` endpoint provides copy/paste-ready endpoint summaries.
- The `/api/openapi.json` endpoint can be imported by API tooling.
+33
View File
@@ -0,0 +1,33 @@
# peer.paste
Temporary P2P text snippets for the P2NS network.
## Features
- Expiring pastes (default 24h, max 7 days)
- Optional burn-after-first-read
- Optional max read count
- Public share links (`/p/:id`)
- Raw API access (`/api/pastes/:id/raw`)
- Metadata replication via HyperDB
## API
- `POST /api/pastes` create paste
- `GET /api/pastes` list active pastes
- `GET /api/pastes/mine` list active pastes owned by local peer
- `GET /api/pastes/:id` metadata
- `GET /api/pastes/:id/raw` consume/read paste content
- `PUT /api/pastes/:id` update paste (owner only, authenticated)
- `DELETE /api/pastes/:id` delete paste (owner only, authenticated)
- `GET /api/stats` service stats
- `GET /api/health` service health
- `POST /api/admin/cleanup` trigger immediate cleanup (authenticated)
- `GET /api/docs` interactive API docs page
- `GET /api/openapi.json` machine-readable OpenAPI schema
- `GET /p/:id` human-readable paste page
## Notes
- Pasted content is stored in HyperDB and replicates with peers.
- A cleanup task runs hourly to purge expired/consumed entries.
+50
View File
@@ -0,0 +1,50 @@
{
"name": "Peer Paste",
"version": "1.0.0",
"domain": "peer.paste",
"enabled": true,
"description": "Temporary P2P text snippets with expiration and burn-after-read options",
"author": "P2NS",
"license": "MIT",
"icon": "clipboard",
"dependencies": {},
"www": "www",
"hyperdb": {
"schemas": {
"namespace": "peerpaste",
"structs": [
{
"name": "paste",
"compact": true,
"fields": [
{ "name": "id", "type": "string", "required": true },
{ "name": "title", "type": "string", "required": false },
{ "name": "content", "type": "string", "required": true },
{ "name": "language", "type": "string", "required": false },
{ "name": "ownerPeerId", "type": "string", "required": false },
{ "name": "destroyOnRead", "type": "bool", "required": false },
{ "name": "maxReads", "type": "uint", "required": false },
{ "name": "readCount", "type": "uint", "required": true },
{ "name": "createdAt", "type": "uint", "required": true },
{ "name": "expiresAt", "type": "uint", "required": true }
]
}
]
},
"collections": [
{
"name": "pastes",
"schema": "@peerpaste/paste",
"key": ["id"]
}
],
"indexes": [
{
"name": "pastes-by-expiry",
"collection": "@peerpaste/pastes",
"unique": false,
"key": ["expiresAt"]
}
]
}
}
+678
View File
@@ -0,0 +1,678 @@
const crypto = require('crypto');
const sdk = require('../../includes/plugins/sdk');
const COLLECTION = '@peerpaste/pastes';
const DEFAULT_EXPIRES_HOURS = 24;
const MAX_EXPIRES_HOURS = 168;
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
const MAX_CONTENT_CHARS = 100000;
const MAX_TITLE_CHARS = 120;
const MAX_LANGUAGE_CHARS = 32;
const MAX_MAXREADS = 1000000;
let cleanupTimer = null;
function now() {
return Date.now();
}
function generatePasteId() {
return crypto.randomBytes(8).toString('hex');
}
function parseExpiresHours(input) {
const n = Number(input);
if (!Number.isFinite(n) || n <= 0) return DEFAULT_EXPIRES_HOURS;
return Math.min(Math.floor(n), MAX_EXPIRES_HOURS);
}
function sanitizeText(v, fallback = '') {
return typeof v === 'string' ? v : fallback;
}
function normalizePasteInput(body = {}, existing = null) {
const title = sanitizeText(body.title, existing ? existing.title : '').slice(0, MAX_TITLE_CHARS);
const language = sanitizeText(body.language, existing ? existing.language : '').slice(0, MAX_LANGUAGE_CHARS);
const content = body.content !== undefined ? sanitizeText(body.content) : (existing ? existing.content : '');
const destroyOnRead = body.destroyOnRead !== undefined ? !!body.destroyOnRead : (existing ? !!existing.destroyOnRead : false);
const maxReadsRaw = body.maxReads !== undefined ? body.maxReads : (existing ? existing.maxReads : 0);
const maxReads = Math.max(0, Math.min(MAX_MAXREADS, Math.floor(Number(maxReadsRaw) || 0)));
const expiresHours = body.expiresHours !== undefined ? parseExpiresHours(body.expiresHours) : null;
return {
title,
language,
content,
destroyOnRead,
maxReads,
expiresHours
};
}
function toPublicPaste(paste, includeOwner = false) {
const t = now();
const out = {
id: paste.id,
title: paste.title || '',
language: paste.language || '',
destroyOnRead: !!paste.destroyOnRead,
maxReads: paste.maxReads || 0,
readCount: paste.readCount || 0,
createdAt: paste.createdAt,
expiresAt: paste.expiresAt,
expiresInMs: Math.max(0, paste.expiresAt - t),
isExpired: paste.expiresAt <= t
};
if (includeOwner) out.ownerPeerId = paste.ownerPeerId || '';
return out;
}
function isConsumable(paste) {
if (!paste) return false;
if (paste.expiresAt <= now()) return false;
if (paste.destroyOnRead && (paste.readCount || 0) >= 1) return false;
if ((paste.maxReads || 0) > 0 && (paste.readCount || 0) >= paste.maxReads) return false;
return true;
}
function shouldDeleteAfterRead(paste, nextReadCount) {
if (paste.destroyOnRead) return true;
if ((paste.maxReads || 0) > 0 && nextReadCount >= paste.maxReads) return true;
return false;
}
async function deletePaste(paste) {
await sdk.db.delete(COLLECTION, { id: paste.id });
}
async function cleanupExpiredPastes() {
try {
const t = now();
const pastes = await sdk.db.find(COLLECTION, {});
let deleted = 0;
for (const paste of pastes) {
const exhaustedReads = (paste.maxReads || 0) > 0 && (paste.readCount || 0) >= paste.maxReads;
const burned = !!paste.destroyOnRead && (paste.readCount || 0) >= 1;
if (paste.expiresAt <= t || exhaustedReads || burned) {
await deletePaste(paste);
deleted += 1;
}
}
if (deleted > 0) {
await sdk.db.flush();
sdk.log.info('peer.paste', `Cleanup removed ${deleted} expired/consumed paste(s)`);
}
return { deleted };
} catch (err) {
sdk.log.error('peer.paste', `Cleanup failed: ${err.message}`);
throw err;
}
}
async function parseJsonBody(req, res) {
try {
return await sdk.router.readJSON(req);
} catch (err) {
sdk.router.badRequest(res, 'Invalid JSON body');
return null;
}
}
async function createPaste(req, res) {
const body = await parseJsonBody(req, res);
if (!body) return true;
const input = normalizePasteInput(body);
if (!input.content || !input.content.trim()) {
return sdk.router.badRequest(res, 'content is required');
}
if (input.content.length > MAX_CONTENT_CHARS) {
return sdk.router.badRequest(res, `content exceeds ${MAX_CONTENT_CHARS} characters`);
}
const createdAt = now();
const expiresAt = createdAt + (input.expiresHours || DEFAULT_EXPIRES_HOURS) * 60 * 60 * 1000;
const ownerPeerId = sdk.state.localPeerId || '';
const paste = {
id: generatePasteId(),
title: input.title,
content: input.content,
language: input.language,
ownerPeerId,
destroyOnRead: input.destroyOnRead,
maxReads: input.maxReads,
readCount: 0,
createdAt,
expiresAt
};
await sdk.db.insert(COLLECTION, paste);
await sdk.db.flush();
return sdk.router.json(res, {
success: true,
paste: toPublicPaste(paste, true),
viewUrl: `/p/${paste.id}`,
apiUrl: `/api/pastes/${paste.id}`,
rawUrl: `/api/pastes/${paste.id}/raw`
});
}
async function listPastes(req, res, ownerOnly = false) {
const all = await sdk.db.find(COLLECTION, {});
const t = now();
const ownerPeerId = sdk.state.localPeerId || '';
const search = sanitizeText(req.query?.search || '').toLowerCase();
let items = all.filter((p) => p.expiresAt > t);
if (ownerOnly) items = items.filter((p) => (p.ownerPeerId || '') === ownerPeerId);
if (search) {
items = items.filter((p) =>
(p.title || '').toLowerCase().includes(search) ||
(p.language || '').toLowerCase().includes(search) ||
p.id.includes(search)
);
}
items.sort((a, b) => b.createdAt - a.createdAt);
const pastes = items.map((p) => toPublicPaste(p, ownerOnly));
return sdk.router.json(res, {
count: pastes.length,
pastes
});
}
async function getPasteMetadata(res, pasteId) {
const paste = await sdk.db.get(COLLECTION, { id: pasteId });
if (!paste) return sdk.router.notFound(res, 'Paste not found');
return sdk.router.json(res, {
paste: toPublicPaste(paste, true),
consumable: isConsumable(paste)
});
}
async function consumePaste(res, pasteId) {
const paste = await sdk.db.get(COLLECTION, { id: pasteId });
if (!paste || !isConsumable(paste)) {
return sdk.router.notFound(res, 'Paste not found or expired');
}
const nextReadCount = (paste.readCount || 0) + 1;
const consumed = shouldDeleteAfterRead(paste, nextReadCount);
if (consumed) {
await deletePaste(paste);
} else {
await sdk.db.insert(COLLECTION, {
...paste,
readCount: nextReadCount
});
}
await sdk.db.flush();
return sdk.router.json(res, {
paste: {
...toPublicPaste({ ...paste, readCount: nextReadCount }, true),
content: paste.content
},
consumed
});
}
async function updatePaste(req, res, pasteId) {
const localPeer = await sdk.auth.requireLocalPeer(req, res);
if (!localPeer) return true;
const existing = await sdk.db.get(COLLECTION, { id: pasteId });
if (!existing) return sdk.router.notFound(res, 'Paste not found');
if (existing.ownerPeerId && existing.ownerPeerId !== localPeer) {
return sdk.router.forbidden(res, 'Only the owner can update this paste');
}
const body = await parseJsonBody(req, res);
if (!body) return true;
const input = normalizePasteInput(body, existing);
if (body.content !== undefined && (!input.content || !input.content.trim())) {
return sdk.router.badRequest(res, 'content cannot be empty');
}
if (input.content.length > MAX_CONTENT_CHARS) {
return sdk.router.badRequest(res, `content exceeds ${MAX_CONTENT_CHARS} characters`);
}
const updated = {
...existing,
title: input.title,
language: input.language,
content: body.content !== undefined ? input.content : existing.content,
destroyOnRead: input.destroyOnRead,
maxReads: input.maxReads,
expiresAt: input.expiresHours
? now() + input.expiresHours * 60 * 60 * 1000
: existing.expiresAt
};
await sdk.db.insert(COLLECTION, updated);
await sdk.db.flush();
return sdk.router.json(res, {
success: true,
paste: toPublicPaste(updated, true)
});
}
async function deletePasteRoute(req, res, pasteId) {
const localPeer = await sdk.auth.requireLocalPeer(req, res);
if (!localPeer) return true;
const paste = await sdk.db.get(COLLECTION, { id: pasteId });
if (!paste) return sdk.router.notFound(res, 'Paste not found');
if (paste.ownerPeerId && paste.ownerPeerId !== localPeer) {
return sdk.router.forbidden(res, 'Only the owner can delete this paste');
}
await deletePaste(paste);
await sdk.db.flush();
return sdk.router.json(res, { success: true, deleted: pasteId });
}
async function getStats(res) {
const all = await sdk.db.find(COLLECTION, {});
const t = now();
const active = all.filter((p) => p.expiresAt > t);
const expired = all.length - active.length;
const consumed = all.filter((p) => (p.readCount || 0) > 0).length;
const burnEnabled = all.filter((p) => !!p.destroyOnRead).length;
const totalReads = all.reduce((acc, p) => acc + (p.readCount || 0), 0);
const ownerPeerId = sdk.state.localPeerId || '';
const mine = all.filter((p) => (p.ownerPeerId || '') === ownerPeerId).length;
return sdk.router.json(res, {
total: all.length,
active: active.length,
expired,
consumed,
burnEnabled,
totalReads,
mine,
timestamp: t
});
}
function renderPastePage(paste) {
const title = paste.title || 'Untitled Paste';
const language = paste.language || 'plain';
const expiresIn = Math.max(0, paste.expiresAt - now());
const hours = Math.floor(expiresIn / (1000 * 60 * 60));
const minutes = Math.floor((expiresIn % (1000 * 60 * 60)) / (1000 * 60));
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>${title} - peer.paste</title>
<style>
body{margin:0;font-family:system-ui;background:#0f172a;color:#e2e8f0}
.wrap{max-width:960px;margin:2rem auto;padding:0 1rem}
.card{background:#111827;border:1px solid #334155;border-radius:12px;padding:1rem}
pre{white-space:pre-wrap;word-break:break-word;background:#020617;border:1px solid #1e293b;border-radius:8px;padding:1rem}
a{color:#60a5fa}
.meta{font-size:.9rem;color:#94a3b8;margin:.5rem 0 1rem}
</style>
</head>
<body>
<div class="wrap">
<h1>${title}</h1>
<div class="meta">Language: ${language} | Expires in: ${hours}h ${minutes}m</div>
<div class="card"><pre>${paste.content.replace(/[<>&]/g, (m) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[m]))}</pre></div>
<p><a href="/">Create another paste</a></p>
</div>
</body>
</html>`;
}
function apiDocsHtml(baseUrl) {
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>peer.paste API Docs</title>
<style>
body{font-family:system-ui;background:#0f172a;color:#e2e8f0;margin:0;padding:1.25rem}
.wrap{max-width:1100px;margin:0 auto}
.card{background:#111827;border:1px solid #334155;border-radius:12px;padding:1rem;margin-bottom:1rem}
.m{display:inline-block;padding:.2rem .5rem;border-radius:6px;font-weight:700;font-size:.8rem;margin-right:.5rem}
.GET{background:#059669}.POST{background:#2563eb}.PUT{background:#d97706}.DELETE{background:#dc2626}
code,pre{background:#020617;border:1px solid #1e293b;border-radius:8px}
pre{padding:.75rem;overflow:auto}
a{color:#60a5fa}
</style>
</head>
<body>
<div class="wrap">
<h1>peer.paste REST API</h1>
<p>Base URL: <code>${baseUrl}</code></p>
<div class="card">
<h2><span class="m GET">GET</span>/api/health</h2>
<p>Plugin health and DB status.</p>
</div>
<div class="card">
<h2><span class="m GET">GET</span>/api/stats</h2>
<p>Global paste statistics.</p>
</div>
<div class="card">
<h2><span class="m GET">GET</span>/api/pastes</h2>
<p>List active pastes. Query: <code>?search=term</code></p>
</div>
<div class="card">
<h2><span class="m GET">GET</span>/api/pastes/mine</h2>
<p>List active pastes owned by local peer.</p>
</div>
<div class="card">
<h2><span class="m POST">POST</span>/api/pastes</h2>
<p>Create a paste.</p>
<pre>{
"title": "optional",
"language": "plain|js|json|md",
"content": "required string",
"expiresHours": 24,
"destroyOnRead": false,
"maxReads": 0
}</pre>
</div>
<div class="card">
<h2><span class="m GET">GET</span>/api/pastes/:id</h2>
<p>Get metadata for a paste.</p>
</div>
<div class="card">
<h2><span class="m GET">GET</span>/api/pastes/:id/raw</h2>
<p>Consume/read paste content (increments read count).</p>
</div>
<div class="card">
<h2><span class="m PUT">PUT</span>/api/pastes/:id</h2>
<p>Update a paste (owner only, auth required).</p>
<pre>{
"title": "optional",
"language": "optional",
"content": "optional",
"expiresHours": 12,
"destroyOnRead": true,
"maxReads": 10
}</pre>
</div>
<div class="card">
<h2><span class="m DELETE">DELETE</span>/api/pastes/:id</h2>
<p>Delete a paste (owner only, auth required).</p>
</div>
<div class="card">
<h2><span class="m POST">POST</span>/api/admin/cleanup</h2>
<p>Trigger cleanup immediately (auth required).</p>
</div>
<div class="card">
<h2><span class="m GET">GET</span>/p/:id</h2>
<p>Human readable paste page (also consumes read count).</p>
</div>
<div class="card">
<h2><span class="m GET">GET</span>/api/docs</h2>
<p>This documentation page.</p>
</div>
<div class="card">
<h2><span class="m GET">GET</span>/api/openapi.json</h2>
<p>Machine-readable OpenAPI 3.0 schema.</p>
</div>
</div>
</body>
</html>`;
}
function buildOpenApi(baseUrl) {
return {
openapi: '3.0.3',
info: {
title: 'peer.paste API',
version: '1.0.0',
description: 'Temporary P2P text snippets with expiration and burn-after-read options.'
},
servers: [{ url: baseUrl }],
paths: {
'/api/health': {
get: { summary: 'Health check', responses: { 200: { description: 'OK' } } }
},
'/api/stats': {
get: { summary: 'Service stats', responses: { 200: { description: 'Stats response' } } }
},
'/api/pastes': {
get: {
summary: 'List active pastes',
parameters: [
{ name: 'search', in: 'query', schema: { type: 'string' }, required: false }
],
responses: { 200: { description: 'List of active pastes' } }
},
post: {
summary: 'Create paste',
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/CreatePasteRequest' }
}
}
},
responses: { 200: { description: 'Created' }, 400: { description: 'Invalid input' } }
}
},
'/api/pastes/mine': {
get: { summary: 'List local peer pastes', responses: { 200: { description: 'Owned active pastes' } } }
},
'/api/pastes/{id}': {
get: {
summary: 'Get paste metadata',
parameters: [{ $ref: '#/components/parameters/PasteId' }],
responses: { 200: { description: 'Metadata' }, 404: { description: 'Not found' } }
},
put: {
summary: 'Update paste (owner only)',
parameters: [{ $ref: '#/components/parameters/PasteId' }],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/UpdatePasteRequest' }
}
}
},
responses: { 200: { description: 'Updated' }, 403: { description: 'Forbidden' } }
},
delete: {
summary: 'Delete paste (owner only)',
parameters: [{ $ref: '#/components/parameters/PasteId' }],
responses: { 200: { description: 'Deleted' }, 403: { description: 'Forbidden' } }
}
},
'/api/pastes/{id}/raw': {
get: {
summary: 'Consume/read paste content',
parameters: [{ $ref: '#/components/parameters/PasteId' }],
responses: { 200: { description: 'Paste content' }, 404: { description: 'Not found or expired' } }
}
},
'/api/admin/cleanup': {
post: {
summary: 'Trigger cleanup (authenticated)',
responses: { 200: { description: 'Cleanup result' }, 401: { description: 'Unauthorized' } }
}
},
'/api/docs': {
get: { summary: 'Interactive docs page', responses: { 200: { description: 'HTML' } } }
},
'/api/openapi.json': {
get: { summary: 'OpenAPI document', responses: { 200: { description: 'OpenAPI JSON' } } }
},
'/p/{id}': {
get: {
summary: 'Human-readable paste page',
parameters: [{ $ref: '#/components/parameters/PasteId' }],
responses: { 200: { description: 'HTML page' }, 404: { description: 'Not found' } }
}
}
},
components: {
parameters: {
PasteId: {
name: 'id',
in: 'path',
required: true,
schema: { type: 'string' },
description: 'Paste identifier'
}
},
schemas: {
CreatePasteRequest: {
type: 'object',
required: ['content'],
properties: {
title: { type: 'string', maxLength: MAX_TITLE_CHARS },
language: { type: 'string', maxLength: MAX_LANGUAGE_CHARS },
content: { type: 'string', maxLength: MAX_CONTENT_CHARS },
expiresHours: { type: 'integer', minimum: 1, maximum: MAX_EXPIRES_HOURS },
destroyOnRead: { type: 'boolean' },
maxReads: { type: 'integer', minimum: 0, maximum: MAX_MAXREADS }
}
},
UpdatePasteRequest: {
type: 'object',
properties: {
title: { type: 'string', maxLength: MAX_TITLE_CHARS },
language: { type: 'string', maxLength: MAX_LANGUAGE_CHARS },
content: { type: 'string', maxLength: MAX_CONTENT_CHARS },
expiresHours: { type: 'integer', minimum: 1, maximum: MAX_EXPIRES_HOURS },
destroyOnRead: { type: 'boolean' },
maxReads: { type: 'integer', minimum: 0, maximum: MAX_MAXREADS }
}
}
}
}
};
}
async function handler(req, res) {
try {
const parsed = sdk.router.parseRequest(req);
const path = parsed.path;
const method = parsed.method;
req.query = parsed.query || {};
if (path === '' || path === '/') return false;
if (path === 'api/docs' && method === 'GET') {
const baseUrl = `https://${process.env.PLUGIN_DOMAIN || 'peer.paste'}`;
return sdk.router.html(res, apiDocsHtml(baseUrl), 200);
}
if (path === 'api/openapi.json' && method === 'GET') {
const baseUrl = `https://${process.env.PLUGIN_DOMAIN || 'peer.paste'}`;
return sdk.router.json(res, buildOpenApi(baseUrl));
}
if (path === 'api/health' && method === 'GET') {
return sdk.router.json(res, {
plugin: 'peer.paste',
ok: true,
dbReady: !sdk.db.closed,
timestamp: now()
});
}
if (path === 'api/stats' && method === 'GET') {
return getStats(res);
}
if (path === 'api/admin/cleanup' && method === 'POST') {
const localPeer = await sdk.auth.requireLocalPeer(req, res);
if (!localPeer) return true;
const result = await cleanupExpiredPastes();
return sdk.router.json(res, { success: true, ...result });
}
if (path === 'api/pastes' && method === 'POST') return createPaste(req, res);
if (path === 'api/pastes' && method === 'GET') return listPastes(req, res, false);
if (path === 'api/pastes/mine' && method === 'GET') return listPastes(req, res, true);
if (path.startsWith('api/pastes/') && path.endsWith('/raw') && method === 'GET') {
const pasteId = path.slice('api/pastes/'.length, -'/raw'.length);
return consumePaste(res, pasteId);
}
if (path.startsWith('api/pastes/') && method === 'GET') {
const pasteId = path.slice('api/pastes/'.length);
return getPasteMetadata(res, pasteId);
}
if (path.startsWith('api/pastes/') && method === 'PUT') {
const pasteId = path.slice('api/pastes/'.length);
return updatePaste(req, res, pasteId);
}
if (path.startsWith('api/pastes/') && method === 'DELETE') {
const pasteId = path.slice('api/pastes/'.length);
return deletePasteRoute(req, res, pasteId);
}
if (path.startsWith('p/') && method === 'GET') {
const pasteId = path.slice('p/'.length);
const paste = await sdk.db.get(COLLECTION, { id: pasteId });
if (!paste || !isConsumable(paste)) {
return sdk.router.notFound(res, 'Paste not found or expired');
}
const nextReadCount = (paste.readCount || 0) + 1;
const consumed = shouldDeleteAfterRead(paste, nextReadCount);
if (consumed) {
await deletePaste(paste);
} else {
await sdk.db.insert(COLLECTION, { ...paste, readCount: nextReadCount });
}
await sdk.db.flush();
return sdk.router.html(res, renderPastePage(paste), 200);
}
return false;
} catch (err) {
sdk.log.error('peer.paste', `Handler error: ${err.message}`);
return sdk.router.error(res, 'Internal Server Error', 500);
}
}
async function onInit() {
sdk.log.info('peer.paste', 'Initializing peer.paste...');
try {
await sdk.db.ready();
} catch (err) {
sdk.log.warn('peer.paste', `Database not ready at init: ${err.message}`);
}
await cleanupExpiredPastes();
cleanupTimer = setInterval(cleanupExpiredPastes, CLEANUP_INTERVAL_MS);
sdk.admin.registerSetting('defaultExpiryHours', {
type: 'number',
label: 'Default Expiry (Hours)',
description: 'Default paste expiration for UI-created pastes',
default: DEFAULT_EXPIRES_HOURS,
min: 1,
max: MAX_EXPIRES_HOURS
});
sdk.log.info('peer.paste', 'peer.paste initialized');
}
async function onShutdown() {
if (cleanupTimer) {
clearInterval(cleanupTimer);
cleanupTimer = null;
}
sdk.log.info('peer.paste', 'peer.paste shutdown complete');
}
module.exports = { handler, onInit, onShutdown };
+49
View File
@@ -0,0 +1,49 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>peer.paste</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<main class="wrap">
<h1>peer.paste</h1>
<p class="subtitle">Temporary P2P snippets with expiration and burn-after-read options.</p>
<section class="card">
<h2>Create Paste</h2>
<form id="create-form">
<label>Title (optional)
<input type="text" id="title" maxlength="120" />
</label>
<label>Language (optional)
<input type="text" id="language" maxlength="32" placeholder="plain / js / json / md" />
</label>
<label>Expires in hours
<input type="number" id="expiresHours" min="1" max="168" value="24" />
</label>
<label>Max reads (0 = unlimited until expiry)
<input type="number" id="maxReads" min="0" max="10000" value="0" />
</label>
<label class="row">
<input type="checkbox" id="destroyOnRead" />
Burn after first read
</label>
<label>Content
<textarea id="content" rows="14" required></textarea>
</label>
<button type="submit">Create Paste</button>
</form>
<div id="create-result" class="result"></div>
</section>
<section class="card">
<h2>Active Pastes</h2>
<button id="refresh-btn" type="button">Refresh</button>
<div id="list"></div>
</section>
</main>
<script src="/main.js"></script>
</body>
</html>
+75
View File
@@ -0,0 +1,75 @@
async function api(path, options) {
const res = await fetch(path, options);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
return data;
}
function escapeHtml(s) {
return String(s).replace(/[<>&"]/g, (m) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[m]));
}
function msToHuman(ms) {
if (ms <= 0) return 'expired';
const h = Math.floor(ms / (1000 * 60 * 60));
const m = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
return `${h}h ${m}m`;
}
async function refreshList() {
const list = document.getElementById('list');
list.textContent = 'Loading...';
try {
const data = await api('/api/pastes');
if (!data.pastes || data.pastes.length === 0) {
list.textContent = 'No active pastes.';
return;
}
list.innerHTML = data.pastes.map((p) => `
<div class="paste-item">
<div><strong>${escapeHtml(p.title || '(untitled)')}</strong></div>
<div class="meta">
id: ${p.id} |
reads: ${p.readCount}${p.maxReads ? `/${p.maxReads}` : ''} |
expires: ${msToHuman(p.expiresInMs)}
</div>
<div>
<a href="/p/${p.id}" target="_blank" rel="noopener">Open</a>
&nbsp;|&nbsp;
<a href="/api/pastes/${p.id}/raw" target="_blank" rel="noopener">Raw JSON</a>
</div>
</div>
`).join('');
} catch (err) {
list.textContent = err.message;
}
}
document.getElementById('create-form').addEventListener('submit', async (e) => {
e.preventDefault();
const result = document.getElementById('create-result');
result.textContent = 'Creating...';
try {
const payload = {
title: document.getElementById('title').value,
language: document.getElementById('language').value,
expiresHours: Number(document.getElementById('expiresHours').value),
maxReads: Number(document.getElementById('maxReads').value),
destroyOnRead: document.getElementById('destroyOnRead').checked,
content: document.getElementById('content').value
};
const data = await api('/api/pastes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
result.innerHTML = `Created: <a href="${data.viewUrl}" target="_blank" rel="noopener">${data.viewUrl}</a>`;
document.getElementById('content').value = '';
await refreshList();
} catch (err) {
result.textContent = err.message;
}
});
document.getElementById('refresh-btn').addEventListener('click', refreshList);
refreshList();
+86
View File
@@ -0,0 +1,86 @@
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #0f172a;
color: #e2e8f0;
}
.wrap {
max-width: 1000px;
margin: 1.5rem auto;
padding: 0 1rem;
}
h1, h2 {
margin: 0 0 0.75rem;
}
.subtitle {
color: #94a3b8;
margin: 0 0 1.25rem;
}
.card {
background: #111827;
border: 1px solid #334155;
border-radius: 12px;
padding: 1rem;
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.75rem;
font-size: 0.95rem;
}
input[type="text"],
input[type="number"],
textarea {
width: 100%;
margin-top: 0.35rem;
padding: 0.6rem;
border-radius: 8px;
border: 1px solid #475569;
background: #020617;
color: #e2e8f0;
}
.row {
display: flex;
gap: 0.5rem;
align-items: center;
}
button {
background: #2563eb;
color: white;
border: 0;
border-radius: 8px;
padding: 0.55rem 0.9rem;
cursor: pointer;
}
button:hover {
background: #1d4ed8;
}
.result {
margin-top: 0.75rem;
color: #93c5fd;
word-break: break-all;
}
.paste-item {
border-top: 1px solid #1e293b;
padding: 0.75rem 0;
}
.meta {
color: #94a3b8;
font-size: 0.85rem;
}
a {
color: #60a5fa;
}