aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/.gitignore5
-rw-r--r--src/data/.db-gets-made-here1
-rw-r--r--src/index.html1
-rw-r--r--src/server.py845
4 files changed, 852 insertions, 0 deletions
diff --git a/src/.gitignore b/src/.gitignore
new file mode 100644
index 0000000..afa7079
--- /dev/null
+++ b/src/.gitignore
@@ -0,0 +1,5 @@
+data/kj-clipboard.db
+__pycache__/
+*.pyc
+.venv/
+venv/ \ No newline at end of file
diff --git a/src/data/.db-gets-made-here b/src/data/.db-gets-made-here
new file mode 100644
index 0000000..47a6fda
--- /dev/null
+++ b/src/data/.db-gets-made-here
@@ -0,0 +1 @@
+fr fr fs fs 67 67 \ No newline at end of file
diff --git a/src/index.html b/src/index.html
new file mode 100644
index 0000000..658d834
--- /dev/null
+++ b/src/index.html
@@ -0,0 +1 @@
+<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="color-scheme" content="light dark"><meta http-equiv="cache-control" content="no-cache"><meta http-equiv="expires" content="0"><meta http-equiv="pragma" content="no-cache"><title>kj-clipboard</title><link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/kj-sh604/noir.css@latest/out/noir.min.css"><style>textarea{ font-family: monospace; font-size: 0.95rem; padding: 0.75rem; resize: vertical; box-sizing: border-box; tab-size: 4;} textarea:not([rows]){ width: 100%; min-height: 30rem;} .controls{ display: flex; gap: 0.5rem; margin-top: 0.75rem; flex-wrap: wrap; align-items: center;} button:disabled{ opacity: 0.5; cursor: not-allowed;} input[type="password"]{ font-family: inherit; font-size: 0.9rem;} input[type="password"]:focus{ outline: none;} label{ font-size: 0.9rem; cursor: pointer; display: flex; align-items: center; gap: 0.3rem;} .result{ margin-top: 1rem; padding: 0.75rem; border: 1px solid #444; border-radius: 4px; display: none;} .result a{ word-break: break-all;} .spacer{ flex-grow: 1;} #lang-select{ display: none;} .status{ font-size: 0.85rem; margin-top: 0.5rem;} </style></head><body><h1>kj-clipboard</h1><p>no frills, just a public clipboard on the internet that you can use to share snippets around... that's it.</p><textarea id="content" placeholder="paste or type something here..." autofocus spellcheck="false"></textarea><div class="controls"><button id="get-link-btn" onclick="createPaste()" title="generate snippet link">generate link</button><span class="spacer"></span><label><input type="checkbox" id="is-code" onchange="toggleLang()">is this code? </label><select id="lang-select"><option value="">auto-detect</option><option value="1c">.bsl</option><option value="abnf">.abnf</option><option value="accesslog">access log</option><option value="ada">.adb/.ads</option><option value="arduino">.ino</option><option value="armasm">.s/.S</option><option value="asciidoc">.adoc</option><option value="aspectj">.aj</option><option value="autohotkey">.ahk</option><option value="autoit">.au3</option><option value="avrasm">.s</option><option value="awk">.awk</option><option value="bash">.sh</option><option value="basic">.bas</option><option value="bnf">.bnf</option><option value="brainfuck">.bf</option><option value="c">.c/.h</option><option value="cal">cal</option><option value="capnproto">.capnp</option><option value="ceylon">.ceylon</option><option value="clean">.icl/.dcl</option><option value="clojure">.clj</option><option value="clojure-repl">clojure repl</option><option value="cmake">.cmake</option><option value="coffeescript">.coffee</option><option value="coq">.v</option><option value="cos">.mac</option><option value="cpp">.cpp/.hpp</option><option value="crmsh">crmsh</option><option value="crystal">.cr</option><option value="csharp">.cs</option><option value="csp">csp</option><option value="css">.css</option><option value="d">.d</option><option value="dart">.dart</option><option value="delphi">.pas</option><option value="diff">.patch</option><option value="django">django</option><option value="dns">.zone</option><option value="dockerfile">dockerfile</option><option value="dos">.bat/.cmd</option><option value="dsconfig">dsconfig</option><option value="dts">.dts</option><option value="dust">dust</option><option value="ebnf">.ebnf</option><option value="elixir">.ex/.exs</option><option value="elm">.elm</option><option value="erb">.erb</option><option value="erlang">.erl</option><option value="excel">excel formula</option><option value="fix">fix</option><option value="flix">.flix</option><option value="fortran">.f90</option><option value="fsharp">.fs</option><option value="gams">.gms</option><option value="gauss">.gss</option><option value="gcode">.gcode</option><option value="gherkin">.feature</option><option value="glsl">.glsl</option><option value="gml">gml</option><option value="go">.go</option><option value="golo">.golo</option><option value="gradle">.gradle</option><option value="graphql">.graphql</option><option value="groovy">.groovy</option><option value="haml">.haml</option><option value="handlebars">.hbs</option><option value="haskell">.hs</option><option value="haxe">.hx</option><option value="hsp">hsp</option><option value="http">.http</option><option value="hy">.hy</option><option value="inform7">.ni</option><option value="ini">.ini</option><option value="irpf90">irpf90</option><option value="isbl">isbl</option><option value="java">.java</option><option value="javascript">.js</option><option value="jboss-cli">jboss cli</option><option value="json">.json</option><option value="julia">.jl</option><option value="kotlin">.kt</option><option value="lasso">.lasso</option><option value="latex">.tex</option><option value="ldif">.ldif</option><option value="leaf">leaf</option><option value="less">.less</option><option value="lisp">.lisp</option><option value="livecodeserver">livecodeserver</option><option value="livescript">.ls</option><option value="llvm">.ll</option><option value="lsl">lsl</option><option value="lua">.lua</option><option value="makefile">makefile</option><option value="markdown">.md</option><option value="mathematica">.nb</option><option value="matlab">.m</option><option value="maxima">.mac</option><option value="mel">.mel</option><option value="mercury">.m</option><option value="mipsasm">.s</option><option value="mizar">.miz</option><option value="mojolicious">mojolicious</option><option value="monkey">monkey</option><option value="moonscript">.moon</option><option value="n1ql">n1ql</option><option value="nestedtext">.nt</option><option value="nginx">.conf</option><option value="nim">.nim</option><option value="nix">.nix</option><option value="node-repl">node repl</option><option value="nsis">.nsi</option><option value="objectivec">.m/.mm</option><option value="ocaml">.ml/.mli</option><option value="openscad">.scad</option><option value="oxygene">.pas</option><option value="parser3">parser3</option><option value="perl">.pl</option><option value="pf">pf</option><option value="pgsql">.sql</option><option value="php">.php</option><option value="php-template">.phtml</option><option value="plaintext">.txt</option><option value="pony">.pony</option><option value="powershell">.ps1</option><option value="processing">.pde</option><option value="profile">profile</option><option value="prolog">.pl</option><option value="properties">.properties</option><option value="protobuf">.proto</option><option value="puppet">.pp</option><option value="purebasic">.pb</option><option value="python">.py</option><option value="python-repl">python repl</option><option value="q">.q</option><option value="qml">.qml</option><option value="r">.r</option><option value="reasonml">.re</option><option value="rib">rib</option><option value="roboconf">roboconf</option><option value="routeros">routeros</option><option value="rsl">rsl</option><option value="ruby">.rb</option><option value="ruleslanguage">rules language</option><option value="rust">.rs</option><option value="sas">.sas</option><option value="scala">.scala</option><option value="scheme">.scm</option><option value="scilab">.sci</option><option value="scss">.scss</option><option value="shell">.sh</option><option value="smali">.smali</option><option value="smalltalk">.st</option><option value="sml">.sml</option><option value="sqf">.sqf</option><option value="sql">.sql</option><option value="stan">.stan</option><option value="stata">.do</option><option value="step21">.p21</option><option value="stylus">.styl</option><option value="subunit">subunit</option><option value="swift">.swift</option><option value="taggerscript">taggerscript</option><option value="tap">tap</option><option value="tcl">.tcl</option><option value="thrift">.thrift</option><option value="tp">tp</option><option value="twig">.twig</option><option value="typescript">.ts</option><option value="vala">.vala</option><option value="vbnet">.vb</option><option value="vbscript">.vbs</option><option value="verilog">.v</option><option value="vhdl">.vhd</option><option value="vim">.vim</option><option value="wasm">.wat/.wasm</option><option value="wren">.wren</option><option value="x86asm">.asm</option><option value="xl">xl</option><option value="xml">.xml</option><option value="xquery">.xq</option><option value="yaml">.yml/.yaml</option><option value="zephir">.zep</option></select></div><br><input type="password" id="passphrase" placeholder="passphrase (optional)"><div class="result" id="result"><span>link: <a id="result-link" href="#" target="_blank"></a></span><button onclick="copyLink()" style="margin-left: 0.5rem; font-size: 0.85rem; padding: 0.25rem 0.5rem;">copy link</button></div><div class="status" id="status"></div><script>const FORM_STATE_KEY="kj-clipboard-form-state-v1"; function saveFormState(){ const state={ content: document.getElementById("content").value, isCode: document.getElementById("is-code").checked, language: document.getElementById("lang-select").value,}; sessionStorage.setItem(FORM_STATE_KEY, JSON.stringify(state));} function restoreFormState(){ const raw=sessionStorage.getItem(FORM_STATE_KEY); if (!raw){ toggleLang(); return;} try{ const state=JSON.parse(raw); document.getElementById("content").value=typeof state.content==="string" ? state.content : ""; document.getElementById("is-code").checked=!!state.isCode; const langSelect=document.getElementById("lang-select"); if (typeof state.language==="string"){ const langExists=Array.from(langSelect.options).some(opt=>opt.value===state.language); langSelect.value=langExists ? state.language : "";}} catch (_err){ sessionStorage.removeItem(FORM_STATE_KEY);} toggleLang();} function toggleLang(){ const sel=document.getElementById("lang-select"); sel.style.display=document.getElementById("is-code").checked ? "inline-block" : "none"; saveFormState();} async function createPaste(){ const content=document.getElementById("content").value.trim(); if (!content){ setStatus("nothing to paste."); return;} const btn=document.getElementById("get-link-btn"); btn.disabled=true; btn.textContent="generating..."; setStatus(""); const body={ content: content, is_code: document.getElementById("is-code").checked, language: document.getElementById("lang-select").value, passphrase: document.getElementById("passphrase").value,}; try{ const resp=await fetch("/api/paste",{ method: "POST", headers:{"Content-Type": "application/json"}, body: JSON.stringify(body),}); const data=await resp.json(); if (data.error){ setStatus("error: " + data.error); btn.disabled=false; btn.textContent="generate link"; return;} const url=window.location.origin + data.url; const linkEl=document.getElementById("result-link"); linkEl.href=url; linkEl.textContent=url; document.getElementById("result").style.display="block"; setStatus("done.");} catch (e){ setStatus("error: " + e.message);} btn.disabled=false; btn.textContent="generate link";} function copyLink(){ const url=document.getElementById("result-link").textContent; navigator.clipboard.writeText(url);} function setStatus(msg){ document.getElementById("status").textContent=msg;} restoreFormState(); document.getElementById("content").addEventListener("input", saveFormState); document.getElementById("lang-select").addEventListener("change", saveFormState); // allow tab key in textarea document.getElementById("content").addEventListener("keydown", function(e){ if (e.key==="Tab"){ e.preventDefault(); const start=this.selectionStart; const end=this.selectionEnd; this.value=this.value.substring(0, start) + "\t" + this.value.substring(end); this.selectionStart=this.selectionEnd=start + 1; saveFormState();}}); </script></body></html> \ No newline at end of file
diff --git a/src/server.py b/src/server.py
new file mode 100644
index 0000000..79e8079
--- /dev/null
+++ b/src/server.py
@@ -0,0 +1,845 @@
+#!/usr/bin/env python3
+
+# kj-clipboard server — no frills public clipboard
+# single-file server: sqlite, mojicrypt encryption, syntax highlighting
+# usage: python3 server.py
+
+import http.server
+import json
+import os
+import re
+import secrets
+import signal
+import sqlite3
+import subprocess
+import sys
+import threading
+import time
+from pathlib import Path
+from urllib.parse import urlparse, unquote
+
+
+# config
+
+PORT = int(os.environ.get("KJ_CLIPBOARD_PORT", 5555))
+BIND = os.environ.get("KJ_CLIPBOARD_BIND", "0.0.0.0")
+BASE_DIR = Path(__file__).parent.resolve()
+DB_PATH = BASE_DIR / "data" / "kj-clipboard.db"
+RANDOM_ID_LENGTH = 40 # random chars after unix epoch
+MAX_PASTE_SIZE = 1024 * 1024 # 1 MiB
+MAX_PASSPHRASE_SIZE = 256
+MAX_LANGUAGE_SIZE = 32
+MAX_DECRYPT_SIZE = 2 * 1024 * 1024 # includes encrypted glyph overhead
+ID_PATTERN = re.compile(r"^[0-9]{10,}[a-f0-9]{40}$")
+LANGUAGE_PATTERN = re.compile(r"^[a-z0-9_+#-]{1,32}$")
+
+REQUESTS_PER_WINDOW = int(os.environ.get("KJ_CLIPBOARD_RATE_LIMIT", "60"))
+RATE_WINDOW_SECONDS = int(os.environ.get("KJ_CLIPBOARD_RATE_WINDOW", "60"))
+
+ALLOWED_LANGUAGES = {
+ "1c",
+ "abnf",
+ "accesslog",
+ "ada",
+ "arduino",
+ "armasm",
+ "asciidoc",
+ "aspectj",
+ "autohotkey",
+ "autoit",
+ "avrasm",
+ "awk",
+ "bash",
+ "basic",
+ "bnf",
+ "brainfuck",
+ "c",
+ "cal",
+ "capnproto",
+ "ceylon",
+ "clean",
+ "clojure",
+ "clojure-repl",
+ "cmake",
+ "coffeescript",
+ "coq",
+ "cos",
+ "cpp",
+ "crmsh",
+ "crystal",
+ "csharp",
+ "csp",
+ "css",
+ "d",
+ "dart",
+ "delphi",
+ "diff",
+ "django",
+ "dns",
+ "dockerfile",
+ "dos",
+ "dsconfig",
+ "dts",
+ "dust",
+ "ebnf",
+ "elixir",
+ "elm",
+ "erb",
+ "erlang",
+ "excel",
+ "fix",
+ "flix",
+ "fortran",
+ "fsharp",
+ "gams",
+ "gauss",
+ "gcode",
+ "gherkin",
+ "glsl",
+ "gml",
+ "go",
+ "golo",
+ "gradle",
+ "graphql",
+ "groovy",
+ "haml",
+ "handlebars",
+ "haskell",
+ "haxe",
+ "hsp",
+ "http",
+ "hy",
+ "inform7",
+ "ini",
+ "irpf90",
+ "isbl",
+ "java",
+ "javascript",
+ "jboss-cli",
+ "json",
+ "julia",
+ "kotlin",
+ "lasso",
+ "latex",
+ "ldif",
+ "leaf",
+ "less",
+ "lisp",
+ "livecodeserver",
+ "livescript",
+ "llvm",
+ "lsl",
+ "lua",
+ "makefile",
+ "markdown",
+ "mathematica",
+ "matlab",
+ "maxima",
+ "mel",
+ "mercury",
+ "mipsasm",
+ "mizar",
+ "mojolicious",
+ "monkey",
+ "moonscript",
+ "n1ql",
+ "nestedtext",
+ "nginx",
+ "nim",
+ "nix",
+ "node-repl",
+ "nsis",
+ "objectivec",
+ "ocaml",
+ "openscad",
+ "oxygene",
+ "parser3",
+ "perl",
+ "pf",
+ "pgsql",
+ "php",
+ "php-template",
+ "plaintext",
+ "pony",
+ "powershell",
+ "processing",
+ "profile",
+ "prolog",
+ "properties",
+ "protobuf",
+ "puppet",
+ "purebasic",
+ "python",
+ "python-repl",
+ "q",
+ "qml",
+ "r",
+ "reasonml",
+ "rib",
+ "roboconf",
+ "routeros",
+ "rsl",
+ "ruby",
+ "ruleslanguage",
+ "rust",
+ "sas",
+ "scala",
+ "scheme",
+ "scilab",
+ "scss",
+ "shell",
+ "smali",
+ "smalltalk",
+ "sml",
+ "sqf",
+ "sql",
+ "stan",
+ "stata",
+ "step21",
+ "stylus",
+ "subunit",
+ "swift",
+ "taggerscript",
+ "tap",
+ "tcl",
+ "thrift",
+ "tp",
+ "twig",
+ "typescript",
+ "vala",
+ "vbnet",
+ "vbscript",
+ "verilog",
+ "vhdl",
+ "vim",
+ "wasm",
+ "wren",
+ "x86asm",
+ "xl",
+ "xml",
+ "xquery",
+ "yaml",
+ "zephir",
+}
+
+
+# in-memory fixed-window rate limiter (cheap guardrail behind nginx)
+_rate_lock = threading.Lock()
+_rate_state = {}
+
+
+# database
+
+
+def init_db():
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
+ conn = sqlite3.connect(str(DB_PATH))
+ conn.execute("PRAGMA journal_mode=WAL")
+ conn.execute("PRAGMA synchronous=NORMAL")
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS pastes (
+ id TEXT PRIMARY KEY,
+ content TEXT NOT NULL,
+ language TEXT DEFAULT NULL,
+ is_code INTEGER DEFAULT 0,
+ is_encrypted INTEGER DEFAULT 0,
+ created_at INTEGER NOT NULL
+ )
+ """
+ )
+ conn.commit()
+ conn.close()
+
+
+def generate_id():
+ """generate id in format: <unix-epoch><40-char-random-hex>"""
+ return f"{int(time.time())}{secrets.token_hex(RANDOM_ID_LENGTH // 2)}"
+
+
+def is_valid_paste_id(paste_id):
+ """validate id format: unix epoch prefix + 40 hex chars"""
+ return bool(ID_PATTERN.match(paste_id))
+
+
+def save_paste(content, language=None, is_code=False, is_encrypted=False):
+ """store a paste in the database, return its id"""
+ conn = sqlite3.connect(str(DB_PATH))
+ try:
+ for _ in range(5):
+ paste_id = generate_id()
+ try:
+ conn.execute(
+ "INSERT INTO pastes (id, content, language, is_code, is_encrypted, created_at) "
+ "VALUES (?, ?, ?, ?, ?, ?)",
+ (
+ paste_id,
+ content,
+ language,
+ int(is_code),
+ int(is_encrypted),
+ int(time.time()),
+ ),
+ )
+ conn.commit()
+ return paste_id
+ except sqlite3.IntegrityError:
+ continue
+ raise RuntimeError("failed to generate unique paste id")
+ finally:
+ conn.close()
+
+
+def get_paste(paste_id):
+ """retrieve a paste by id, returns dict or None"""
+ conn = sqlite3.connect(str(DB_PATH))
+ conn.row_factory = sqlite3.Row
+ row = conn.execute("SELECT * FROM pastes WHERE id = ?", (paste_id,)).fetchone()
+ conn.close()
+ if row:
+ return dict(row)
+ return None
+
+
+# mojicrypt helpers
+
+
+def mojicrypt_encrypt(text, passphrase):
+ """encrypt text with mojicrypt, return glyph string or None on failure"""
+ try:
+ result = subprocess.run(
+ ["mojicrypt", "encrypt", "-p", passphrase],
+ input=text,
+ capture_output=True,
+ text=True,
+ timeout=120,
+ check=False,
+ )
+ if result.returncode != 0:
+ return None
+ return result.stdout.strip()
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ return None
+
+
+def mojicrypt_decrypt(encrypted_blob, passphrase):
+ """decrypt a mojicrypt glyph string, return plaintext or None on failure"""
+ try:
+ result = subprocess.run(
+ ["mojicrypt", "decrypt", "-p", passphrase],
+ input=encrypted_blob,
+ capture_output=True,
+ text=True,
+ timeout=120,
+ check=False,
+ )
+ if result.returncode != 0:
+ return None
+ return result.stdout.strip()
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ return None
+
+
+# html templates
+
+
+def landing_page():
+ return (BASE_DIR / "index.html").read_text(encoding="utf-8")
+
+
+def paste_page(paste):
+ """render the view page for a paste"""
+ paste_id = paste["id"]
+ content = paste["content"]
+ is_code = paste["is_code"]
+ is_encrypted = paste["is_encrypted"]
+ language = paste["language"] or ""
+ created = time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime(paste["created_at"]))
+
+ if is_encrypted:
+ # show decrypt form instead of content
+ content_block = f"""
+ <form id="decrypt-form">
+ <p>this paste is password-protected.</p>
+ <input type="password" id="decrypt-pass" placeholder="passphrase" required>
+ <button type="submit" id="decrypt-btn">decrypt</button>
+ <span id="decrypt-status" style="margin-left:0.5rem;"></span>
+ </form>
+ <div id="paste-content" style="display:none;"></div>
+ <script>
+ document.getElementById("decrypt-form").addEventListener("submit", async function(e) {{
+ e.preventDefault();
+ const btn = document.getElementById("decrypt-btn");
+ const status = document.getElementById("decrypt-status");
+ const pass = document.getElementById("decrypt-pass").value;
+ btn.disabled = true;
+ btn.textContent = "--->";
+ status.textContent = "decrypting...";
+ const resp = await fetch("/api/decrypt", {{
+ method: "POST",
+ headers: {{"Content-Type": "application/json"}},
+ body: JSON.stringify({{id: "{paste_id}", passphrase: pass}})
+ }});
+ const data = await resp.json();
+ if (data.error) {{
+ alert(data.error);
+ btn.disabled = false;
+ btn.textContent = "decrypt";
+ status.textContent = "";
+ return;
+ }}
+ status.textContent = "decrypted.";
+ document.getElementById("decrypt-form").style.display = "none";
+ const el = document.getElementById("paste-content");
+ el.style.display = "block";
+ {"" if not is_code else f'''
+ const codeEl = document.createElement("pre");
+ const codeInner = document.createElement("code");
+ codeInner.className = "{("language-" + language) if language else ""}";
+ codeInner.textContent = data.content;
+ codeEl.appendChild(codeInner);
+ el.appendChild(codeEl);
+ hljs.highlightElement(codeInner);
+ '''}
+ {"" if is_code else '''
+ const pre = document.createElement("pre");
+ pre.textContent = data.content;
+ el.appendChild(pre);
+ '''}
+ // update copy button
+ document.getElementById("copy-btn").onclick = function() {{
+ copyPaste();
+ }};
+ // keep decrypted text in a runtime-only container for copy.
+ el.dataset.decryptedContent = data.content;
+ }});
+ </script>"""
+ else:
+ escaped = html_escape(content)
+ if is_code:
+ lang_class = f'class="language-{language}"' if language else ""
+ content_block = (
+ f'<pre><code id="paste-code" {lang_class}>{escaped}</code></pre>'
+ )
+ else:
+ content_block = f'<pre id="paste-plain">{escaped}</pre>'
+
+ highlight_css = ""
+ highlight_js = ""
+ if is_code:
+ highlight_css = '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">'
+ highlight_js = """<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
+<script>hljs.highlightAll();</script>"""
+
+ return f"""<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="color-scheme" content="light dark">
+ <meta name="robots" content="noindex, nofollow">
+ <title>kj-clipboard - {paste_id}</title>
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/kj-sh604/noir.css@latest/out/noir.min.css">
+ {highlight_css}
+</head>
+<body>
+ <h1><a href="/" style="text-decoration:none;color:inherit;">kj-clipboard</a></h1>
+ <p class="meta">created {created}{(" · " + language) if language else ""}{" · encrypted" if is_encrypted else ""}</p>
+ <div class="actions">
+ <button id="copy-btn" onclick="copyPaste()">copy to clipboard</button>
+ <a href="/raw/{paste_id}">raw</a>
+ </div>
+ {content_block}
+ {highlight_js}
+ <script>
+ function copyPaste() {{
+ const btn = document.getElementById("copy-btn");
+ let text = "";
+
+ const decryptedWrap = document.getElementById("paste-content");
+ if (decryptedWrap && decryptedWrap.dataset && decryptedWrap.dataset.decryptedContent) {{
+ text = decryptedWrap.dataset.decryptedContent;
+ }} else {{
+ const code = document.getElementById("paste-code");
+ const plain = document.getElementById("paste-plain");
+ if (code) {{
+ text = code.textContent || "";
+ }} else if (plain) {{
+ text = plain.textContent || "";
+ }}
+ }}
+
+ if (!text) {{
+ btn.textContent = "nothing to copy";
+ setTimeout(() => btn.textContent = "copy to clipboard", 1500);
+ return;
+ }}
+
+ navigator.clipboard.writeText(text);
+ btn.textContent = "copied!";
+ setTimeout(() => btn.textContent = "copy to clipboard", 1500);
+ }}
+ </script>
+</body>
+</html>"""
+
+
+def not_found_page():
+ return """<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="color-scheme" content="light dark">
+ <title>kj-clipboard - not found</title>
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/kj-sh604/noir.css@latest/out/noir.min.css">
+</head>
+<body>
+ <h1><a href="/" style="text-decoration:none;color:inherit;">kj-clipboard</a></h1>
+ <p>paste not found.</p>
+</body>
+</html>"""
+
+
+# helpers
+
+
+def html_escape(text):
+ return (
+ text.replace("&", "&amp;")
+ .replace("<", "&lt;")
+ .replace(">", "&gt;")
+ .replace('"', "&quot;")
+ .replace("'", "&#x27;")
+ )
+
+
+def html_escape_attr(text):
+ return html_escape(text).replace("\n", "&#10;").replace("\r", "&#13;")
+
+
+def normalize_language(value):
+ """normalize and validate highlight.js language token"""
+ if value is None:
+ return None
+ if not isinstance(value, str):
+ return None
+ lang = value.strip().lower()
+ if not lang:
+ return None
+ if len(lang) > MAX_LANGUAGE_SIZE:
+ return None
+ if not LANGUAGE_PATTERN.match(lang):
+ return None
+ if lang not in ALLOWED_LANGUAGES:
+ return None
+ return lang
+
+
+def get_client_ip(handler):
+ """use x-forwarded-for first when present (nginx reverse proxy)"""
+ xff = handler.headers.get("X-Forwarded-For", "").strip()
+ if xff:
+ return xff.split(",")[0].strip()
+ return handler.client_address[0]
+
+
+def is_rate_limited(client_ip):
+ """fixed window limiter for POST endpoints"""
+ now = int(time.time())
+ window = now // RATE_WINDOW_SECONDS
+
+ with _rate_lock:
+ key = (client_ip, window)
+ count = _rate_state.get(key, 0) + 1
+ _rate_state[key] = count
+
+ # cheap cleanup to avoid unbounded growth
+ stale_before = window - 2
+ stale_keys = [k for k in _rate_state if k[1] < stale_before]
+ for k in stale_keys:
+ _rate_state.pop(k, None)
+
+ return count > REQUESTS_PER_WINDOW
+
+
+# request handler
+
+
+class ClipboardHandler(http.server.BaseHTTPRequestHandler):
+ def log_message(self, fmt, *args):
+ client_ip = get_client_ip(self)
+ sys.stderr.write(
+ f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {client_ip} — {fmt % args}\n"
+ )
+
+ def add_security_headers(self):
+ self.send_header("X-Content-Type-Options", "nosniff")
+ self.send_header("X-Frame-Options", "DENY")
+ self.send_header("Referrer-Policy", "no-referrer")
+ self.send_header("Permissions-Policy", "interest-cohort=()")
+ self.send_header("Cross-Origin-Opener-Policy", "same-origin")
+ self.send_header("Cross-Origin-Resource-Policy", "same-origin")
+ self.send_header("Cache-Control", "no-store")
+ # CSP allows required CDNs and inline scripts currently used in templates.
+ self.send_header(
+ "Content-Security-Policy",
+ "default-src 'self'; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; "
+ "script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; img-src 'self' data:; "
+ "connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; object-src 'none'; form-action 'self'",
+ )
+
+ def send_html(self, code, body):
+ data = body.encode("utf-8")
+ self.send_response(code)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(data)))
+ self.add_security_headers()
+ self.end_headers()
+ self.wfile.write(data)
+
+ def send_json(self, code, obj):
+ data = json.dumps(obj, separators=(",", ":")).encode("utf-8")
+ self.send_response(code)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(data)))
+ self.add_security_headers()
+ self.end_headers()
+ self.wfile.write(data)
+
+ def send_plain(self, code, text):
+ data = text.encode("utf-8")
+ self.send_response(code)
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
+ self.send_header("Content-Length", str(len(data)))
+ self.add_security_headers()
+ self.end_headers()
+ self.wfile.write(data)
+
+ def read_body(self, max_size):
+ try:
+ length = int(self.headers.get("Content-Length", "0"))
+ except ValueError:
+ return None
+ if length <= 0 or length > max_size:
+ return None
+ return self.rfile.read(length)
+
+ def read_json_body(self, max_size):
+ ctype = self.headers.get("Content-Type", "").split(";")[0].strip().lower()
+ if ctype != "application/json":
+ return None
+ body = self.read_body(max_size)
+ if body is None:
+ return None
+ try:
+ data = json.loads(body)
+ except (json.JSONDecodeError, TypeError, UnicodeDecodeError):
+ return None
+ if not isinstance(data, dict):
+ return None
+ return data
+
+ def do_GET(self):
+ try:
+ parsed = urlparse(self.path)
+ path = unquote(parsed.path).rstrip("/") or "/"
+
+ if path == "/healthz":
+ self.send_plain(200, "ok")
+ return
+
+ if path == "/":
+ self.send_html(200, landing_page())
+ return
+
+ if path.startswith("/raw/"):
+ paste_id = path[5:]
+ if not is_valid_paste_id(paste_id):
+ self.send_plain(404, "not found")
+ return
+ paste = get_paste(paste_id)
+ if not paste:
+ self.send_plain(404, "not found")
+ return
+ if paste["is_encrypted"]:
+ self.send_plain(
+ 403,
+ "403: this paste is encrypted; raw view is not available - use the web interface to decrypt and view the content",
+ )
+ return
+ self.send_plain(200, paste["content"])
+ return
+
+ # treat anything else as a paste id
+ paste_id = path.lstrip("/")
+ if not is_valid_paste_id(paste_id):
+ self.send_html(404, not_found_page())
+ return
+
+ paste = get_paste(paste_id)
+ if not paste:
+ self.send_html(404, not_found_page())
+ return
+
+ self.send_html(200, paste_page(paste))
+ except Exception:
+ self.send_json(500, {"error": "internal server error"})
+
+ def do_POST(self):
+ try:
+ client_ip = get_client_ip(self)
+ if is_rate_limited(client_ip):
+ self.send_json(429, {"error": "rate limit exceeded"})
+ return
+
+ parsed = urlparse(self.path)
+ path = unquote(parsed.path)
+
+ if path == "/api/paste":
+ self.handle_create_paste()
+ elif path == "/api/decrypt":
+ self.handle_decrypt()
+ else:
+ self.send_json(404, {"error": "not found"})
+ except Exception:
+ self.send_json(500, {"error": "internal server error"})
+
+ def handle_create_paste(self):
+ data = self.read_json_body(MAX_PASTE_SIZE)
+ if data is None:
+ self.send_json(400, {"error": "invalid request"})
+ return
+
+ content = data.get("content", "")
+ if not isinstance(content, str):
+ self.send_json(400, {"error": "content is required"})
+ return
+
+ # preserve exact content while blocking empty/oversized payloads
+ if not content.strip():
+ self.send_json(400, {"error": "content is required"})
+ return
+ if len(content.encode("utf-8")) > MAX_PASTE_SIZE:
+ self.send_json(413, {"error": "paste too large (max 1 MiB)"})
+ return
+
+ is_code = bool(data.get("is_code", False))
+ language = normalize_language(data.get("language", ""))
+ passphrase = data.get("passphrase", "")
+ if passphrase is None:
+ passphrase = ""
+ if not isinstance(passphrase, str):
+ self.send_json(400, {"error": "invalid passphrase"})
+ return
+ passphrase = passphrase.strip()
+ if len(passphrase.encode("utf-8")) > MAX_PASSPHRASE_SIZE:
+ self.send_json(400, {"error": "passphrase too long"})
+ return
+
+ if data.get("language", "") and language is None:
+ self.send_json(400, {"error": "invalid language name"})
+ return
+
+ is_encrypted = False
+ store_content = content
+
+ if passphrase:
+ encrypted = mojicrypt_encrypt(content, passphrase)
+ if encrypted is None:
+ self.send_json(
+ 500, {"error": "encryption failed — is mojicrypt installed?"}
+ )
+ return
+ store_content = encrypted
+ is_encrypted = True
+
+ paste_id = save_paste(
+ content=store_content,
+ language=language,
+ is_code=is_code,
+ is_encrypted=is_encrypted,
+ )
+
+ self.send_json(200, {"id": paste_id, "url": f"/{paste_id}"})
+
+ def handle_decrypt(self):
+ data = self.read_json_body(MAX_DECRYPT_SIZE)
+ if data is None:
+ self.send_json(400, {"error": "invalid request"})
+ return
+
+ paste_id = data.get("id", "")
+ passphrase = data.get("passphrase", "")
+
+ if not isinstance(paste_id, str) or not isinstance(passphrase, str):
+ self.send_json(400, {"error": "id and passphrase are required"})
+ return
+ passphrase = passphrase.strip()
+ if len(passphrase.encode("utf-8")) > MAX_PASSPHRASE_SIZE:
+ self.send_json(400, {"error": "passphrase too long"})
+ return
+
+ if not paste_id or not passphrase:
+ self.send_json(400, {"error": "id and passphrase are required"})
+ return
+
+ if not is_valid_paste_id(paste_id):
+ self.send_json(404, {"error": "paste not found"})
+ return
+
+ paste = get_paste(paste_id)
+ if not paste:
+ self.send_json(404, {"error": "paste not found"})
+ return
+
+ if not paste["is_encrypted"]:
+ self.send_json(400, {"error": "paste is not encrypted"})
+ return
+
+ plaintext = mojicrypt_decrypt(paste["content"], passphrase)
+ if plaintext is None:
+ self.send_json(403, {"error": "wrong passphrase or corrupted data"})
+ return
+
+ self.send_json(200, {"content": plaintext})
+
+
+# main
+
+
+class ClipboardHTTPServer(http.server.ThreadingHTTPServer):
+ daemon_threads = True
+ allow_reuse_address = True
+
+
+def main():
+ init_db()
+ print(f"kj-clipboard — listening on {BIND}:{PORT}")
+
+ server = ClipboardHTTPServer((BIND, PORT), ClipboardHandler)
+ shutdown_requested = threading.Event()
+
+ def request_shutdown(msg):
+ if shutdown_requested.is_set():
+ return
+ shutdown_requested.set()
+ print(msg)
+ # shutdown() should run outside the serving thread.
+ threading.Thread(target=server.shutdown, daemon=True).start()
+
+ def _shutdown_handler(signum, _frame):
+ request_shutdown(f"\nreceived signal {signum}, shutting down.")
+
+ signal.signal(signal.SIGTERM, _shutdown_handler)
+
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ print("\nreceived keyboard interrupt, shutting down.")
+ finally:
+ server.server_close()
+
+
+if __name__ == "__main__":
+ main()