init
This commit is contained in:
15
clipboard.json
Normal file
15
clipboard.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
3
go.mod
Normal file
3
go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module git.send.nrw/sendnrw/virtual-clipboard
|
||||||
|
|
||||||
|
go 1.24.4
|
||||||
607
main.go
Normal file
607
main.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
7
pers
Normal file
7
pers
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"rooms": {
|
||||||
|
"default": [],
|
||||||
|
"default1": []
|
||||||
|
}
|
||||||
|
}
|
||||||
221
web/app.js
Normal file
221
web/app.js
Normal file
@@ -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 = `
|
||||||
|
<div class="meta">
|
||||||
|
<span class="pill">${c.type}</span>
|
||||||
|
<span>${when}</span>
|
||||||
|
${c.author ? `<span>von <strong>${escapeHtml(c.author)}</strong></span>` : ''}
|
||||||
|
<span class="muted">ID ${c.id.slice(-6)}</span>
|
||||||
|
<span style="margin-left:auto" class="inline">
|
||||||
|
<button data-copy>In Zwischenablage</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="white-space: pre-wrap; word-break: break-word">${linkify(escapeHtml(c.content))}</div>
|
||||||
|
`;
|
||||||
|
el.querySelector('[data-copy]').addEventListener('click', async () => {
|
||||||
|
await navigator.clipboard.writeText(c.content);
|
||||||
|
});
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkify(text) {
|
||||||
|
const urlRE = /(https?:\/\/[^\s]+)/g;
|
||||||
|
return text.replace(urlRE, (u) => `<a href="${u}" target="_blank" rel="noreferrer noopener">${u}</a>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return s.replace(/[&<>"']/g, (ch) => ({'&':'&','<':'<','>':'>','"':'"','\'':'''}[ch]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectTokenRequirement() {
|
||||||
|
try {
|
||||||
|
const s = await getJSON('/status');
|
||||||
|
tokenWrap.style.display = s.requiresToken ? 'inline-flex' : 'none';
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendClip() {
|
||||||
|
const content = contentEl.value;
|
||||||
|
if (!content.trim()) return;
|
||||||
|
const body = { type: 'text', content, author: authorEl.value || undefined };
|
||||||
|
await postJSON(`/api/${encodeURIComponent(roomEl.value.trim())}/clip`, body);
|
||||||
|
contentEl.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyLatest() {
|
||||||
|
const c = await getJSON(`/api/${encodeURIComponent(roomEl.value.trim())}/latest`);
|
||||||
|
await navigator.clipboard.writeText(c.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareLink() {
|
||||||
|
const url = new URL(location.href);
|
||||||
|
url.searchParams.set('room', roomEl.value.trim());
|
||||||
|
if (tokenEl.value) url.searchParams.set('token', tokenEl.value.trim());
|
||||||
|
navigator.clipboard.writeText(url.toString());
|
||||||
|
alert('Link in Zwischenablage');
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectStream() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (tokenEl.value.trim()) params.set('token', tokenEl.value.trim());
|
||||||
|
const src = new EventSource(`/api/${encodeURIComponent(roomEl.value.trim())}/stream?${params}`);
|
||||||
|
statusEl.textContent = 'verbunden';
|
||||||
|
src.addEventListener('clip', (ev) => {
|
||||||
|
const c = JSON.parse(ev.data);
|
||||||
|
if (seen.has(c.id)) return;
|
||||||
|
seen.add(c.id);
|
||||||
|
listEl.prepend(renderClip(c));
|
||||||
|
});
|
||||||
|
src.onerror = () => {
|
||||||
|
statusEl.textContent = 'getrennt';
|
||||||
|
try { src.close(); } catch {}
|
||||||
|
setTimeout(() => { sse = connectStream(); }, 1500);
|
||||||
|
};
|
||||||
|
return src;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// wire up UI
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
qs('#btnSend').addEventListener('click', sendClip);
|
||||||
|
qs('#btnRooms')?.addEventListener('click', loadRooms);
|
||||||
|
qs('#btnCopyLatest').addEventListener('click', copyLatest);
|
||||||
|
qs('#btnReload').addEventListener('click', refreshHistory);
|
||||||
|
qs('#btnShare').addEventListener('click', shareLink);
|
||||||
|
qs('#btnClear').addEventListener('click', async () => {
|
||||||
|
if (!confirm('Verlauf in diesem Raum wirklich löschen?')) return;
|
||||||
|
await fetch(`/api/${encodeURIComponent(roomEl.value.trim())}/history`, { method: 'DELETE', headers: tokenEl.value ? {'X-Token': tokenEl.value} : {} });
|
||||||
|
seen.clear();
|
||||||
|
await refreshHistory();
|
||||||
|
});
|
||||||
|
|
||||||
|
qs('#btnDelete').addEventListener('click', async () => {
|
||||||
|
if (!confirm('Diesen Raum komplett löschen?')) return;
|
||||||
|
await fetch(`/api/${encodeURIComponent(roomEl.value.trim())}`, { method: 'DELETE', headers: tokenEl.value ? {'X-Token': tokenEl.value} : {} });
|
||||||
|
seen.clear();
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
});
|
||||||
|
const url = new URL(location.href);
|
||||||
|
if (url.searchParams.get('room')) {
|
||||||
|
roomEl.value = url.searchParams.get('room');
|
||||||
|
localStorage.setItem('vc.room', roomEl.value);
|
||||||
|
}
|
||||||
|
if (url.searchParams.get('token')) {
|
||||||
|
tokenEl.value = url.searchParams.get('token');
|
||||||
|
localStorage.setItem('vc.token', tokenEl.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
detectTokenRequirement();
|
||||||
|
refreshHistory().catch(console.error);
|
||||||
|
loadRooms();
|
||||||
|
sse = connectStream();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadRooms() {
|
||||||
|
try {
|
||||||
|
const s = await getJSON('/status');
|
||||||
|
const rooms = Array.isArray(s.rooms) ? s.rooms : [];
|
||||||
|
roomsListEl.innerHTML = '';
|
||||||
|
for (const r of rooms) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'clip'; // kleiner Stil-Reuse
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="inline" style="justify-content: space-between; width:100%">
|
||||||
|
<div><strong>${escapeHtml(r)}</strong></div>
|
||||||
|
<div class="inline" style="gap:6px">
|
||||||
|
<button data-open>Öffnen</button>
|
||||||
|
<button data-clear title="Verlauf leeren">Leeren</button>
|
||||||
|
<button data-del title="Raum löschen">Löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
item.querySelector('[data-open]').addEventListener('click', () => switchRoom(r));
|
||||||
|
item.querySelector('[data-clear]').addEventListener('click', async () => {
|
||||||
|
if (!confirm(`Verlauf in Raum "${r}" wirklich löschen?`)) return;
|
||||||
|
await fetch(`/api/${encodeURIComponent(r)}/history`, { method: 'DELETE', headers: tokenEl.value ? {'X-Token': tokenEl.value} : {} });
|
||||||
|
if (r === roomEl.value.trim()) { seen.clear(); await refreshHistory(); }
|
||||||
|
await loadRooms();
|
||||||
|
});
|
||||||
|
item.querySelector('[data-del]').addEventListener('click', async () => {
|
||||||
|
if (!confirm(`Raum "${r}" wirklich löschen?`)) return;
|
||||||
|
await fetch(`/api/${encodeURIComponent(r)}`, { method: 'DELETE', headers: tokenEl.value ? {'X-Token': tokenEl.value} : {} });
|
||||||
|
if (r === roomEl.value.trim()) {
|
||||||
|
seen.clear();
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
// aktuelle Verbindung trennen
|
||||||
|
if (sse) { try { sse.close(); } catch{} sse = null; }
|
||||||
|
}
|
||||||
|
await loadRooms();
|
||||||
|
});
|
||||||
|
roomsListEl.appendChild(item);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('rooms load failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchRoom(newRoom) {
|
||||||
|
roomEl.value = newRoom;
|
||||||
|
localStorage.setItem('vc.room', newRoom);
|
||||||
|
seen.clear();
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
if (sse) { try { sse.close(); } catch{} sse = null; }
|
||||||
|
await refreshHistory().catch(console.error);
|
||||||
|
sse = connectStream(); // neue Verbindung
|
||||||
|
}
|
||||||
|
|
||||||
91
web/index.html
Normal file
91
web/index.html
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Virtuelle Zwischenablage</title>
|
||||||
|
<style>
|
||||||
|
:root { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif; }
|
||||||
|
body { margin: 0; background: #0b0f19; color: #e6e6e6; }
|
||||||
|
header { padding: 16px 20px; border-bottom: 1px solid #1f2637; position: sticky; top: 0; background: #0b0f19; z-index: 1; }
|
||||||
|
h1 { margin: 0; font-size: 18px; }
|
||||||
|
main { max-width: 900px; margin: 0 auto; padding: 20px; }
|
||||||
|
.row { display: grid; grid-template-columns: 1fr auto; gap: 10px; }
|
||||||
|
.grid { display: grid; gap: 12px; }
|
||||||
|
input, textarea, select, button { background: #131a2a; border: 1px solid #22304d; color: #e6e6e6; border-radius: 10px; padding: 10px; font-size: 14px; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
button.primary { background: #3b82f6; border-color: #3b82f6; color: #fff; }
|
||||||
|
.card { border: 1px solid #1f2637; background: #0f1524; border-radius: 14px; padding: 14px; }
|
||||||
|
.list { display: grid; gap: 10px; }
|
||||||
|
.clip { display: grid; gap: 6px; border: 1px solid #22304d; padding: 10px; border-radius: 12px; background: #0b1222; }
|
||||||
|
.meta { color: #9aa6c6; font-size: 12px; display: flex; gap: 10px; align-items: center; }
|
||||||
|
.actions { display: flex; gap: 8px; }
|
||||||
|
.muted { color: #9aa6c6; }
|
||||||
|
.inline { display: inline-flex; gap: 8px; align-items: center; }
|
||||||
|
.pill { background: #14203a; border: 1px solid #22304d; padding: 2px 8px; border-radius: 999px; font-size: 12px; }
|
||||||
|
a { color: #7cb2ff; text-decoration: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>🗂️ Virtuelle Zwischenablage</h1>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card grid">
|
||||||
|
<div class="row">
|
||||||
|
<div class="inline">
|
||||||
|
<label for="room" class="muted">Raum:</label>
|
||||||
|
<input id="room" placeholder="z.B. default, team, dev" value="default" />
|
||||||
|
<span id="tokenWrap" class="inline" style="display:none">
|
||||||
|
<label for="token" class="muted">Token:</label>
|
||||||
|
<input id="token" placeholder="geheimer Schlüssel" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="btnCopyLatest">Aktuell kopieren</button>
|
||||||
|
<button id="btnShare">Share-Link</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea id="content" rows="5" placeholder="Text hier einfügen…"></textarea>
|
||||||
|
<div class="row">
|
||||||
|
<input id="author" placeholder="Optional: Name/Autor" />
|
||||||
|
<button id="btnSend" class="primary">An Zwischenablage senden</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="inline" style="justify-content: space-between; width: 100%">
|
||||||
|
<div>
|
||||||
|
<strong>Verlauf</strong>
|
||||||
|
<span class="pill" id="status">not connected</span>
|
||||||
|
</div>
|
||||||
|
<div class="inline">
|
||||||
|
<label for="limit" class="muted">Anzahl:</label>
|
||||||
|
<select id="limit">
|
||||||
|
<option>10</option>
|
||||||
|
<option selected>25</option>
|
||||||
|
<option>50</option>
|
||||||
|
<option>100</option>
|
||||||
|
</select>
|
||||||
|
<button id="btnReload">Neu laden</button>
|
||||||
|
<button id="btnClear">Raum leeren</button>
|
||||||
|
<button id="btnDelete">Raum löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="list" id="list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="inline" style="justify-content: space-between; width: 100%">
|
||||||
|
<strong>Räume</strong>
|
||||||
|
<button id="btnRooms">Aktualisieren</button>
|
||||||
|
</div>
|
||||||
|
<div class="list" id="roomsList"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user