diff --git a/clipboard.json b/clipboard.json new file mode 100644 index 0000000..dfa4d58 --- /dev/null +++ b/clipboard.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "rooms": { + "default": [ + { + "id": "20250905T103347.497751500Z-f12bf341402200b2", + "room": "default", + "type": "text", + "content": "demo", + "author": "Jan", + "created_at": "2025-09-05T10:33:47.4977515Z" + } + ] + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..523aa82 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.send.nrw/sendnrw/virtual-clipboard + +go 1.24.4 diff --git a/main.go b/main.go new file mode 100644 index 0000000..f23bd6e --- /dev/null +++ b/main.go @@ -0,0 +1,607 @@ +// 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 +} diff --git a/pers b/pers new file mode 100644 index 0000000..ac79550 --- /dev/null +++ b/pers @@ -0,0 +1,7 @@ +{ + "version": 1, + "rooms": { + "default": [], + "default1": [] + } +} diff --git a/pers.tmp b/pers.tmp new file mode 100644 index 0000000..e69de29 diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..1d28b61 --- /dev/null +++ b/web/app.js @@ -0,0 +1,221 @@ +"use strict"; +const qs = (sel) => document.querySelector(sel); +const roomsListEl = qs('#roomsList'); +const roomEl = qs('#room'); +const tokenEl = qs('#token'); +const tokenWrap = qs('#tokenWrap'); +const contentEl = qs('#content'); +const authorEl = qs('#author'); +const statusEl = qs('#status'); +const listEl = qs('#list'); + +let sse; // <— globale Referenz auf EventSource +const seen = new Set(); // falls noch nicht vorhanden (für Dedupe) + +// remember last-used settings +roomEl.value = localStorage.getItem('vc.room') || 'default'; +tokenEl.value = localStorage.getItem('vc.token') || ''; +authorEl.value = localStorage.getItem('vc.author') || ''; + +roomEl.addEventListener('change', () => localStorage.setItem('vc.room', roomEl.value.trim())); +tokenEl.addEventListener('change', () => localStorage.setItem('vc.token', tokenEl.value)); +authorEl.addEventListener('change', () => localStorage.setItem('vc.author', authorEl.value)); + +async function getJSON(path) { + const url = new URL(path, location.href); + const headers = { 'Accept': 'application/json' }; + const token = tokenEl.value.trim(); + if (token) headers['X-Token'] = token; + const res = await fetch(url, { headers }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +async function postJSON(path, body) { + const url = new URL(path, location.href); + const headers = { 'Content-Type': 'application/json' }; + const token = tokenEl.value.trim(); + if (token) headers['X-Token'] = token; + const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +async function refreshHistory() { + const n = Number(qs('#limit').value); + const data = await getJSON(`/api/${encodeURIComponent(roomEl.value.trim())}/history?limit=${n}`); + listEl.innerHTML = ''; + for (const c of data.reverse()) { + listEl.appendChild(renderClip(c)); + } +} + +function renderClip(c) { + const el = document.createElement('div'); + el.className = 'clip'; + const when = new Date(c.created_at).toLocaleString(); + el.innerHTML = ` +
+