Files

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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
refresh();
setInterval(refresh, 3000);
</script>
</body>
</html>`