Some checks failed
release-tag / release-image (push) Failing after 56s
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
608 lines
14 KiB
Go
608 lines
14 KiB
Go
// 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
|
||
}
|