Add optional sync between Macs with a shared 8-digit code
Notes and photos were stuck on one machine. A small Go/Postgres backend
stores each group's payload under a code that doubles as the shared
secret, so a second Mac just needs the code — no account or login.
Applying a remote change must not push it straight back, otherwise the
two Macs would bump the version back and forth forever, so payloads are
fingerprinted and identical ones are skipped.
💘 Generated with Crush
Assisted-by: Crush:kimi-k3
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
module dev.tzgyn.prompt/server
|
||||
|
||||
go 1.26
|
||||
|
||||
require github.com/lib/pq v1.10.9
|
||||
@@ -0,0 +1,2 @@
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
// Prompt sync server. No authentication: a sync group is identified by an
|
||||
// 8-digit code (1234-5678) that acts as the shared secret. Whoever has the
|
||||
// code can read and overwrite the group's data.
|
||||
//
|
||||
// Endpoints:
|
||||
//
|
||||
// POST /api/codes -> {"code":"1234-5678"} (create group)
|
||||
// GET /api/sync/{code} -> {"version":N,"data":{...}} (404 if unknown)
|
||||
// PUT /api/sync/{code} -> {"version":N+1} (upsert payload)
|
||||
// GET /healthz -> 200
|
||||
//
|
||||
// Config:
|
||||
//
|
||||
// PROMPT_SYNC_DATABASE_URL (default postgres://localhost:5432/prompt_sync?sslmode=disable)
|
||||
// PROMPT_SYNC_ADDR (default :8080)
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS sync_groups (
|
||||
code TEXT PRIMARY KEY,
|
||||
version BIGINT NOT NULL DEFAULT 0,
|
||||
data JSONB,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`
|
||||
|
||||
var codePattern = regexp.MustCompile(`^\d{4}-\d{4}$`)
|
||||
|
||||
type Server struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func main() {
|
||||
dsn := os.Getenv("PROMPT_SYNC_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
dsn = "postgres://localhost:5432/prompt_sync?sslmode=disable"
|
||||
}
|
||||
addr := os.Getenv("PROMPT_SYNC_ADDR")
|
||||
if addr == "" {
|
||||
addr = ":8080"
|
||||
}
|
||||
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
log.Fatalf("open database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := db.Ping(); err != nil {
|
||||
log.Fatalf("connect to database: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
log.Fatalf("ensure schema: %v", err)
|
||||
}
|
||||
|
||||
s := &Server{db: db}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/codes", s.createCode)
|
||||
mux.HandleFunc("GET /api/sync/{code}", s.getSync)
|
||||
mux.HandleFunc("PUT /api/sync/{code}", s.putSync)
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
log.Printf("prompt-sync listening on %s", addr)
|
||||
log.Fatal(http.ListenAndServe(addr, mux))
|
||||
}
|
||||
|
||||
// createCode generates a unique 8-digit code, formats it as 1234-5678, and
|
||||
// inserts an empty group so the code exists before the first PUT.
|
||||
func (s *Server) createCode(w http.ResponseWriter, _ *http.Request) {
|
||||
for range 10 {
|
||||
code, err := newCode()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not generate code")
|
||||
return
|
||||
}
|
||||
var exists bool
|
||||
err = s.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM sync_groups WHERE code = $1)`, code).Scan(&exists)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
if _, err := s.db.Exec(`INSERT INTO sync_groups (code) VALUES ($1)`, code); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"code": code})
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "could not find a free code")
|
||||
}
|
||||
|
||||
func (s *Server) getSync(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.PathValue("code")
|
||||
if !codePattern.MatchString(code) {
|
||||
writeError(w, http.StatusBadRequest, "code must look like 1234-5678")
|
||||
return
|
||||
}
|
||||
var (
|
||||
version int64
|
||||
data []byte
|
||||
updatedAt time.Time
|
||||
)
|
||||
err := s.db.QueryRow(
|
||||
`SELECT version, data, updated_at FROM sync_groups WHERE code = $1`, code,
|
||||
).Scan(&version, &data, &updatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
writeError(w, http.StatusNotFound, "unknown code")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
if data == nil {
|
||||
data = []byte("null")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"version":%d,"updatedAt":%q,"data":%s}`,
|
||||
version, updatedAt.UTC().Format(time.RFC3339), data)
|
||||
}
|
||||
|
||||
func (s *Server) putSync(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.PathValue("code")
|
||||
if !codePattern.MatchString(code) {
|
||||
writeError(w, http.StatusBadRequest, "code must look like 1234-5678")
|
||||
return
|
||||
}
|
||||
body := http.MaxBytesReader(w, r.Body, 50<<20) // 50 MB ceiling (images inside)
|
||||
defer body.Close()
|
||||
var payload json.RawMessage
|
||||
if err := json.NewDecoder(body).Decode(&payload); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "body must be JSON")
|
||||
return
|
||||
}
|
||||
var version int64
|
||||
err := s.db.QueryRow(`
|
||||
INSERT INTO sync_groups (code, version, data, updated_at)
|
||||
VALUES ($1, 1, $2, now())
|
||||
ON CONFLICT (code) DO UPDATE
|
||||
SET version = sync_groups.version + 1, data = $2, updated_at = now()
|
||||
RETURNING version`, code, payload).Scan(&version)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int64{"version": version})
|
||||
}
|
||||
|
||||
func newCode() (string, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
digits := make([]byte, 8)
|
||||
for i, v := range b {
|
||||
digits[i] = '0' + v%10
|
||||
}
|
||||
return fmt.Sprintf("%s-%s", digits[:4], digits[4:]), nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
Reference in New Issue
Block a user