// 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 }