From 15e45b95a9f1eea0c2723f6ab2ad3c3a144413bd Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Mon, 6 Apr 2026 16:57:18 +0300 Subject: [PATCH] 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 --- stacks/terminal/clipboard-upload/go.mod | 3 + stacks/terminal/clipboard-upload/main.go | 94 +++++++++ stacks/terminal/files/index.html | 231 +++++++++++++++++++++++ stacks/terminal/main.tf | 88 +++++++++ 4 files changed, 416 insertions(+) create mode 100644 stacks/terminal/clipboard-upload/go.mod create mode 100644 stacks/terminal/clipboard-upload/main.go create mode 100644 stacks/terminal/files/index.html diff --git a/stacks/terminal/clipboard-upload/go.mod b/stacks/terminal/clipboard-upload/go.mod new file mode 100644 index 00000000..2c842e6e --- /dev/null +++ b/stacks/terminal/clipboard-upload/go.mod @@ -0,0 +1,3 @@ +module clipboard-upload + +go 1.25.0 diff --git a/stacks/terminal/clipboard-upload/main.go b/stacks/terminal/clipboard-upload/main.go new file mode 100644 index 00000000..99ba9367 --- /dev/null +++ b/stacks/terminal/clipboard-upload/main.go @@ -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}) +} diff --git a/stacks/terminal/files/index.html b/stacks/terminal/files/index.html new file mode 100644 index 00000000..548e7552 --- /dev/null +++ b/stacks/terminal/files/index.html @@ -0,0 +1,231 @@ + + + + + + Terminal + + + + +
+
+ + + + + + + + diff --git a/stacks/terminal/main.tf b/stacks/terminal/main.tf index 099bb2d2..97b94781 100644 --- a/stacks/terminal/main.tf +++ b/stacks/terminal/main.tf @@ -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