From 8e777431ba0740989eb2c80d327608e92abeb478 Mon Sep 17 00:00:00 2001 From: Pelican Installer Bot Date: Sun, 17 May 2026 13:36:02 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Forge=20Paste=20=E2=80=94=20lightweight?= =?UTF-8?q?=20self-hosted=20paste=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Zero dependencies (Python 3 stdlib + SQLite) - Hastebin-compatible API - Clean dark UI matching Forge Panel - systemd service included - ~330 lines, single file --- README.md | 72 ++++++++++ forge-paste.py | 327 ++++++++++++++++++++++++++++++++++++++++++++ forge-paste.service | 15 ++ 3 files changed, 414 insertions(+) create mode 100644 README.md create mode 100644 forge-paste.py create mode 100644 forge-paste.service diff --git a/README.md b/README.md new file mode 100644 index 0000000..71743cb --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# ๐Ÿ”ฅ Forge Paste + +A lightweight, self-hosted paste server with a clean UI. Hastebin-compatible API. + +**Zero dependencies** โ€” runs on Python 3.10+ with the standard library only. Uses SQLite for storage. + +## Quick Install + +```bash +# Clone +git clone https://github.com/everestmcarthur/forge-paste.git /opt/forge-paste + +# Create data directory +sudo mkdir -p /var/lib/forge-paste +sudo chown www-data:www-data /var/lib/forge-paste + +# Install systemd service +sudo cp /opt/forge-paste/forge-paste.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now forge-paste +``` + +## API + +```bash +# Create a paste +curl -X POST -d "your text here" https://logs.yourdomain.com/api/paste +# โ†’ {"key": "abc123", "url": "https://logs.yourdomain.com/abc123"} + +# Hastebin-compatible +curl -X POST -d "your text" https://logs.yourdomain.com/api/documents +# โ†’ {"key": "abc123"} + +# Get raw text +curl https://logs.yourdomain.com/api/raw/abc123 + +# Get JSON +curl https://logs.yourdomain.com/api/documents/abc123 +# โ†’ {"key": "abc123", "data": "your text"} +``` + +## Wings/Spark Integration + +Upload diagnostics directly: +```bash +sudo spark diagnostics --hastebin-url=https://logs.yourdomain.com +``` + +Upload logs via curl: +```bash +tail -n 300 /var/www/forge/storage/logs/laravel-$(date +%F).log | curl -X POST --data-binary @- https://logs.yourdomain.com/api/paste +``` + +## Caddy Reverse Proxy + +``` +logs.yourdomain.com { + reverse_proxy 127.0.0.1:7890 +} +``` + +## Configuration + +| Env / Flag | Default | Description | +|---|---|---| +| `FORGE_PASTE_PORT` / `--port` | 7890 | Listen port | +| `FORGE_PASTE_HOST` / `--host` | 127.0.0.1 | Listen address | +| `FORGE_PASTE_DB` / `--db` | /var/lib/forge-paste/pastes.db | SQLite database path | + +## License + +MIT diff --git a/forge-paste.py b/forge-paste.py new file mode 100644 index 0000000..4d65c4f --- /dev/null +++ b/forge-paste.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +""" +Forge Paste โ€” A lightweight, self-hosted paste server. +Hastebin-compatible API with a clean web UI. + +Usage: + python3 forge-paste.py [--port PORT] [--host HOST] [--db PATH] + +API: + POST /api/paste โ€” raw text body โ†’ {"key": "abc123", "url": "..."} + POST /api/documents โ€” hastebin-compat โ†’ {"key": "abc123"} + GET /api/raw/ โ€” raw text output + GET /api/documents/ โ€” {"key": "...", "data": "..."} + GET / โ€” web viewer + GET / โ€” web editor +""" + +import sqlite3 +import string +import random +import os +import json +import argparse +import time +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse +from datetime import datetime +import html + +DB_PATH = os.environ.get("FORGE_PASTE_DB", "/var/lib/forge-paste/pastes.db") +HOST = os.environ.get("FORGE_PASTE_HOST", "127.0.0.1") +PORT = int(os.environ.get("FORGE_PASTE_PORT", "7890")) +MAX_SIZE = 512 * 1024 # 512KB + +def get_db(): + """Get a thread-local database connection.""" + db = sqlite3.connect(DB_PATH) + db.row_factory = sqlite3.Row + db.execute("PRAGMA journal_mode=WAL") + db.execute("PRAGMA foreign_keys=ON") + return db + +def init_db(): + """Initialize the database schema.""" + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + db = get_db() + db.execute(""" + CREATE TABLE IF NOT EXISTS pastes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT UNIQUE NOT NULL, + content TEXT NOT NULL, + language TEXT, + created_at REAL NOT NULL DEFAULT (unixepoch()), + views INTEGER NOT NULL DEFAULT 0, + ip TEXT + ) + """) + db.execute("CREATE INDEX IF NOT EXISTS idx_slug ON pastes(slug)") + db.execute("CREATE INDEX IF NOT EXISTS idx_created ON pastes(created_at)") + db.commit() + db.close() + +def generate_slug(length=8): + chars = string.ascii_lowercase + string.digits + return ''.join(random.choices(chars, k=length)) + +def create_paste(content, language=None, ip=None): + db = get_db() + slug = generate_slug() + # Ensure unique + while db.execute("SELECT 1 FROM pastes WHERE slug=?", (slug,)).fetchone(): + slug = generate_slug() + db.execute( + "INSERT INTO pastes (slug, content, language, created_at, views, ip) VALUES (?, ?, ?, ?, 0, ?)", + (slug, content, language, time.time(), ip) + ) + db.commit() + db.close() + return slug + +def get_paste(slug): + db = get_db() + row = db.execute("SELECT * FROM pastes WHERE slug=?", (slug,)).fetchone() + if row: + db.execute("UPDATE pastes SET views = views + 1 WHERE slug=?", (slug,)) + db.commit() + db.close() + return dict(row) if row else None + +def get_stats(): + db = get_db() + row = db.execute("SELECT COUNT(*) as total FROM pastes").fetchone() + db.close() + return row["total"] + +def time_ago(ts): + seconds = int(time.time() - ts) + if seconds < 60: return "just now" + if seconds < 3600: return f"{seconds // 60}m ago" + if seconds < 86400: return f"{seconds // 3600}h ago" + return f"{seconds // 86400}d ago" + +# โ”€โ”€ HTML Templates โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +STYLE = """ + +""" + +ICON_SVG = '' + +def page_editor(host): + total = get_stats() + return f""" + +Forge Paste{STYLE} +
{ICON_SVG}

Forge Paste

+{total} paste{'s' if total != 1 else ''}
+
Ctrl + Enter to save
+
+ +
+
+ +""" + +def page_viewer(paste, host, is_new=False): + lines = paste["content"].split("\n") + gw = len(str(len(lines))) + line_html = "" + for i, line in enumerate(lines, 1): + line_html += f'
{i}{html.escape(line)}
' + + ta = time_ago(paste["created_at"]) + v = paste["views"] + slug = paste["slug"] + url = f"{host}/{slug}" + + if is_new: + return f""" + +{slug} โ€” Forge Paste{STYLE} + +
โœ“ Paste created!
+ +
""" + + return f""" + +{slug} โ€” Forge Paste{STYLE} +
+
{ta} ยท {v} view{'s' if v != 1 else ''} + +Raw +New
+
{line_html}
""" + +def page_notfound(): + return f""" + +Not Found โ€” Forge Paste{STYLE} + +
๐Ÿ”

Paste not found

+

This paste may have expired or never existed.

+Create New Paste
""" + + +class PasteHandler(BaseHTTPRequestHandler): + def log_message(self, format, *args): + # Quiet logging + pass + + def _send(self, code, content, content_type="text/html"): + self.send_response(code) + self.send_header("Content-Type", content_type) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + if isinstance(content, str): + content = content.encode("utf-8") + self.wfile.write(content) + + def _host(self): + proto = self.headers.get("X-Forwarded-Proto", "http") + host = self.headers.get("X-Forwarded-Host") or self.headers.get("Host", "localhost") + return f"{proto}://{host}" + + def do_OPTIONS(self): + self._send(204, "") + + def do_GET(self): + parsed = urlparse(self.path) + path = parsed.path.rstrip("/") + query = parsed.query + host = self._host() + + if path == "" or path == "/": + return self._send(200, page_editor(host)) + + # API routes + if path.startswith("/api/raw/"): + slug = path[9:] + paste = get_paste(slug) + if not paste: + return self._send(404, "Not found", "text/plain") + return self._send(200, paste["content"], "text/plain; charset=utf-8") + + if path.startswith("/api/documents/"): + slug = path[15:] + paste = get_paste(slug) + if not paste: + return self._send(404, json.dumps({"error": "not found"}), "application/json") + return self._send(200, json.dumps({"key": paste["slug"], "data": paste["content"]}), "application/json") + + # Web viewer + slug = path[1:] # Remove leading / + if slug and all(c in string.ascii_lowercase + string.digits for c in slug): + paste = get_paste(slug) + if paste: + is_new = "new=1" in query + return self._send(200, page_viewer(paste, host, is_new)) + return self._send(404, page_notfound()) + + self._send(404, page_notfound()) + + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + if length > MAX_SIZE: + return self._send(413, json.dumps({"error": "Content too large (max 512KB)"}), "application/json") + + body = self.rfile.read(length).decode("utf-8", errors="replace") + + if not body.strip(): + return self._send(400, json.dumps({"error": "Empty content"}), "application/json") + + parsed = urlparse(self.path) + path = parsed.path.rstrip("/") + host = self._host() + ip = self.headers.get("X-Real-IP") or self.client_address[0] + + slug = create_paste(body, ip=ip) + + if path == "/api/documents": + # Hastebin-compatible response + return self._send(200, json.dumps({"key": slug}), "application/json") + + # Default API response + self._send(200, json.dumps({"key": slug, "url": f"{host}/{slug}"}), "application/json") + + +def main(): + parser = argparse.ArgumentParser(description="Forge Paste Server") + parser.add_argument("--port", type=int, default=PORT, help=f"Port (default: {PORT})") + parser.add_argument("--host", default=HOST, help=f"Host (default: {HOST})") + parser.add_argument("--db", default=DB_PATH, help=f"Database path (default: {DB_PATH})") + args = parser.parse_args() + + global DB_PATH, HOST, PORT + DB_PATH = args.db + HOST = args.host + PORT = args.port + + init_db() + + server = HTTPServer((HOST, PORT), PasteHandler) + print(f"๐Ÿ”ฅ Forge Paste running on http://{HOST}:{PORT}") + print(f"๐Ÿ“ฆ Database: {DB_PATH}") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nShutting down...") + server.server_close() + +if __name__ == "__main__": + main() diff --git a/forge-paste.service b/forge-paste.service new file mode 100644 index 0000000..c45f816 --- /dev/null +++ b/forge-paste.service @@ -0,0 +1,15 @@ +[Unit] +Description=Forge Paste Server +After=network.target + +[Service] +Type=simple +User=www-data +Group=www-data +ExecStart=/usr/bin/python3 /opt/forge-paste/forge-paste.py --port 7890 --host 127.0.0.1 +Restart=always +RestartSec=5 +Environment=FORGE_PASTE_DB=/var/lib/forge-paste/pastes.db + +[Install] +WantedBy=multi-user.target