Files
virtual-clipboard/main.go
jbergner 8fd73ec786
All checks were successful
release-tag / release-image (push) Successful in 1m33s
build-binaries / build (, amd64, linux) (push) Has been skipped
build-binaries / build (, arm, 7, linux) (push) Has been skipped
build-binaries / build (, arm64, linux) (push) Has been skipped
build-binaries / build (.exe, amd64, windows) (push) Has been skipped
build-binaries / release (push) Has been skipped
bugfix2
2025-09-07 12:26:41 +02:00

608 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Go Virtual Clipboard single-file server
//
// Features
// - Web UI + REST API + SSE for realtime sync
// - Rooms ("channels") to separate clipboards
// - Optional shared-secret token (env: CLIPBOARD_TOKEN)
// - Optional JSON persistence (env: CLIPBOARD_DATA)
// - CORS enabled for API so you can script it from anywhere
// - No external dependencies; Go standard library only
//
// Build & Run
//
// go run .
//
// or
//
// PORT=9090 CLIPBOARD_TOKEN=geheim CLIPBOARD_DATA=clipboard.json go run .
//
// Open http://localhost:8080 (or your chosen PORT)
//
// API examples
//
// # put text
// curl -X POST http://localhost:8080/api/default/clip \
// -H 'Content-Type: application/json' \
// -d '{"content":"Hallo Welt","type":"text","author":"alice"}'
//
// # get latest
// curl http://localhost:8080/api/default/latest
//
// # stream updates (SSE)
// curl http://localhost:8080/api/default/stream
package main
import (
"context"
"crypto/rand"
"embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
// ---- Data model ----
type Clip struct {
ID string `json:"id"`
Room string `json:"room"`
Type string `json:"type"` // e.g., "text"
Content string `json:"content"`
Author string `json:"author,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// For persistence
type snapshot struct {
Version int `json:"version"`
Rooms map[string][]*Clip `json:"rooms"`
}
// Room keeps a ring buffer and subscribers for SSE.
// Non-blocking fanout so slow clients don't stall writers.
type Room struct {
name string
max int
mu sync.RWMutex
clips []*Clip
subs map[chan *Clip]struct{}
closed bool
}
func NewRoom(name string, max int) *Room {
return &Room{name: name, max: max, subs: make(map[chan *Clip]struct{})}
}
func (r *Room) addClip(c *Clip) {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.clips) >= r.max {
// drop oldest
copy(r.clips, r.clips[1:])
r.clips[len(r.clips)-1] = c
} else {
r.clips = append(r.clips, c)
}
for ch := range r.subs {
select {
case ch <- c:
default:
// drop to avoid blocking
}
}
}
func (r *Room) latest() (*Clip, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
if len(r.clips) == 0 {
return nil, false
}
return r.clips[len(r.clips)-1], true
}
func (r *Room) history(limit int) []*Clip {
r.mu.RLock()
defer r.mu.RUnlock()
if limit <= 0 || limit > len(r.clips) {
limit = len(r.clips)
}
start := len(r.clips) - limit
out := make([]*Clip, limit)
copy(out, r.clips[start:])
return out
}
// ---- Server ----
type Server struct {
mu sync.RWMutex
rooms map[string]*Room
maxPerRoom int
secretToken string
persistPath string
}
func NewServer(maxPerRoom int, token, persist string) *Server {
var perspath string = ""
if persist != "" {
perspath = persist
} else {
perspath = "./clipboard.json" // klar als Datei
}
return &Server{rooms: make(map[string]*Room), maxPerRoom: maxPerRoom, secretToken: token, persistPath: perspath}
}
func (s *Server) room(name string) *Room {
s.mu.Lock()
defer s.mu.Unlock()
if r, ok := s.rooms[name]; ok {
return r
}
r := NewRoom(name, s.maxPerRoom)
s.rooms[name] = r
return r
}
func (s *Server) load() error {
if s.persistPath == "" {
return nil
}
f, err := os.Open(s.persistPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
defer f.Close()
dec := json.NewDecoder(f)
var snap snapshot
if err := dec.Decode(&snap); err != nil {
return err
}
if snap.Version != 1 {
return fmt.Errorf("unsupported snapshot version: %d", snap.Version)
}
for name, list := range snap.Rooms {
r := s.room(name)
r.mu.Lock()
// keep only up to capacity
if len(list) > r.max {
list = list[len(list)-r.max:]
}
r.clips = append([]*Clip(nil), list...)
r.mu.Unlock()
}
return nil
}
func (s *Server) save() error {
if s.persistPath == "" {
return nil
}
tmp := s.persistPath + ".tmp"
if err := os.MkdirAll(filepath.Dir(s.persistPath), 0o755); err != nil {
return err
}
f, err := os.Create(tmp)
if err != nil {
return err
}
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
// build snapshot copy
s.mu.RLock()
snap := snapshot{Version: 1, Rooms: make(map[string][]*Clip, len(s.rooms))}
for name, r := range s.rooms {
r.mu.RLock()
list := make([]*Clip, len(r.clips))
copy(list, r.clips)
r.mu.RUnlock()
snap.Rooms[name] = list
}
s.mu.RUnlock()
if err := enc.Encode(&snap); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
_ = os.Remove(tmp)
return err
}
return os.Rename(tmp, s.persistPath)
}
// ---- HTTP helpers ----
type apiError struct {
Error string `json:"error"`
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func (s *Server) withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Token")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) requireToken(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.secretToken == "" {
next.ServeHTTP(w, r)
return
}
token := r.Header.Get("X-Token")
if token == "" {
token = r.URL.Query().Get("token")
}
if token != s.secretToken {
writeJSON(w, http.StatusUnauthorized, apiError{Error: "missing or invalid token"})
return
}
next.ServeHTTP(w, r)
})
}
// ---- Handlers ----
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
// Simple JSON status endpoint
s.mu.RLock()
rooms := make([]string, 0, len(s.rooms))
for name := range s.rooms {
rooms = append(rooms, name)
}
s.mu.RUnlock()
sort.Strings(rooms)
resp := map[string]any{
"rooms": rooms,
"requiresToken": s.secretToken != "",
}
writeJSON(w, http.StatusOK, resp)
}
type postClipReq struct {
Type string `json:"type"`
Content string `json:"content"`
Author string `json:"author"`
}
func (s *Server) handlePostClip(w http.ResponseWriter, r *http.Request) {
room := strings.TrimPrefix(r.URL.Path, "/api/")
room, _, _ = strings.Cut(room, "/") // first segment is room
if room == "" || room == "api" || strings.ContainsRune(room, '\n') {
writeJSON(w, http.StatusBadRequest, apiError{Error: "invalid room"})
return
}
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, apiError{Error: "use POST"})
return
}
var req postClipReq
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { // 1MB
writeJSON(w, http.StatusBadRequest, apiError{Error: "invalid JSON or payload too large"})
return
}
if req.Type == "" {
req.Type = "text"
}
if req.Content == "" {
writeJSON(w, http.StatusBadRequest, apiError{Error: "content empty"})
return
}
id := makeID()
clip := &Clip{ID: id, Room: room, Type: req.Type, Content: req.Content, Author: req.Author, CreatedAt: time.Now().UTC()}
rm := s.room(room)
rm.addClip(clip)
_ = s.save()
writeJSON(w, http.StatusCreated, clip)
}
func (s *Server) handleLatest(w http.ResponseWriter, r *http.Request) {
room := strings.TrimPrefix(r.URL.Path, "/api/")
room, _, _ = strings.Cut(room, "/")
rm := s.room(room)
if c, ok := rm.latest(); ok {
writeJSON(w, http.StatusOK, c)
return
}
writeJSON(w, http.StatusNotFound, apiError{Error: "no clips yet"})
}
func (s *Server) handleHistory(w http.ResponseWriter, r *http.Request) {
room := strings.TrimPrefix(r.URL.Path, "/api/")
room, _, _ = strings.Cut(room, "/")
limit := 50
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
limit = n
}
}
rm := s.room(room)
list := rm.history(limit)
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
room := strings.TrimPrefix(r.URL.Path, "/api/")
room, _, _ = strings.Cut(room, "/")
rm := s.room(room)
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
writeJSON(w, http.StatusInternalServerError, apiError{Error: "streaming unsupported"})
return
}
ch, unsubscribe := rm.subscribe()
defer unsubscribe()
ticker := time.NewTicker(25 * time.Second) // keep-alive for proxies
defer ticker.Stop()
ctx := r.Context()
enc := json.NewEncoder(w)
// send latest immediately if present
if latest, ok := rm.latest(); ok {
fmt.Fprintf(w, "id: %s\n", latest.ID)
fmt.Fprintf(w, "event: clip\n")
fmt.Fprintf(w, "data: ")
_ = enc.Encode(latest)
fmt.Fprintf(w, "\n")
flusher.Flush()
}
for {
select {
case <-ctx.Done():
return
case c, ok := <-ch:
if !ok {
// Raum wurde gelöscht -> Kanal zu -> Handler beenden
return
}
fmt.Fprintf(w, "id: %s\n", c.ID)
fmt.Fprintf(w, "event: clip\n")
fmt.Fprintf(w, "data: ")
_ = enc.Encode(c)
fmt.Fprintf(w, "\n")
flusher.Flush()
case <-ticker.C:
fmt.Fprintf(w, ": ping %d\n\n", time.Now().Unix())
flusher.Flush()
}
}
}
// ---- Utilities ----
func makeID() string {
var b [8]byte
_, _ = rand.Read(b[:])
return fmt.Sprintf("%s-%s", time.Now().UTC().Format("20060102T150405.000000000Z"), hex.EncodeToString(b[:]))
}
// ---- Router ----
//go:embed web/**
var embedded embed.FS
func (s *Server) routes() http.Handler {
mux := http.NewServeMux()
// --- API Handler ---
api := http.NewServeMux()
api.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// parse /api/{room}/...
path := strings.TrimPrefix(r.URL.Path, "/api/")
room, rest, _ := strings.Cut(path, "/")
if room == "" {
writeJSON(w, http.StatusNotFound, apiError{Error: "not found"})
return
}
// Fall 1: DELETE /api/{room} → Raum löschen
if rest == "" {
if r.Method == http.MethodDelete {
if err := s.deleteRoom(room); err != nil {
writeJSON(w, http.StatusInternalServerError, apiError{Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"status": "deleted", "room": room})
return
}
writeJSON(w, http.StatusNotFound, apiError{Error: "not found"})
return
}
// Fall 2: Sub-Routen wie /clip, /latest, /history, /stream
switch rest {
case "clip":
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, apiError{Error: "use POST"})
return
}
s.handlePostClip(w, r)
case "latest":
s.handleLatest(w, r)
case "history":
// Zusätzlich: DELETE /api/{room}/history -> Verlauf leeren
if r.Method == http.MethodDelete {
if err := s.clearRoom(room); err != nil {
writeJSON(w, http.StatusInternalServerError, apiError{Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"status": "cleared", "room": room})
return
}
s.handleHistory(w, r)
case "stream":
s.handleStream(w, r)
default:
writeJSON(w, http.StatusNotFound, apiError{Error: "unknown endpoint"})
}
})
// /api/* Routes einhängen (ggf. mit Token/CORS-Middleware umwickeln)
mux.Handle("/api/", s.withCORS(s.requireToken(api)))
// Status
mux.HandleFunc("/status", s.handleStatus)
// Static UI
sub, _ := fs.Sub(embedded, "web")
mux.Handle("/", http.FileServer(http.FS(sub)))
return mux
}
func main() {
port := getenv("PORT", ":8080")
maxPerRoom := getenvInt("MAX_PER_ROOM", 200)
secret := os.Getenv("CLIPBOARD_TOKEN")
persist := os.Getenv("CLIPBOARD_DATA")
s := NewServer(maxPerRoom, secret, persist)
if err := s.load(); err != nil {
log.Printf("WARN: could not load snapshot: %v", err)
}
srv := &http.Server{
Addr: port,
Handler: s.routes(),
ReadHeaderTimeout: 10 * time.Second,
}
// Docker-/Ctrl+C-Signale abfangen
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
log.Printf("Virtual Clipboard listening on http://0.0.0.0:%s (token required: %v)", port, secret != "")
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
}()
<-ctx.Done()
log.Printf("Shutdown signal received")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("server shutdown error: %v", err)
}
// Final speichert ihr ohnehin nach jedem POST; hier optional noch ein Save:
if err := s.save(); err != nil {
log.Printf("final save error: %v", err)
}
}
// nahe bei deinen Server-Methoden ergänzen
func (s *Server) clearRoom(name string) error {
r := s.room(name)
r.mu.Lock()
r.clips = nil
r.mu.Unlock()
return s.save()
}
// Room: alle Subscriber schließen (z.B. bei Delete)
func (r *Room) closeAll() {
r.mu.Lock()
if r.closed {
r.mu.Unlock()
return
}
r.closed = true
for ch := range r.subs {
close(ch)
delete(r.subs, ch)
}
r.mu.Unlock()
}
// Raum komplett löschen
func (s *Server) deleteRoom(name string) error {
s.mu.Lock()
r, ok := s.rooms[name]
if ok {
delete(s.rooms, name)
}
s.mu.Unlock()
if ok && r != nil {
r.closeAll() // <- WICHTIG: SSE-Kanäle schließen
}
return s.save()
}
func getenv(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func getenvInt(k string, def int) int {
if v := os.Getenv(k); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
func (r *Room) subscribe() (chan *Clip, func()) {
ch := make(chan *Clip, 8)
r.mu.Lock()
if r.closed {
r.mu.Unlock()
close(ch)
return ch, func() {}
}
r.subs[ch] = struct{}{}
r.mu.Unlock()
unsubscribe := func() {
r.mu.Lock()
// Nur schließen, wenn noch registriert
if _, ok := r.subs[ch]; ok {
delete(r.subs, ch)
close(ch)
}
r.mu.Unlock()
}
return ch, unsubscribe
}