feat(terminal): add clipboard paste support for text and images
- Custom index.html with xterm.js for reliable Ctrl+V text paste - Go clipboard-upload service saves pasted images to /tmp/clipboard-images/ - Traefik IngressRoute routes /clipboard/* to upload service (same-origin) - Authentik-protected upload path with strip-prefix middleware
This commit is contained in:
parent
cbed5423ec
commit
15e45b95a9
4 changed files with 416 additions and 0 deletions
3
stacks/terminal/clipboard-upload/go.mod
Normal file
3
stacks/terminal/clipboard-upload/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module clipboard-upload
|
||||
|
||||
go 1.25.0
|
||||
94
stacks/terminal/clipboard-upload/main.go
Normal file
94
stacks/terminal/clipboard-upload/main.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
uploadDir = "/tmp/clipboard-images"
|
||||
maxUpload = 10 << 20 // 10MB
|
||||
listenAddr = "0.0.0.0:7683"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := os.MkdirAll(uploadDir, 0755); err != nil {
|
||||
log.Fatalf("Failed to create upload dir: %v", err)
|
||||
}
|
||||
|
||||
http.HandleFunc("/upload", handleUpload)
|
||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
log.Printf("Clipboard upload service listening on %s, saving to %s", listenAddr, uploadDir)
|
||||
log.Fatal(http.ListenAndServe(listenAddr, nil))
|
||||
}
|
||||
|
||||
func handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUpload)
|
||||
if err := r.ParseMultipartForm(maxUpload); err != nil {
|
||||
http.Error(w, "File too large (max 10MB)", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("image")
|
||||
if err != nil {
|
||||
http.Error(w, "Missing 'image' field", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
ct := header.Header.Get("Content-Type")
|
||||
if !strings.HasPrefix(ct, "image/") {
|
||||
http.Error(w, "Not an image", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ext := ".png"
|
||||
switch ct {
|
||||
case "image/jpeg":
|
||||
ext = ".jpg"
|
||||
case "image/gif":
|
||||
ext = ".gif"
|
||||
case "image/webp":
|
||||
ext = ".webp"
|
||||
}
|
||||
|
||||
randBytes := make([]byte, 4)
|
||||
rand.Read(randBytes)
|
||||
filename := fmt.Sprintf("%s-%s%s", time.Now().Format("20060102-150405"), hex.EncodeToString(randBytes), ext)
|
||||
destPath := filepath.Join(uploadDir, filename)
|
||||
|
||||
dest, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to save", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer dest.Close()
|
||||
|
||||
if _, err := io.Copy(dest, file); err != nil {
|
||||
os.Remove(destPath)
|
||||
http.Error(w, "Failed to save", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Saved clipboard image: %s (%s, %d bytes)", destPath, ct, header.Size)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"path": destPath})
|
||||
}
|
||||
231
stacks/terminal/files/index.html
Normal file
231
stacks/terminal/files/index.html
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Terminal</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #000; }
|
||||
#terminal { height: 100%; width: 100%; }
|
||||
#toast {
|
||||
position: fixed; top: 16px; right: 16px; z-index: 9999;
|
||||
background: #1a1a2e; color: #a29bfe; border: 1px solid #333;
|
||||
border-radius: 8px; padding: 10px 18px; font-family: monospace;
|
||||
font-size: 14px; opacity: 0; transition: opacity 0.3s;
|
||||
pointer-events: none; max-width: 500px; word-break: break-all;
|
||||
}
|
||||
#toast.visible { opacity: 1; }
|
||||
#toast.error { color: #e74c3c; border-color: #e74c3c; }
|
||||
#toast.success { color: #2ecc71; border-color: #2ecc71; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="terminal"></div>
|
||||
<div id="toast"></div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.18.0/lib/addon-webgl.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
// ttyd binary protocol: first byte is message type
|
||||
const MSG_OUTPUT = 0x30; // '0' - terminal output
|
||||
const MSG_SET_PREFS = 0x31; // '1' - JSON preferences
|
||||
const MSG_SET_TITLE = 0x32; // '2' - window title
|
||||
|
||||
const MSG_INPUT = '0'; // client sends: '0' + data
|
||||
const MSG_RESIZE = '1'; // client sends: '1' + JSON {columns, rows}
|
||||
|
||||
let ws = null;
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
function showToast(msg, type, duration) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.className = 'visible' + (type ? ' ' + type : '');
|
||||
clearTimeout(el._timer);
|
||||
el._timer = setTimeout(() => { el.className = ''; }, duration || 3000);
|
||||
}
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace",
|
||||
fontSize: 15,
|
||||
theme: {
|
||||
background: '#1a1a2e',
|
||||
foreground: '#eee',
|
||||
cursor: '#a29bfe',
|
||||
selectionBackground: 'rgba(162, 155, 254, 0.3)'
|
||||
},
|
||||
allowProposedApi: true
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(webLinksAddon);
|
||||
|
||||
try {
|
||||
const webglAddon = new WebglAddon.WebglAddon();
|
||||
webglAddon.onContextLoss(() => { webglAddon.dispose(); });
|
||||
term.loadAddon(webglAddon);
|
||||
} catch (e) {
|
||||
console.warn('WebGL addon failed:', e);
|
||||
}
|
||||
|
||||
term.open(document.getElementById('terminal'));
|
||||
fitAddon.fit();
|
||||
|
||||
// Send binary input to ttyd
|
||||
function sendInput(data) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const payload = textEncoder.encode(data);
|
||||
const buf = new Uint8Array(payload.length + 1);
|
||||
buf[0] = MSG_INPUT.charCodeAt(0);
|
||||
buf.set(payload, 1);
|
||||
ws.send(buf.buffer);
|
||||
}
|
||||
|
||||
// Send resize
|
||||
function sendResize() {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const json = JSON.stringify({ columns: term.cols, rows: term.rows });
|
||||
const payload = textEncoder.encode(json);
|
||||
const buf = new Uint8Array(payload.length + 1);
|
||||
buf[0] = MSG_RESIZE.charCodeAt(0);
|
||||
buf.set(payload, 1);
|
||||
ws.send(buf.buffer);
|
||||
}
|
||||
|
||||
// Terminal input
|
||||
term.onData(sendInput);
|
||||
term.onBinary((data) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const bytes = new Uint8Array(data.length + 1);
|
||||
bytes[0] = MSG_INPUT.charCodeAt(0);
|
||||
for (let i = 0; i < data.length; i++) bytes[i + 1] = data.charCodeAt(i);
|
||||
ws.send(bytes.buffer);
|
||||
});
|
||||
|
||||
// Resize handling
|
||||
term.onResize(() => sendResize());
|
||||
window.addEventListener('resize', () => fitAddon.fit());
|
||||
|
||||
// Clipboard: Ctrl+V / Cmd+V
|
||||
term.attachCustomKeyEventHandler((e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'c' && term.hasSelection()) {
|
||||
navigator.clipboard.writeText(term.getSelection());
|
||||
return false;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
|
||||
return false; // let browser paste event fire
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Image + text paste
|
||||
document.addEventListener('paste', async (e) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const blob = item.getAsFile();
|
||||
if (!blob) { showToast('Failed to read image', 'error'); return; }
|
||||
|
||||
showToast('Uploading image...', '');
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', blob);
|
||||
const resp = await fetch('/clipboard/upload', { method: 'POST', body: formData });
|
||||
if (!resp.ok) { showToast('Upload failed: ' + await resp.text(), 'error', 5000); return; }
|
||||
const { path } = await resp.json();
|
||||
sendInput(path);
|
||||
showToast('Pasted: ' + path, 'success', 4000);
|
||||
} catch (err) {
|
||||
showToast('Upload error: ' + err.message, 'error', 5000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const text = e.clipboardData.getData('text');
|
||||
if (text) {
|
||||
e.preventDefault();
|
||||
sendInput(text);
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Connect
|
||||
function connect() {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = proto + '//' + location.host + location.pathname.replace(/\/+$/, '') + '/ws';
|
||||
|
||||
fetch(location.pathname.replace(/\/+$/, '') + '/token', { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.then(tokenData => {
|
||||
const token = tokenData.token || '';
|
||||
ws = new WebSocket(wsUrl, ['tty']);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('Connected to ttyd');
|
||||
// Send auth + initial size as JSON
|
||||
const initMsg = JSON.stringify({
|
||||
AuthToken: token,
|
||||
columns: term.cols,
|
||||
rows: term.rows
|
||||
});
|
||||
ws.send(initMsg);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = event.data;
|
||||
if (data instanceof ArrayBuffer) {
|
||||
const view = new Uint8Array(data);
|
||||
if (view.length < 1) return;
|
||||
const msgType = view[0];
|
||||
const payload = view.slice(1);
|
||||
|
||||
switch (msgType) {
|
||||
case MSG_OUTPUT:
|
||||
term.write(payload);
|
||||
break;
|
||||
case MSG_SET_PREFS:
|
||||
try {
|
||||
const prefs = JSON.parse(textDecoder.decode(payload));
|
||||
console.log('ttyd prefs:', prefs);
|
||||
} catch (e) {}
|
||||
break;
|
||||
case MSG_SET_TITLE:
|
||||
document.title = textDecoder.decode(payload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
term.write('\r\n\x1b[31mDisconnected. Reconnecting...\x1b[0m\r\n');
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = (e) => console.error('WebSocket error:', e);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Token fetch failed:', err);
|
||||
term.write('\r\n\x1b[31mFailed to connect. Retrying...\x1b[0m\r\n');
|
||||
setTimeout(connect, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
connect();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -107,6 +107,94 @@ resource "kubernetes_endpoints" "terminal_ro" {
|
|||
}
|
||||
}
|
||||
|
||||
# Clipboard image upload service (same-origin path routing)
|
||||
resource "kubernetes_service" "clipboard_upload" {
|
||||
metadata {
|
||||
name = "clipboard-upload"
|
||||
namespace = kubernetes_namespace.terminal.metadata[0].name
|
||||
labels = {
|
||||
app = "clipboard-upload"
|
||||
}
|
||||
}
|
||||
|
||||
spec {
|
||||
port {
|
||||
name = "http"
|
||||
port = 80
|
||||
target_port = 7683
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_endpoints" "clipboard_upload" {
|
||||
metadata {
|
||||
name = "clipboard-upload"
|
||||
namespace = kubernetes_namespace.terminal.metadata[0].name
|
||||
}
|
||||
|
||||
subset {
|
||||
address {
|
||||
ip = "10.0.10.10"
|
||||
}
|
||||
port {
|
||||
name = "http"
|
||||
port = 7683
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# IngressRoute for /clipboard/* on terminal.viktorbarzin.me → clipboard-upload service
|
||||
resource "kubernetes_manifest" "clipboard_ingressroute" {
|
||||
manifest = {
|
||||
apiVersion = "traefik.io/v1alpha1"
|
||||
kind = "IngressRoute"
|
||||
metadata = {
|
||||
name = "clipboard-upload"
|
||||
namespace = kubernetes_namespace.terminal.metadata[0].name
|
||||
}
|
||||
spec = {
|
||||
entryPoints = ["websecure"]
|
||||
routes = [{
|
||||
match = "Host(`terminal.viktorbarzin.me`) && PathPrefix(`/clipboard/`)"
|
||||
kind = "Rule"
|
||||
middlewares = [
|
||||
{
|
||||
name = "authentik-forward-auth"
|
||||
namespace = "traefik"
|
||||
},
|
||||
{
|
||||
name = "clipboard-strip-prefix"
|
||||
namespace = kubernetes_namespace.terminal.metadata[0].name
|
||||
}
|
||||
]
|
||||
services = [{
|
||||
name = "clipboard-upload"
|
||||
port = 80
|
||||
}]
|
||||
}]
|
||||
tls = {
|
||||
secretName = var.tls_secret_name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_manifest" "clipboard_strip_prefix" {
|
||||
manifest = {
|
||||
apiVersion = "traefik.io/v1alpha1"
|
||||
kind = "Middleware"
|
||||
metadata = {
|
||||
name = "clipboard-strip-prefix"
|
||||
namespace = kubernetes_namespace.terminal.metadata[0].name
|
||||
}
|
||||
spec = {
|
||||
stripPrefix = {
|
||||
prefixes = ["/clipboard"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module "ingress_ro" {
|
||||
source = "../../modules/kubernetes/ingress_factory"
|
||||
namespace = kubernetes_namespace.terminal.metadata[0].name
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue