Skip to content

Instantly share code, notes, and snippets.

@sebadima
Created March 3, 2026 07:05
Show Gist options
  • Select an option

  • Save sebadima/7e1cf309f372ddc73d02e369cc7542db to your computer and use it in GitHub Desktop.

Select an option

Save sebadima/7e1cf309f372ddc73d02e369cc7542db to your computer and use it in GitHub Desktop.
JustPaste Clone - Sistema FastAPI + PDF Worker (Full Package)

JustPaste Clone

Sistema minimale di pasteboard basato su FastAPI con worker asincrono per la generazione di PDF tramite Chromium headless. Progettato per girare su Ubuntu LTS senza dipendenze gonfie.

1. Requisiti di Sistema (Ubuntu)

Oltre a Python 3, è necessario Chromium per la conversione HTML -> PDF:

sudo apt update && sudo apt install chromium-browser -y
import os
import re
import json
import shutil
import subprocess
import threading
import queue
import logging
import signal
import sys
from datetime import datetime
from pathlib import Path
from typing import Tuple
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse
import uvicorn
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO"),
format="%(asctime)s | %(levelname)s | %(message)s",
)
logger = logging.getLogger("justpaste")
HOST = os.getenv("JP_HOST", "127.0.0.1")
PORT = int(os.getenv("JP_PORT", "5000"))
MAX_BODY_SIZE = int(os.getenv("JP_MAX_BODY", "5242880")) # 5MB default
PDF_TIMEOUT = int(os.getenv("JP_PDF_TIMEOUT", "60"))
BASE_DIR = Path(__file__).parent.resolve()
STYLE_FILE = BASE_DIR / "style.css"
STORAGE = Path(os.getenv("JP_STORAGE", Path.home() / "justpaste")).resolve()
AUTOSAVE_FILE = STORAGE / ".autosave.json"
# --------------------------------------------------
# BOOT CLEANUP & STORAGE PERSISTENCE
# --------------------------------------------------
def initialize_storage():
try:
STORAGE.mkdir(parents=True, exist_ok=True)
# Rimuove le "porcherie" (file tmp orfani) all'avvio
for junk in STORAGE.glob("tmp*"):
try:
if junk.is_file():
junk.unlink(missing_ok=True)
logger.info(f"CLEANED_ORPHAN: {junk.name}")
except Exception as e:
logger.warning(f"FAILED_CLEANUP {junk.name}: {e}")
except Exception as e:
logger.critical(f"STORAGE_INIT_ERROR: {e}")
sys.exit(1)
initialize_storage()
# --------------------------------------------------
# JOB QUEUE (deduplicated)
# --------------------------------------------------
job_q = queue.Queue()
queued_jobs = set()
queue_lock = threading.Lock()
# --------------------------------------------------
# UTILITIES
# --------------------------------------------------
def safe_filename(name: str) -> str:
try:
name = (name or "untitled").strip()
# Rimuove caratteri pericolosi e sequenze di escape
name = re.sub(r"[^a-zA-Z0-9._-]+", "_", name)
# Previene nomi nascosti o risalita directory
name = name.lstrip(".")
if not name or name.lower() in ("not_found", "con", "prn", "aux", "nul"):
name = f"file_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
return name[:120] # hard limit
except Exception:
return f"emergency_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
def find_chrome():
candidates = ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser")
for cmd in candidates:
if p := shutil.which(cmd):
return p
return None
def html_to_pdf(html_path: Path, pdf_path: Path) -> Tuple[bool, str]:
chrome = find_chrome()
if not chrome:
return False, "CHROME_NOT_FOUND"
args = [
chrome,
"--headless",
"--no-sandbox",
"--disable-gpu",
"--font-render-hinting=none",
"--print-to-pdf-no-header",
f"--print-to-pdf={pdf_path}",
f"file://{html_path.absolute()}",
]
try:
subprocess.run(
args,
timeout=PDF_TIMEOUT,
capture_output=True,
check=True,
)
if pdf_path.is_file() and pdf_path.stat().st_size > 100:
return True, "OK"
return False, "EMPTY_OUTPUT"
except subprocess.TimeoutExpired:
return False, "TIMEOUT"
except subprocess.CalledProcessError as e:
logger.error(f"PDF_SUBPROCESS_ERR: {e.stderr}")
return False, "SUBPROCESS_ERROR"
except Exception as e:
logger.error(f"PDF_SUBPROCESS_ERR: {e}")
return False, str(e)
# --------------------------------------------------
# WORKER
# --------------------------------------------------
def worker():
logger.info("PDF_WORKER_READY")
while True:
name = job_q.get()
if name is None:
break
try:
html = (STORAGE / f"{name}.html").resolve()
pdf = (STORAGE / f"{name}.pdf").resolve()
# Controllo di sicurezza: il file deve essere dentro STORAGE
if STORAGE not in html.parents:
logger.error(f"SECURITY_VIOLATION: Attempted access to {html}")
continue
if not html.exists():
continue
ok, msg = html_to_pdf(html, pdf)
logger.info(f"PDF_GEN {name}: {'SUCCESS' if ok else 'FAILED'} | {msg}")
except Exception as e:
logger.error(f"WORKER_ERROR: {e}")
finally:
with queue_lock:
queued_jobs.discard(name)
job_q.task_done()
worker_thread = threading.Thread(target=worker, daemon=True)
worker_thread.start()
# --------------------------------------------------
# FASTAPI
# --------------------------------------------------
app = FastAPI()
@app.get("/style.css")
def style():
if STYLE_FILE.is_file():
return FileResponse(STYLE_FILE)
return HTMLResponse(content="", status_code=404)
@app.get("/favicon.svg")
def favicon():
return RedirectResponse("https://www.robotdazero.it/favicon.svg")
@app.get("/nextname")
def nextname():
return f"file_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
@app.get("/", response_class=HTMLResponse)
def index():
autosave = {"filename": "", "html": ""}
if AUTOSAVE_FILE.is_file():
try:
autosave = json.loads(AUTOSAVE_FILE.read_text(encoding="utf-8"))
except Exception:
logger.warning("AUTOSAVE_CORRUPTED")
content = HTML_PAGE.replace("{{AUTOSAVE_FILENAME}}", str(autosave.get("filename", "")))
content = content.replace("{{AUTOSAVE_HTML}}", str(autosave.get("html", "")))
return content
@app.post("/save")
async def save(request: Request):
try:
name = safe_filename(request.query_params.get("name", "untitled"))
body = await request.body()
if not body:
return JSONResponse(status_code=400, content={"ok": False, "err": "EMPTY_BODY"})
if len(body) > MAX_BODY_SIZE:
return JSONResponse(status_code=413, content={"ok": False, "err": "PAYLOAD_TOO_LARGE"})
html_path = (STORAGE / f"{name}.html").resolve()
if STORAGE not in html_path.parents:
raise HTTPException(status_code=400, detail="INVALID_PATH")
# atomic write
tmp_path = STORAGE / f"tmp_{name}_{os.getpid()}"
tmp_path.write_bytes(body)
tmp_path.replace(html_path)
# Accoda il job per il PDF (se non è già in coda lo stesso nome)
with queue_lock:
if name not in queued_jobs:
queued_jobs.add(name)
job_q.put(name)
logger.info(f"SAVED: {name}.html")
return {"ok": True, "file": str(html_path), "name": name}
except HTTPException:
raise
except Exception as e:
logger.error(f"SAVE_ERR: {e}")
return JSONResponse(status_code=500, content={"ok": False, "err": "INTERNAL_ERROR"})
@app.post("/autosave")
async def autosave(request: Request):
try:
data = await request.json()
tmp = STORAGE / ".autosave.tmp"
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
tmp.replace(AUTOSAVE_FILE)
return {"ok": True}
except Exception:
return JSONResponse(status_code=500, content={"ok": False})
HTML_PAGE = """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/style.css">
<title>JustPaste</title>
</head>
<body>
<div class="sticky-header">
<div class="header">
NAME:
<input type="text" id="filename" value="{{AUTOSAVE_FILENAME}}" spellcheck="false" autocomplete="off">
<div id="status">READY</div>
</div>
<div class="bar">
<button onclick="saveMaster()">[ SAVE MASTER ]</button>
<button onclick="newDoc()">[ NEW ]</button>
</div>
</div>
<div id="editor" contenteditable="true" spellcheck="false">{{AUTOSAVE_HTML}}</div>
<script>
async function ensureName(){
const i = document.getElementById('filename');
if (i.value.trim()) return;
try {
const r = await fetch('/nextname');
i.value = (await r.text()).trim();
} catch(e) { console.error("Name fetch failed"); }
}
async function saveMaster(){
await ensureName();
let name = document.getElementById('filename').value.trim() || 'untitled';
const status = document.getElementById('status');
status.innerText = "SAVING...";
const content = document.getElementById('editor').innerHTML;
try {
const r = await fetch('/save?name=' + encodeURIComponent(name), {
method: 'POST',
body: content
});
const res = await r.json();
if(res.ok) {
status.innerText = "SAVED";
setTimeout(() => { if(status.innerText === "SAVED") status.innerText = "READY"; }, 3000);
} else {
status.innerText = "ERROR";
}
} catch(e) {
status.innerText = "NET_ERROR";
}
}
async function autosave(){
const payload = {
filename: document.getElementById('filename').value.trim(),
html: document.getElementById('editor').innerHTML
};
try {
await fetch('/autosave', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
} catch(e) {}
}
function newDoc(){
if (!confirm("Cancellare tutto?")) return;
document.getElementById('editor').innerHTML = '';
document.getElementById('filename').value = '';
ensureName();
}
document.getElementById('editor').addEventListener('paste', function(e) {
e.preventDefault();
const items = e.clipboardData.items;
let handledImage = false;
for (let item of items) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
handledImage = true;
const blob = item.getAsFile();
const reader = new FileReader();
reader.onload = function(event) {
const img = document.createElement('img');
img.src = event.target.result;
img.style.maxWidth = '100%';
img.style.display = 'block';
img.style.margin = '10px 0';
document.getElementById('editor').appendChild(img);
};
reader.readAsDataURL(blob);
}
}
if (!handledImage) {
const text = e.clipboardData.getData('text/plain') || '';
document.execCommand('insertText', false, text);
}
});
window.onload = () => {
ensureName();
setInterval(autosave, 60000);
};
</script>
</body>
</html>
"""
# --------------------------------------------------
# SHUTDOWN
# --------------------------------------------------
def signal_handler(sig, frame):
logger.info("SHUTTING_DOWN...")
job_q.put(None)
worker_thread.join(timeout=5)
sys.exit(0)
if __name__ == "__main__":
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
if not find_chrome():
logger.warning("PDF_DISABLED: Chrome/Chromium non trovato.")
uvicorn.run(
app,
host=HOST,
port=PORT,
log_level="warning",
proxy_headers=True,
)
# MyPaste RAW - Control Logic
PYTHON = python3
PID_FILE = mypaste.pid
PORT = 5000
STORAGE = $(HOME)/justpaste
.PHONY: run stop status list
# Lancia il server. Nota: in foreground il PID_FILE non serve per il kill,
# ma lo stop pulisce comunque il socket della porta PORT.
run: stop
@mkdir -p $(STORAGE)
@echo "[STARTING] MyPaste RAW Server on port $(PORT)..."
$(PYTHON) main.py
# Forza la liberazione della porta e pulisce file residui
stop:
@echo "[CLEANING] Killing processes on port $(PORT)..."
-@fuser -k $(PORT)/tcp 2>/dev/null || true
-@if [ -f $(PID_FILE) ]; then kill -9 $$(cat $(PID_FILE)) 2>/dev/null || true; fi
-@rm -f $(PID_FILE)
# Elenca i paste. Se la cartella è vuota non crasha.
list:
@echo "[STORAGE] Current saved pastes in $(STORAGE):"
@ls -lh $(STORAGE) 2>/dev/null || echo "Empty or missing directory."
# Stato del socket (L4)
status:
@lsof -i :$(PORT) || echo "Port $(PORT) is FREE"
fastapi==0.110.0
uvicorn==0.27.1
python-multipart==0.0.9
body, html {
margin: 0;
padding: 0;
background: #121212;
color: #0f0;
font-family: monospace;
font-size: 17px; /* base leggermente più grande */
}
.sticky-header {
position: sticky;
top: 0;
z-index: 1000;
background: #1a1a1a;
border-bottom: 2px solid #333;
}
.header {
display: flex;
height: 50px; /* un filo più alta per comodità */
align-items: center;
padding: 8px 12px;
gap: 20px;
}
#filename {
background: #000;
color: #0f0;
border: 1px solid #444;
padding: 6px 10px;
flex-grow: 1;
font-size: 18px !important; /* più leggibile */
}
#status {
min-width: 200px;
font-size: 15px;
text-align: right;
font-weight: bold;
}
#editor {
width: 100%;
min-height: 80vh;
padding: 24px;
outline: none;
box-sizing: border-box;
font-size: 22px !important; /* qui lo ingrandiamo davvero */
line-height: 1.55;
font-family: 'Courier New', Courier, monospace !important;
}
#editor * {
font-family: 'Courier New', Courier, monospace !important;
}
#editor::selection,
#editor *::selection {
background: #0f0;
color: #000;
}
.bar {
display: flex;
height: 40px;
background: #222;
align-items: center;
}
button {
flex: 1;
background: none;
color: #0f0;
border: 1px solid #333;
font-weight: bold;
font-size: 16px !important;
padding: 6px 0;
cursor: pointer;
text-transform: uppercase;
}
button:hover {
background: #0f0;
color: #000;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment