feat: implement security, discovery, history, file transfer, web UI (P0-P3)
This commit is contained in:
+259
-4
@@ -1,35 +1,77 @@
|
||||
// Package server provides the HTTP endpoint that receives clipboard payloads
|
||||
// from remote clip-sync peers.
|
||||
// from remote clip-sync peers, plus a small local monitoring UI.
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"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 addr.
|
||||
func New(addr string, cb clipboard.Clipboard, filter *dedup.Filter) *Server {
|
||||
// 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: addr,
|
||||
Addr: opts.Addr,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
@@ -41,17 +83,36 @@ 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)
|
||||
@@ -63,6 +124,14 @@ func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
@@ -75,5 +144,191 @@ func (s *Server) handleClip(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
s.filter.MarkWritten(p.Text)
|
||||
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 {
|
||||
if err := s.opts.Notifier.Notify("clip-sync", "Clip reçu de "+p.Origin); err != nil {
|
||||
log.Printf("server: notify: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
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>`
|
||||
|
||||
Reference in New Issue
Block a user