368 lines
10 KiB
Go
368 lines
10 KiB
Go
// 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 = `<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>clip-sync</title>
|
|
<style>
|
|
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }
|
|
h1 { font-size: 1.4rem; }
|
|
.card { background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }
|
|
.kv { display: flex; justify-content: space-between; padding: .2rem 0; }
|
|
ul { list-style: none; padding: 0; margin: 0; }
|
|
li { padding: .35rem .5rem; border-bottom: 1px solid #eaeef2; font-family: monospace; font-size: .85rem; word-break: break-all; }
|
|
.origin { color: #0969da; font-weight: 600; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>clip-sync — monitoring</h1>
|
|
<div class="card">
|
|
<div class="kv"><span>État</span><strong id="status">…</strong></div>
|
|
<div class="kv"><span>Clips reçus</span><span id="received">…</span></div>
|
|
<div class="kv"><span>Dernier clip</span><span id="last">…</span></div>
|
|
</div>
|
|
<div class="card">
|
|
<h2>Historique récent</h2>
|
|
<ul id="history"><li>Chargement…</li></ul>
|
|
</div>
|
|
<script>
|
|
async function refresh() {
|
|
try {
|
|
const h = await (await fetch('/health')).json();
|
|
document.getElementById('status').textContent = h.ok ? 'OK' : 'ERREUR';
|
|
document.getElementById('received').textContent = h.stats.received_count;
|
|
document.getElementById('last').textContent = h.stats.last_received_by
|
|
? (h.stats.last_received_by + ' (' + h.stats.last_received_len + ' octets)') : '—';
|
|
} catch (e) { document.getElementById('status').textContent = 'indisponible'; }
|
|
|
|
try {
|
|
const entries = await (await fetch('/history')).json();
|
|
const ul = document.getElementById('history');
|
|
ul.innerHTML = '';
|
|
[...entries].reverse().slice(0, 20).forEach(e => {
|
|
const li = document.createElement('li');
|
|
li.innerHTML = '<span class="origin">' + escapeHtml(e.origin || 'local') + '</span> ' + escapeHtml(e.text);
|
|
ul.appendChild(li);
|
|
});
|
|
} catch (e) {}
|
|
}
|
|
function escapeHtml(s) { return s.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
|
refresh();
|
|
setInterval(refresh, 3000);
|
|
</script>
|
|
</body>
|
|
</html>`
|