package main import ( "crypto/rand" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strings" "sync" "time" "golang.org/x/crypto/bcrypt" "gopkg.in/yaml.v3" _ "github.com/mattn/go-sqlite3" ) // ============================================================ // Config // ============================================================ type AdminUser struct { Username string `yaml:"username"` PasswordHash string `yaml:"password_hash"` } type Config struct { Port int `yaml:"port"` Admin []AdminUser `yaml:"admin"` mu sync.Mutex path string } func LoadConfig(dataDir string) (*Config, error) { path := filepath.Join(dataDir, "config.yml") cfg := &Config{ Port: 47732, Admin: nil, path: path, } data, err := os.ReadFile(path) if err == nil { if err := yaml.Unmarshal(data, cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) } } return cfg, nil } func (c *Config) Save() error { c.mu.Lock() defer c.mu.Unlock() data, err := yaml.Marshal(c) if err != nil { return err } return os.WriteFile(c.path, data, 0640) } func (c *Config) SetAdmin(username, password string) error { c.mu.Lock() defer c.mu.Unlock() hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return err } user := AdminUser{Username: username, PasswordHash: string(hash)} // Replace existing or append. for i, u := range c.Admin { if u.Username == username { c.Admin[i] = user return c.saveLocked() } } c.Admin = append(c.Admin, user) return c.saveLocked() } func (c *Config) CheckAdmin(username, password string) bool { c.mu.Lock() defer c.mu.Unlock() for _, u := range c.Admin { if u.Username == username { if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil { return true } } } return false } func (c *Config) saveLocked() error { data, err := yaml.Marshal(c) if err != nil { return err } return os.WriteFile(c.path, data, 0640) } // ============================================================ // Token // ============================================================ type tokenStore struct { mu sync.Mutex tokens map[string]time.Time } func newTokenStore() *tokenStore { return &tokenStore{tokens: make(map[string]time.Time)} } func (ts *tokenStore) Create() string { ts.mu.Lock() defer ts.mu.Unlock() b := make([]byte, 16) rand.Read(b) tok := hex.EncodeToString(b) ts.tokens[tok] = time.Now().Add(24 * time.Hour) return tok } func (ts *tokenStore) Check(tok string) bool { ts.mu.Lock() defer ts.mu.Unlock() exp, ok := ts.tokens[tok] if !ok { return false } if time.Now().After(exp) { delete(ts.tokens, tok) return false } return true } // ============================================================ // Server DB schema // ============================================================ const serverSchema = ` CREATE TABLE IF NOT EXISTS server_devices ( id TEXT PRIMARY KEY, name TEXT NOT NULL, api_key TEXT NOT NULL UNIQUE, last_seen TEXT, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS server_revisions ( rev INTEGER PRIMARY KEY AUTOINCREMENT, op_id TEXT NOT NULL, device_id TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS server_ops ( op_id TEXT PRIMARY KEY, device_id TEXT NOT NULL, entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, op_type TEXT NOT NULL, payload_json TEXT NOT NULL, created_at TEXT NOT NULL, pushed_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS server_blobs ( sha256 TEXT PRIMARY KEY, size INTEGER NOT NULL, created_at TEXT NOT NULL ); ` // ============================================================ // Server // ============================================================ type Server struct { db *sql.DB cfg *Config tokens *tokenStore blobsDir string mux *http.ServeMux } func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) { db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?mode=rwc", dbPath)) if err != nil { return nil, fmt.Errorf("open db: %w", err) } db.SetMaxOpenConns(1) // Run schema. for _, stmt := range strings.Split(serverSchema, ";") { stmt = strings.TrimSpace(stmt) if stmt == "" { continue } if _, err := db.Exec(stmt); err != nil { db.Close() return nil, fmt.Errorf("schema: %w", err) } } blobsDir := filepath.Join(dataDir, "blobs") if err := os.MkdirAll(blobsDir, 0750); err != nil { db.Close() return nil, err } s := &Server{ db: db, cfg: cfg, tokens: newTokenStore(), blobsDir: blobsDir, } s.mux = s.routes() return s, nil } func (s *Server) Close() error { return s.db.Close() } func (s *Server) ListenAndServe(addr string) error { return http.ListenAndServe(addr, s.mux) } // ============================================================ // Routes // ============================================================ func (s *Server) routes() *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("/api/v1/health", s.handleHealth) mux.HandleFunc("/api/v1/device/register", s.handleDeviceRegister) mux.HandleFunc("/api/v1/sync/push", s.handleSyncPush) mux.HandleFunc("/api/v1/sync/pull", s.handleSyncPull) mux.HandleFunc("/api/v1/blobs/", s.handleBlobs) mux.HandleFunc("/admin/login", s.handleAdminLogin) mux.HandleFunc("/admin/dashboard", s.handleAdminDashboard) mux.HandleFunc("/admin/", s.handleAdminAPI) mux.HandleFunc("/", s.handleNotFound) return mux } // ============================================================ // Helpers // ============================================================ func jsonOK(w http.ResponseWriter, v interface{}) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(v) } func jsonErr(w http.ResponseWriter, code int, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(map[string]string{"error": msg}) } func (s *Server) requireAPIKey(w http.ResponseWriter, r *http.Request) bool { key := r.Header.Get("Authorization") key = strings.TrimPrefix(key, "Bearer ") if key == "" { key = r.URL.Query().Get("api_key") } if key == "" { jsonErr(w, 401, "API key required") return false } var count int err := s.db.QueryRow("SELECT COUNT(*) FROM server_devices WHERE api_key=?", key).Scan(&count) if err != nil || count == 0 { jsonErr(w, 401, "invalid API key") return false } return true } func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool { cookie, err := r.Cookie("session") if err != nil || !s.tokens.Check(cookie.Value) { http.Redirect(w, r, "/admin/login", http.StatusFound) return false } return true } // ============================================================ // Handlers // ============================================================ func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Write([]byte("Verstak Sync Server\n")) return } jsonErr(w, 404, "not found") } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { jsonOK(w, map[string]interface{}{ "status": "ok", "version": "verstak-server/v1", "time": time.Now().UTC().Format(time.RFC3339), }) } func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { jsonErr(w, 405, "POST required") return } var req struct { Name string `json:"name"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, 400, "invalid JSON") return } if req.Name == "" { jsonErr(w, 400, "name required") return } b := make([]byte, 20) rand.Read(b) apiKey := hex.EncodeToString(b) now := time.Now().UTC().Format(time.RFC3339) result, err := s.db.Exec( "INSERT INTO server_devices (id, name, api_key, last_seen, created_at) VALUES (?, ?, ?, ?, ?)", apiKey[:12], req.Name, apiKey, now, now, ) if err != nil { jsonErr(w, 500, err.Error()) return } id, _ := result.LastInsertId() _ = id jsonOK(w, map[string]interface{}{ "device_id": apiKey[:12], "api_key": apiKey, }) } func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) { if !s.requireAPIKey(w, r) { return } jsonOK(w, map[string]string{"status": "ok", "message": "push endpoint ready (not yet implemented)"}) } func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) { if !s.requireAPIKey(w, r) { return } jsonOK(w, map[string]string{"status": "ok", "message": "pull endpoint ready (not yet implemented)"}) } func (s *Server) handleBlobs(w http.ResponseWriter, r *http.Request) { if !s.requireAPIKey(w, r) { return } switch r.Method { case "POST": // Upload: accept multipart file, store by SHA-256. if err := r.ParseMultipartForm(200 << 20); err != nil { jsonErr(w, 400, "multipart error: "+err.Error()) return } file, header, err := r.FormFile("file") if err != nil { jsonErr(w, 400, "file field required") return } defer file.Close() // Read content and compute SHA-256. data, err := io.ReadAll(file) if err != nil { jsonErr(w, 500, "read error") return } hash := sha256.Sum256(data) shaHex := hex.EncodeToString(hash[:]) // Store at blobs/ab/cd/sha256. blobDir := filepath.Join(s.blobsDir, shaHex[:2], shaHex[2:4]) if err := os.MkdirAll(blobDir, 0750); err != nil { jsonErr(w, 500, "mkdir error") return } blobPath := filepath.Join(blobDir, shaHex) if err := os.WriteFile(blobPath, data, 0640); err != nil { jsonErr(w, 500, "write error") return } _ = header // Record in blobs table. now := time.Now().UTC().Format(time.RFC3339) s.db.Exec("INSERT OR IGNORE INTO server_blobs (sha256, size, created_at) VALUES (?, ?, ?)", shaHex, len(data), now) jsonOK(w, map[string]interface{}{ "sha256": shaHex, "size": len(data), }) case "GET": // Download: GET /api/v1/blobs/{sha256} shaHex := strings.TrimPrefix(r.URL.Path, "/api/v1/blobs/") if len(shaHex) != 64 { jsonErr(w, 400, "invalid SHA-256") return } blobPath := filepath.Join(s.blobsDir, shaHex[:2], shaHex[2:4], shaHex) if _, err := os.Stat(blobPath); os.IsNotExist(err) { jsonErr(w, 404, "blob not found") return } data, err := os.ReadFile(blobPath) if err != nil { jsonErr(w, 500, "read error") return } w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Disposition", "attachment; filename=\""+shaHex+"\"") w.Write(data) default: jsonErr(w, 405, "method not allowed") } } func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(adminLoginHTML)) case "POST": if err := r.ParseForm(); err != nil { jsonErr(w, 400, "bad form") return } user := r.FormValue("username") pass := r.FormValue("password") if !s.cfg.CheckAdmin(user, pass) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(401) w.Write([]byte("