forked from snxraven/p2ns
Introduce peer.paste
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) => ({ '<': '<', '>': '>', '&': '&' }[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 };
|
||||||
@@ -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>
|
||||||
@@ -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) => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[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>
|
||||||
|
|
|
||||||
|
<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();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user