// Package server provides the HTTP endpoint that receives clipboard payloads // from remote clip-sync peers, plus a small local monitoring UI. package server import ( "crypto/subtle" "encoding/base64" "encoding/json" "fmt" "log" "net/http" "sync" "time" "git.dracodev.net/Projets/clip-sync/internal/clipboard" "git.dracodev.net/Projets/clip-sync/internal/dedup" "git.dracodev.net/Projets/clip-sync/internal/history" "git.dracodev.net/Projets/clip-sync/internal/notify" "git.dracodev.net/Projets/clip-sync/internal/transfer" ) // Options configures a Server. type Options struct { Addr string SharedKey string AllowedOrigins []string MaxBodyBytes int64 History *history.Store Notifier notify.Notifier ReceiveFiles bool ReceiveDir string } // Stats holds lightweight counters exposed via the monitoring UI. type Stats struct { StartedAt time.Time `json:"started_at"` ReceivedCount int64 `json:"received_count"` LastReceivedAt time.Time `json:"last_received_at"` LastReceivedBy string `json:"last_received_by"` LastReceivedLen int `json:"last_received_len"` } // Server receives clips from peers and writes them to the local clipboard. type Server struct { httpServer *http.Server clipboard clipboard.Clipboard filter *dedup.Filter opts Options allowed map[string]struct{} mu sync.Mutex stats Stats } // New creates a Server that listens on opts.Addr. func New(opts Options, cb clipboard.Clipboard, filter *dedup.Filter) *Server { s := &Server{ clipboard: cb, filter: filter, opts: opts, allowed: make(map[string]struct{}), stats: Stats{StartedAt: time.Now()}, } for _, o := range opts.AllowedOrigins { s.allowed[o] = struct{}{} } mux := http.NewServeMux() mux.HandleFunc("/clip", s.handleClip) mux.HandleFunc("/file", s.handleFile) mux.HandleFunc("/health", s.handleHealth) mux.HandleFunc("/history", s.handleHistory) mux.HandleFunc("/", s.handleIndex) s.httpServer = &http.Server{ Addr: opts.Addr, Handler: mux, } return s } // ListenAndServe starts the HTTP server. It blocks until the server is stopped. func (s *Server) ListenAndServe() error { return s.httpServer.ListenAndServe() } // ListenAndServeTLS starts the HTTPS server with the given cert/key files. func (s *Server) ListenAndServeTLS(certFile, keyFile string) error { return s.httpServer.ListenAndServeTLS(certFile, keyFile) } // Close gracefully shuts down the HTTP server. func (s *Server) Close() error { return s.httpServer.Close() } // Stats returns a snapshot of the server statistics. func (s *Server) Stats() Stats { s.mu.Lock() defer s.mu.Unlock() return s.stats } func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if !s.authorized(w, r) { return } // Bound the request body to prevent memory-exhaustion / DoS. r.Body = http.MaxBytesReader(w, r.Body, s.opts.MaxBodyBytes) var p dedup.Payload if err := json.NewDecoder(r.Body).Decode(&p); err != nil { http.Error(w, "bad request: invalid JSON", http.StatusBadRequest) return } isImage := p.Data != "" if p.Text == "" && !isImage { http.Error(w, "bad request: empty content", http.StatusBadRequest) return } // Origin validation: reject payloads from unknown origins if configured. if len(s.allowed) > 0 { if _, ok := s.allowed[p.Origin]; !ok { http.Error(w, "forbidden: unknown origin", http.StatusForbidden) return } } if s.filter.ShouldIgnore(p) { w.WriteHeader(http.StatusNoContent) return } if isImage { if err := s.writeImage(p); err != nil { log.Printf("server: image write: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } } else { if err := s.clipboard.Write(p.Text); err != nil { log.Printf("server: clipboard write: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } } s.filter.MarkWrittenPayload(p) s.recordReceive(p) if s.opts.History != nil { s.opts.History.Append(history.Entry{Text: p.Text, Origin: p.Origin, Ts: p.Ts}) } if s.opts.Notifier != nil { msg := "Clip reçu de " + p.Origin if isImage { msg = "Image reçue de " + p.Origin } if err := s.opts.Notifier.Notify("clip-sync", msg); err != nil { log.Printf("server: notify: %v", err) } } w.WriteHeader(http.StatusNoContent) } // writeImage decodes and writes an image payload to the clipboard. It requires // the clipboard implementation to support ImageClipboard. func (s *Server) writeImage(p dedup.Payload) error { ic, ok := s.clipboard.(clipboard.ImageClipboard) if !ok { return fmt.Errorf("image clipboard not supported on this platform") } data, err := base64.StdEncoding.DecodeString(p.Data) if err != nil { return fmt.Errorf("decode image: %w", err) } mime := p.Mime if mime == "" { mime = "image/png" } return ic.WriteImage(mime, data) } func (s *Server) recordReceive(p dedup.Payload) { s.mu.Lock() defer s.mu.Unlock() s.stats.ReceivedCount++ s.stats.LastReceivedAt = time.Now() s.stats.LastReceivedBy = p.Origin s.stats.LastReceivedLen = len(p.Text) } // authorized checks the shared-key bearer token (constant-time comparison). // It writes the error response and returns false when authentication fails. func (s *Server) authorized(w http.ResponseWriter, r *http.Request) bool { if s.opts.SharedKey == "" { return true } got := r.Header.Get("Authorization") const prefix = "Bearer " if len(got) < len(prefix) || !equalFoldSubtle(got[:len(prefix)], prefix) { http.Error(w, "unauthorized", http.StatusUnauthorized) return false } token := got[len(prefix):] if subtle.ConstantTimeCompare([]byte(token), []byte(s.opts.SharedKey)) != 1 { http.Error(w, "unauthorized", http.StatusUnauthorized) return false } return true } // handleFile receives a binary payload and, if enabled, writes it to disk. func (s *Server) handleFile(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if !s.authorized(w, r) { return } if !s.opts.ReceiveFiles { http.Error(w, "file reception disabled", http.StatusForbidden) return } r.Body = http.MaxBytesReader(w, r.Body, s.opts.MaxBodyBytes) var f transfer.File if err := json.NewDecoder(r.Body).Decode(&f); err != nil { http.Error(w, "bad request: invalid JSON", http.StatusBadRequest) return } if f.Data == "" || f.Name == "" { http.Error(w, "bad request: missing name or data", http.StatusBadRequest) return } if len(s.allowed) > 0 { if _, ok := s.allowed[f.Origin]; !ok { http.Error(w, "forbidden: unknown origin", http.StatusForbidden) return } } dest, err := f.SaveTo(s.opts.ReceiveDir) if err != nil { log.Printf("server: file save: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } log.Printf("server: received file %q from %q -> %s", f.Name, f.Origin, dest) w.WriteHeader(http.StatusNoContent) } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "ok": true, "stats": s.Stats(), }) } func (s *Server) handleHistory(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if s.opts.History == nil { _ = json.NewEncoder(w).Encode([]history.Entry{}) return } _ = json.NewEncoder(w).Encode(s.opts.History.List()) } func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(indexHTML)) } // equalFoldSubtle is a constant-time ASCII case-insensitive comparison. It is // only used to match the "Bearer " scheme prefix, so a length difference is // acceptable to leak. func equalFoldSubtle(a, b string) bool { if len(a) != len(b) { return false } for i := 0; i < len(a); i++ { ca, cb := a[i], b[i] if ca >= 'A' && ca <= 'Z' { ca += 'a' - 'A' } if cb >= 'A' && cb <= 'Z' { cb += 'a' - 'A' } if ca != cb { return false } } return true } const indexHTML = ` clip-sync

clip-sync — monitoring

État…
Clips reçus…
Dernier clip…

Historique récent

`