Compare commits

..

18 Commits

Author SHA1 Message Date
mirivlad 99e47fcb17 feat: add user registration web form at /register 2026-06-01 23:46:25 +08:00
mirivlad 0ef54c31f8 feat: user web GUI — login, dashboard with devices/keys, logout 2026-06-01 23:40:48 +08:00
mirivlad b3662d4876 test: update smoke test for user auth flow 2026-06-01 23:36:38 +08:00
mirivlad f8dc436709 feat: client auth — login/password flow, auto device reg, sync interval + improved sync UI 2026-06-01 23:36:19 +08:00
mirivlad 241a9d8c06 feat: user registration, email confirmation, login, device management + SMTP config UI in admin panel 2026-06-01 23:33:58 +08:00
mirivlad 5db3da3618 fix: protect device register with admin auth; improve admin UI (full API key, copy button, styling) 2026-06-01 23:22:19 +08:00
mirivlad e828ebd44e docs: add sync server installation and usage guide 2026-06-01 23:13:59 +08:00
mirivlad 84c0bcbcab test: add E2E smoke test for sync 2026-06-01 23:07:24 +08:00
mirivlad a1a50863c5 gui: add sync settings panel in Svelte 2026-06-01 22:58:12 +08:00
mirivlad 1abe8c4fa0 cli: add sync push/pull/status commands 2026-06-01 22:56:05 +08:00
mirivlad 5b2cec5bcc sync: fix SyncStatus binding — remove invalid type assertion, use config for device ID 2026-06-01 22:55:50 +08:00
mirivlad 1a20edac44 feat: sync — client ops recording in core services
- internal/core/sync/: Service, Client, Blob packages

- RecordOp creates sync_ops entries for all mutations

- Client for push/pull/blob HTTP to server

- Blob SHA-256 hashing and local storage

- Wired into app.go alongside activity recording

- Device ID from config or fallback
2026-06-01 22:54:23 +08:00
mirivlad ad684eb118 feat: sync — push/pull API endpoints
- POST /api/v1/sync/push — accepts ops, assigns revisions, returns accepted list

- POST /api/v1/sync/pull — returns ops since given revision with server_revision
2026-06-01 22:51:30 +08:00
mirivlad 10c6d06e38 feat: sync — blob upload/download with SHA-256 storage
- POST /api/v1/blobs/ — multipart upload, stored as blobs/ab/cd/sha256

- GET /api/v1/blobs/{sha256} — download by hash

- server_blobs table for tracking stored blobs
2026-06-01 22:50:38 +08:00
mirivlad ec928e3be6 feat: sync — systemd unit and install.sh for server deployment
- verstak-server.service — systemd unit with sandboxing, configurable port via env

- install.sh — creates user, installs binary, sets up admin, enables service

  Usage: sudo ./install.sh --admin-user admin --admin-pass secret [--port 47732]
2026-06-01 22:49:40 +08:00
mirivlad c5e0060fee chore: add verstak-server to gitignore 2026-06-01 22:49:10 +08:00
mirivlad 834b5ef0d4 feat: sync — server skeleton with health, admin login/dashboard, device registration
- cmd/verstak-server/main.go — flags: --port, --data, --admin-user, --admin-pass

- Server DB schema: server_devices, server_revisions, server_ops

- Health endpoint GET /api/v1/health

- Admin login page + session cookie auth

- Admin dashboard with device stats and API key management

- Device registration POST /api/v1/device/register

- Stub push/pull/blob endpoints
2026-06-01 22:49:02 +08:00
mirivlad 4145b4d74a feat: sync — migration 010 for sync_ops and sync_state tables 2026-06-01 22:45:12 +08:00
18 changed files with 2986 additions and 3 deletions

1
.gitignore vendored
View File

@ -24,6 +24,7 @@ frontend/node_modules/
frontend/bindings/
/verstak-gui
/verstak-cli
/verstak-server
# Vault data
.verstak/

View File

@ -14,12 +14,14 @@ import (
"verstak/internal/core/actions"
"verstak/internal/core/activity"
"verstak/internal/core/config"
"verstak/internal/core/files"
"verstak/internal/core/notes"
"verstak/internal/core/nodes"
"verstak/internal/core/plugins"
"verstak/internal/core/search"
"verstak/internal/core/storage"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/worklog"
)
@ -37,6 +39,7 @@ type App struct {
worklog *worklog.Service
search *search.Service
plugins *plugins.Manager
sync *syncsvc.Service
vault string
}
@ -386,6 +389,7 @@ func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, e
return nil, err
}
_ = a.activity.Record(n.ID, activity.TargetNode, n.ID, "", activity.TypeNodeCreated, title, "")
_ = a.sync.RecordOp(syncsvc.EntityNode, n.ID, syncsvc.OpCreate, map[string]string{"title": title})
dto := toNodeDTO(n)
return &dto, nil
}
@ -480,6 +484,7 @@ func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) {
return nil, err
}
_ = a.activity.Record(parentID, activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, "")
_ = a.sync.RecordOp(syncsvc.EntityNote, node.ID, syncsvc.OpCreate, map[string]string{"title": title})
dto := toNodeDTO(node)
return &dto, nil
}
@ -501,6 +506,7 @@ func (a *App) SaveNote(noteID, content string) error {
pid = *n.ParentID
}
_ = a.activity.Record(pid, activity.TargetNote, noteID, "", activity.TypeNoteUpdated, n.Title, "")
_ = a.sync.RecordOp(syncsvc.EntityNote, noteID, syncsvc.OpUpdate, map[string]string{"title": n.Title})
}
return nil
}
@ -573,6 +579,7 @@ func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) {
}
for _, n := range nodes {
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, map[string]string{"title": n.Title})
}
return toNodeDTOs(nodes), nil
}
@ -584,6 +591,7 @@ func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
}
for _, n := range nodes {
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, map[string]string{"title": n.Title})
}
return toNodeDTOs(nodes), nil
}
@ -602,6 +610,11 @@ func (a *App) DeleteFileOrFolder(nodeID string) error {
targetType = activity.TargetFolder
}
_ = a.activity.Record(pid, targetType, nodeID, "", evType, n.Title, "")
syncEntity := syncsvc.EntityFile
if n.Type == nodes.TypeFolder {
syncEntity = syncsvc.EntityFolder
}
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpDelete, nil)
}
return a.files.DeleteNodeAndChildren(nodeID)
}
@ -612,6 +625,7 @@ func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) {
return nil, err
}
_ = a.activity.Record(parentID, activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, "")
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, map[string]string{"title": filename})
dto := toNodeDTO(node)
return &dto, nil
}
@ -628,6 +642,7 @@ func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) {
pid = *n.ParentID
}
_ = a.activity.Record(pid, activity.TargetFile, node.ID, "", activity.TypeFileCopied, node.Title, "")
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, map[string]string{"title": node.Title})
dto := toNodeDTO(node)
return &dto, nil
}
@ -652,6 +667,11 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
targetType = activity.TargetFolder
}
_ = a.activity.Record(pid, targetType, nodeID, "", evType, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`)
syncEntity := syncsvc.EntityFile
if n.Type == nodes.TypeFolder {
syncEntity = syncsvc.EntityFolder
}
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpUpdate, map[string]string{"title": newTitle})
return nil
}
@ -687,6 +707,7 @@ func (a *App) MoveNode(nodeID, newParentID string) error {
pid = *node.ParentID
}
_ = a.activity.Record(pid, activity.TargetFile, nodeID, "", activity.TypeFileMoved, node.Title, `{"to":"`+newParentID+`"}`)
_ = a.sync.RecordOp(syncsvc.EntityFile, nodeID, syncsvc.OpMove, map[string]string{"title": node.Title})
return nil
}
@ -725,6 +746,7 @@ func (a *App) CreateAction(nodeID, kind, title, data string) (*ActionDTO, error)
if err != nil {
return nil, err
}
_ = a.sync.RecordOp(syncsvc.EntityAction, rec.ID, syncsvc.OpCreate, map[string]string{"title": rec.Title, "kind": rec.Kind})
return &ActionDTO{
ID: rec.ID,
NodeID: rec.NodeID,
@ -735,6 +757,7 @@ func (a *App) CreateAction(nodeID, kind, title, data string) (*ActionDTO, error)
}
func (a *App) DeleteAction(id string) error {
_ = a.sync.RecordOp(syncsvc.EntityAction, id, syncsvc.OpDelete, nil)
return a.actions.Delete(id)
}
@ -774,6 +797,7 @@ func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, e
if err != nil {
return nil, err
}
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, map[string]string{"summary": summary})
mins := 0
if entry.Minutes != nil {
mins = *entry.Minutes
@ -812,6 +836,122 @@ func (a *App) Search(query string) ([]SearchResultDTO, error) {
return out, nil
}
// ============================================================
// Sync
// ============================================================
type SyncStatusDTO struct {
Configured bool `json:"configured"`
ServerURL string `json:"serverUrl"`
DeviceID string `json:"deviceId"`
UnpushedOps int `json:"unpushedOps"`
LastSyncAt string `json:"lastSyncAt"`
SyncInterval int `json:"syncInterval"`
}
func (a *App) SyncStatus() (*SyncStatusDTO, error) {
serverURL, apiKey, _, lastSyncAt, err := a.sync.GetState()
if err != nil {
return &SyncStatusDTO{}, nil
}
unpushed, _ := a.sync.GetUnpushedOps()
cfg, _ := config.Load(a.vault)
dto := &SyncStatusDTO{
Configured: serverURL != "" && apiKey != "",
ServerURL: serverURL,
UnpushedOps: len(unpushed),
LastSyncAt: lastSyncAt,
}
if cfg != nil {
dto.DeviceID = cfg.Sync.DeviceID
dto.SyncInterval = cfg.Sync.SyncInterval
}
return dto, nil
}
func (a *App) SyncConfigure(serverURL, username, password string) error {
// Register device on server with user credentials.
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "unknown"
}
client := syncsvc.NewClient(serverURL, "", "", a.vault)
deviceID, apiKey, err := client.RegisterDeviceWithAuth(hostname, username, password)
if err != nil {
return fmt.Errorf("register: %w", err)
}
if err := a.sync.SetState(serverURL, apiKey); err != nil {
return err
}
// Persist to vault config.
cfg, err := config.Load(a.vault)
if err != nil {
return err
}
cfg.Sync.ServerURL = serverURL
cfg.Sync.APIKey = apiKey
cfg.Sync.DeviceID = deviceID
return config.Save(a.vault, cfg)
}
func (a *App) SyncTestConnection(serverURL, username, password string) error {
client := syncsvc.NewClient(serverURL, "", "", a.vault)
_, _, err := client.RegisterDeviceWithAuth("test-connection", username, password)
return err
}
func (a *App) SyncSetInterval(minutes int) error {
cfg, err := config.Load(a.vault)
if err != nil {
return err
}
cfg.Sync.SyncInterval = minutes
return config.Save(a.vault, cfg)
}
func (a *App) SyncNow() (map[string]interface{}, error) {
serverURL, apiKey, lastRev, _, err := a.sync.GetState()
if err != nil || serverURL == "" || apiKey == "" {
return nil, fmt.Errorf("sync not configured")
}
deviceID := ""
if cfg, err := config.Load(a.vault); err == nil {
deviceID = cfg.Sync.DeviceID
}
client := syncsvc.NewClient(serverURL, apiKey, deviceID, a.vault)
// Push unpushed ops.
unpushed, err := a.sync.GetUnpushedOps()
if err != nil {
return nil, fmt.Errorf("get ops: %w", err)
}
pushResult := &syncsvc.PushResponse{}
if len(unpushed) > 0 {
pushResult, err = client.Push(unpushed)
if err != nil {
return nil, fmt.Errorf("push: %w", err)
}
if err := a.sync.MarkPushed(pushResult.Accepted); err != nil {
return nil, fmt.Errorf("mark pushed: %w", err)
}
}
// Pull remote ops.
pullResult, err := client.Pull(lastRev)
if err != nil {
return nil, fmt.Errorf("pull: %w", err)
}
return map[string]interface{}{
"pushed": len(pushResult.Accepted),
"pulled": len(pullResult.Ops),
"serverRevision": pullResult.ServerRevision,
}, nil
}
// ============================================================
// File Dialogs (Wails v2 Runtime)
// ============================================================

View File

@ -8,12 +8,14 @@ import (
"verstak/internal/core/actions"
"verstak/internal/core/activity"
"verstak/internal/core/config"
"verstak/internal/core/files"
"verstak/internal/core/notes"
"verstak/internal/core/nodes"
"verstak/internal/core/plugins"
"verstak/internal/core/search"
"verstak/internal/core/storage"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/worklog"
"github.com/wailsapp/wails/v2"
@ -53,6 +55,16 @@ func main() {
pm := plugins.NewManager(abs)
pm.Discover()
// Sync service — use configured device ID or vault ID as fallback.
deviceID := ""
if cfg, err := config.Load(abs); err == nil {
deviceID = cfg.Sync.DeviceID
}
if deviceID == "" {
deviceID = "gui-" + abs[:8]
}
syncSvc := syncsvc.NewService(db, deviceID)
app := &App{
db: db,
nodes: nodeRepo,
@ -63,6 +75,7 @@ func main() {
worklog: worklogSvc,
search: searchSvc,
plugins: pm,
sync: syncSvc,
vault: abs,
}

110
cmd/verstak-server/install.sh Executable file
View File

@ -0,0 +1,110 @@
#!/bin/sh
#
# install.sh — установка Verstak Sync Server
#
# Использование:
# sudo ./install.sh --port 47732 --user verstak --admin-user admin --admin-pass secret
#
# Флаги:
# --port Порт сервера (по умолчанию: 47732)
# --user Системный пользователь (по умолчанию: verstak)
# --admin-user Логин администратора (обязательный)
# --admin-pass Пароль администратора (обязательный)
# --bin Путь к бинарнику (по умолчанию: ./verstak-server)
#
set -e
# Defaults
PORT="${VERSTAK_PORT:-47732}"
USER="verstak"
ADMIN_USER=""
ADMIN_PASS=""
BIN="./verstak-server"
# Parse flags
while [ $# -gt 0 ]; do
case "$1" in
--port) PORT="$2"; shift 2 ;;
--user) USER="$2"; shift 2 ;;
--admin-user) ADMIN_USER="$2"; shift 2 ;;
--admin-pass) ADMIN_PASS="$2"; shift 2 ;;
--bin) BIN="$2"; shift 2 ;;
*) echo "Unknown: $1"; exit 1 ;;
esac
done
if [ -z "$ADMIN_USER" ] || [ -z "$ADMIN_PASS" ]; then
echo "Usage: $0 --admin-user USER --admin-pass PASS [--port PORT] [--user USER]"
exit 1
fi
if [ "$(id -u)" -ne 0 ]; then
echo "This script must be run as root (sudo)."
exit 1
fi
echo "=== Verstak Sync Server Installation ==="
echo "Port: $PORT"
echo "User: $USER"
echo "Admin: $ADMIN_USER"
echo "Binary: $BIN"
echo ""
# 1. Create system user if not exists.
if ! id -u "$USER" >/dev/null 2>&1; then
echo "Creating user: $USER"
useradd --system --no-create-home --shell /usr/sbin/nologin "$USER"
fi
# 2. Install binary.
if [ ! -f "$BIN" ]; then
echo "Binary not found: $BIN. Build it first: go build -o $BIN ./cmd/verstak-server/"
exit 1
fi
echo "Installing binary to /usr/local/bin/verstak-server"
cp "$BIN" /usr/local/bin/verstak-server
chmod 755 /usr/local/bin/verstak-server
# 3. Create data directory.
echo "Creating /var/lib/verstak-server"
mkdir -p /var/lib/verstak-server
chown "$USER:$USER" /var/lib/verstak-server
chmod 750 /var/lib/verstak-server
# 4. Set up admin user (first run).
echo "Setting up admin user"
/usr/local/bin/verstak-server \
--port "$PORT" \
--data /var/lib/verstak-server \
--admin-user "$ADMIN_USER" \
--admin-pass "$ADMIN_PASS" &
SERVER_PID=$!
sleep 2
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
# 5. Install systemd unit.
echo "Installing systemd unit"
SERVICE_FILE="/etc/systemd/system/verstak-server.service"
cp "$(dirname "$0")/verstak-server.service" "$SERVICE_FILE"
chmod 644 "$SERVICE_FILE"
# Set port in environment file.
mkdir -p /etc/verstak-server
echo "VERSTAK_PORT=$PORT" > /etc/verstak-server/env
# 6. Enable and start.
echo "Enabling and starting service"
systemctl daemon-reload
systemctl enable verstak-server
systemctl start verstak-server
echo ""
echo "=== Installation complete ==="
echo "Service: verstak-server"
echo "Port: $PORT"
echo "Admin: http://localhost:$PORT/admin/login"
echo ""
echo "Check status: systemctl status verstak-server"
echo "View logs: journalctl -u verstak-server -f"

View File

@ -0,0 +1,53 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"path/filepath"
)
func main() {
port := flag.Int("port", 47732, "HTTP port")
dataDir := flag.String("data", "./server-data", "Data directory (db, blobs, config)")
adminUser := flag.String("admin-user", "", "Create admin user (first run)")
adminPass := flag.String("admin-pass", "", "Admin password (first run)")
flag.Parse()
absData, err := filepath.Abs(*dataDir)
if err != nil {
log.Fatalf("data dir: %v", err)
}
if err := os.MkdirAll(absData, 0750); err != nil {
log.Fatalf("create data dir: %v", err)
}
cfg, err := LoadConfig(absData)
if err != nil {
log.Fatalf("config: %v", err)
}
// First-run admin setup.
if *adminUser != "" && *adminPass != "" {
if err := cfg.SetAdmin(*adminUser, *adminPass); err != nil {
log.Fatalf("set admin: %v", err)
}
fmt.Printf("Admin user %q created.\n", *adminUser)
}
// Open server DB.
dbPath := filepath.Join(absData, "server.db")
srv, err := NewServer(dbPath, absData, cfg)
if err != nil {
log.Fatalf("server: %v", err)
}
defer srv.Close()
addr := fmt.Sprintf(":%d", *port)
log.Printf("Verstak Sync Server starting on %s (data: %s)", addr, absData)
if err := srv.ListenAndServe(addr); err != nil {
log.Fatalf("serve: %v", err)
}
}

1569
cmd/verstak-server/server.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
[Unit]
Description=Verstak Sync Server
Documentation=https://github.com/anomalyco/verstak
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/verstak-server --port ${VERSTAK_PORT} --data /var/lib/verstak-server
Restart=on-failure
RestartSec=5
User=verstak
Group=verstak
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
StateDirectory=verstak-server
RuntimeDirectory=verstak-server
[Install]
WantedBy=multi-user.target

View File

@ -8,9 +8,11 @@ import (
"strings"
"verstak/internal/core/actions"
"verstak/internal/core/config"
"verstak/internal/core/plugins"
"verstak/internal/core/search"
"verstak/internal/core/storage"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/vault"
"verstak/internal/core/worklog"
)
@ -38,6 +40,8 @@ func main() {
runLog(os.Args[2:])
case "index":
runIndex(os.Args[2:])
case "sync":
runSync(os.Args[2:])
case "plugin":
runPlugin(os.Args[2:])
default:
@ -56,6 +60,7 @@ func usage() {
fmt.Println(" node Manage nodes")
fmt.Println(" action Manage actions")
fmt.Println(" --version Show version")
fmt.Println(" sync Sync with server (push/pull/status)")
fmt.Println(" --help Show this help")
}
@ -597,6 +602,162 @@ func runIndexRebuild(args []string) {
fmt.Printf("indexed %d nodes\n", count)
}
// --- sync ---
func runSync(args []string) {
if len(args) == 0 {
fmt.Println("verstak sync — synchronize with server")
fmt.Println()
fmt.Println("Usage: verstak sync <command> [options]")
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" push Push local changes to server")
fmt.Println(" pull Pull remote changes from server")
fmt.Println(" status Show sync status")
os.Exit(0)
}
switch args[0] {
case "push":
runSyncPush(args[1:])
case "pull":
runSyncPull(args[1:])
case "status":
runSyncStatus(args[1:])
case "--help", "-h":
runSync(nil)
default:
fmt.Fprintf(os.Stderr, "Unknown sync command: %s\n", args[0])
os.Exit(1)
}
}
func openSyncDB(args []string) (*storage.DB, string) {
vaultPath, _ := stringFlag(args, "--vault")
abs, _ := filepath.Abs(vaultPath)
dbPath := filepath.Join(abs, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Open vault: %v\n", err)
os.Exit(1)
}
return db, abs
}
func runSyncPush(args []string) {
db, abs := openSyncDB(args)
defer db.Close()
cfg, err := config.Load(abs)
if err != nil || cfg.Sync.ServerURL == "" || cfg.Sync.APIKey == "" {
fmt.Fprintln(os.Stderr, "Sync not configured. Use 'verstak sync configure' or GUI settings.")
os.Exit(1)
}
deviceID := cfg.Sync.DeviceID
if deviceID == "" {
deviceID = "cli-" + abs[:8]
}
syncSvc := syncsvc.NewService(db, deviceID)
client := syncsvc.NewClient(cfg.Sync.ServerURL, cfg.Sync.APIKey, deviceID, abs)
unpushed, err := syncSvc.GetUnpushedOps()
if err != nil {
fmt.Fprintf(os.Stderr, "Get ops: %v\n", err)
os.Exit(1)
}
if len(unpushed) == 0 {
fmt.Println("Nothing to push.")
return
}
result, err := client.Push(unpushed)
if err != nil {
fmt.Fprintf(os.Stderr, "Push failed: %v\n", err)
os.Exit(1)
}
if err := syncSvc.MarkPushed(result.Accepted); err != nil {
fmt.Fprintf(os.Stderr, "Mark pushed: %v\n", err)
os.Exit(1)
}
fmt.Printf("Pushed %d ops, accepted %d\n", len(unpushed), len(result.Accepted))
}
func runSyncPull(args []string) {
db, abs := openSyncDB(args)
defer db.Close()
cfg, err := config.Load(abs)
if err != nil || cfg.Sync.ServerURL == "" || cfg.Sync.APIKey == "" {
fmt.Fprintln(os.Stderr, "Sync not configured.")
os.Exit(1)
}
deviceID := cfg.Sync.DeviceID
if deviceID == "" {
deviceID = "cli-" + abs[:8]
}
syncSvc := syncsvc.NewService(db, deviceID)
client := syncsvc.NewClient(cfg.Sync.ServerURL, cfg.Sync.APIKey, deviceID, abs)
_, _, lastRev, _, err := syncSvc.GetState()
if err != nil {
lastRev = 0
}
result, err := client.Pull(lastRev)
if err != nil {
fmt.Fprintf(os.Stderr, "Pull failed: %v\n", err)
os.Exit(1)
}
var opIDs []string
for _, op := range result.Ops {
fmt.Printf(" %s\t%s\t%s\t%s\n", op.OpType, op.EntityType, op.EntityID, op.PayloadJSON)
opIDs = append(opIDs, op.OpID)
}
if len(opIDs) > 0 {
syncSvc.MarkApplied(opIDs)
}
fmt.Printf("Pulled %d ops (server rev: %d)\n", len(result.Ops), result.ServerRevision)
}
func runSyncStatus(args []string) {
db, abs := openSyncDB(args)
defer db.Close()
cfg, err := config.Load(abs)
configured := err == nil && cfg.Sync.ServerURL != "" && cfg.Sync.APIKey != ""
serverURL := ""
deviceID := ""
if cfg != nil {
serverURL = cfg.Sync.ServerURL
deviceID = cfg.Sync.DeviceID
}
unpushed := 0
if configured {
if deviceID == "" {
deviceID = "cli-" + abs[:8]
}
syncSvc := syncsvc.NewService(db, deviceID)
ops, _ := syncSvc.GetUnpushedOps()
unpushed = len(ops)
}
fmt.Println("Sync Status")
fmt.Println(" Configured:", configured)
fmt.Println(" Server:", serverURL)
fmt.Println(" Device:", deviceID)
fmt.Println(" Unpushed ops:", unpushed)
}
// --- plugin ---
func runPlugin(args []string) {

View File

@ -37,6 +37,7 @@
7. [[07_AI_Coder_Prompts]] — промпты для ИИ-кодера.
8. [[08_MVP_Checklist]] — чеклист первого MVP.
9. [[09_Extensibility]] — архитектура плагинов (Lua + шаблоны дел).
10. [[10_Sync_Server_Guide]] — установка и настройка сервера синхронизации.
## Главные принципы

View File

@ -0,0 +1,185 @@
# Сервер синхронизации Верстак — руководство
## 1. Зачем нужен сервер
Сервер позволяет синхронизировать данные между несколькими устройствами (например, рабочий ПК и ноутбук), а также хранить резервную копию vault'ов.
Он не обязателен — Верстак работает полностью локально и без сервера.
## 2. Установка
### Быстрая установка (systemd)
```bash
# Сборка бинарника
go build -o verstak-server ./cmd/verstak-server/
# Установка (от root)
sudo ./verstak-server/install.sh \
--port 47732 \
--admin-user admin \
--admin-pass 'мой-надёжный-пароль'
```
Скрипт:
- создаёт системного пользователя `verstak`;
- копирует бинарник в `/usr/local/bin/verstak-server`;
- создаёт `/var/lib/verstak-server` для данных;
- записывает админа в конфиг;
- устанавливает systemd-сервис `verstak-server.service`;
- запускает и включает автозапуск.
### Ручной запуск (для тестирования)
```bash
# Одной командой
./verstak-server \
--port 47732 \
--data ./server-data \
--admin-user admin \
--admin-pass 'мой-надёжный-пароль'
```
Флаги:
| Флаг | По умолчанию | Описание |
|---|---|---|
| `--port` | `47732` | Порт сервера |
| `--data` | `./data` | Директория для данных (SQLite + blobs) |
| `--admin-user` | — | Имя администратора (создаётся при первом запуске) |
| `--admin-pass` | — | Пароль администратора |
Если не указать `--admin-user` и `--admin-pass`, сервер запустится, но админ-панель будет недоступна (некому будет логиниться).
## 3. Админ-панель
### Как открыть
Откройте в браузере: `http://<сервер>:47732/admin/login`
Войдите с логином и паролем, указанными при установке.
### Что там есть
- **Дашборд** — статистика: количество устройств, количество операций, список API-ключей.
- **Управление API-ключами** — просмотр, создание и удаление ключей устройств.
Сессия живёт 24 часа. После перезапуска сервера все сессии сбрасываются (хранятся в памяти).
### Несколько админов
Можно запустить сервер с другими `--admin-user`/`--admin-pass` — добавится второй администратор. Повторный запуск с тем же именем меняет пароль.
## 4. API-ключи (устройства)
### Что такое API-ключ
API-ключ — это токен, который клиент (Верстак на вашем ноутбуке или ПК) использует для доступа к серверу. Без ключа push/pull/blobs не работают.
### Как создать
Через админ-панель:
1. Зайти в `/admin/dashboard`.
2. В разделе "API Keys" ввести имя устройства и нажать "Create".
3. Скопировать сгенерированный ключ.
Через API (требует логин и пароль администратора):
```bash
curl -X POST http://localhost:47732/api/v1/device/register \
-H "Content-Type: application/json" \
-d '{"name":"мой-ноутбук","username":"admin","password":"пароль-админа"}'
```
Ответ: `{"device_id":"...","api_key":"..."}`
**Важно:** не выставляйте сервер в интернет без HTTPS (через reverse proxy). До создания полноценной системы пользователей регистрация устройств требует учётных данных администратора.
### Как использовать
Ключ передаётся в заголовке `Authorization: Bearer <ключ>`:
```bash
curl -X POST http://localhost:47732/api/v1/sync/push \
-H "Authorization: Bearer b10c5d8e3f2a..." \
-H "Content-Type: application/json" \
-d '{"device_id":"b10c5d8e3f2a","ops":[...]}'
```
В клиенте Верстак достаточно вбить URL сервера и API-ключ в настройках (GUI Settings или `config.yml`).
### Один ключ на все устройства или отдельный на каждое?
**На каждое устройство — отдельный ключ.** Так вы сможете отозвать доступ конкретному устройству (удалить ключ в админ-панели), не затронув остальные.
Технически один и тот же ключ можно использовать на нескольких устройствах, но:
- его нельзя будет удалить, не отключив все устройства разом;
- в логах сервера все операции будут выглядеть как от одного устройства;
- это не поддерживаемый сценарий.
**Рекомендация:** создавайте отдельный ключ для каждого клиента (ПК, ноутбук, телефон).
## 5. Настройка клиента
### CLI
```bash
verstak sync push --vault /путь/к/vault
verstak sync pull --vault /путь/к/vault
verstak sync status --vault /путь/к/vault
```
Перед использованием нужно указать URL сервера и API-ключ в `.verstak/config.yml` внутри vault:
```yaml
sync:
server_url: http://мой-сервер:47732
api_key: мой-ключ
device_id: id-устройства # опционально
auto_sync: false
```
### GUI
В графическом интерфейсе нажмите на иконку шестерёнки в левом нижнем углу → откроется окно настроек синхронизации. Укажите URL сервера и API-ключ, нажмите "Сохранить". Кнопка "Синхронизировать" запускает push + pull.
## 6. Безопасность
Сервер **не поддерживает HTTPS**. В production используйте reverse proxy (nginx, Caddy) для терминирования TLS.
Что стоит учесть:
- Регистрация устройств открыта — любой, кто достучался до сервера, может создать ключ.
- Нет logout'а — сессия живёт 24 часа или до перезапуска сервера.
- Нет rate limiting'а — возможен перебор пароля.
- Пароль хранится в bcrypt — база данных не должна быть общедоступной.
Рекомендации для production:
- Закрыть порт сервера фаерволом (только доверенные IP).
- Использовать VPN (WireGuard/OpenVPN) для доступа между устройствами.
- Или поставить nginx/Caddy перед сервером с HTTPS и базовой аутентификацией на `/api/v1/device/register`.
## 7. Полный API
### Открытые endpoint'ы
| Метод | Путь | Описание |
|---|---|---|
| GET | `/api/v1/health` | Проверка здоровья сервера |
### Требуют API-ключ (Authorization: Bearer)
| Метод | Путь | Описание |
|---|---|---|
| POST | `/api/v1/sync/push` | Отправить операции на сервер |
| POST | `/api/v1/sync/pull` | Получить операции с сервера |
| POST | `/api/v1/blobs/` | Загрузить blob (multipart) |
| GET | `/api/v1/blobs/{sha256}` | Скачать blob |
### Требуют логин+пароль администратора
| Метод | Путь | Описание |
|---|---|---|
| POST | `/api/v1/device/register` | Регистрация устройства (body: name + username + password) |
### Требуют сессию админа (cookie)
| Метод | Путь | Описание |
|---|---|---|
| GET/POST | `/admin/login` | Страница входа / отправка формы |
| GET | `/admin/dashboard` | Дашборд |
| GET | `/admin/api/keys` | Список API-ключей (JSON) |
| POST | `/admin/api/keys` | Создать ключ |
| DELETE | `/admin/api/keys/{id}` | Удалить ключ |

View File

@ -98,6 +98,16 @@
let renameValue = ''
let renameError = ''
// ===== Sync state =====
let showSettings = false
let syncStatus = null
let syncLoading = false
let syncServerUrl = ''
let syncUsername = ''
let syncPassword = ''
let syncInterval = 0
let syncResult = ''
const tabs = [
{ id: 'overview', label: 'Обзор' },
{ id: 'notes', label: 'Заметки' },
@ -138,6 +148,7 @@
window.addEventListener('keydown', handleKeydown)
loading = false
loadSyncStatus()
})
onDestroy(() => {
@ -882,6 +893,70 @@
error = String(e)
}
}
// ===== Sync =====
async function loadSyncStatus() {
try {
syncStatus = await wailsCall('SyncStatus')
} catch (e) {
syncStatus = { configured: false, serverUrl: '', deviceId: '', unpushedOps: 0, lastSyncAt: '', syncInterval: 0 }
}
}
function openSettings() {
showSettings = true
syncServerUrl = syncStatus?.serverUrl || ''
syncUsername = ''
syncPassword = ''
syncInterval = syncStatus?.syncInterval || 0
syncResult = ''
}
function closeSettings() {
showSettings = false
syncResult = ''
}
async function saveSyncConfig() {
syncLoading = true
syncResult = ''
try {
await wailsCall('SyncConfigure', syncServerUrl, syncUsername, syncPassword)
if (syncInterval > 0) {
await wailsCall('SyncSetInterval', syncInterval)
}
syncResult = 'ok'
await loadSyncStatus()
} catch (e) {
syncResult = 'err: ' + String(e)
}
syncLoading = false
}
async function testConnection() {
syncLoading = true
syncResult = ''
try {
await wailsCall('SyncTestConnection', syncServerUrl, syncUsername, syncPassword)
syncResult = 'connection ok'
} catch (e) {
syncResult = 'connection failed: ' + String(e)
}
syncLoading = false
}
async function runSyncNow() {
syncLoading = true
syncResult = ''
try {
const r = await wailsCall('SyncNow')
syncResult = 'pushed ' + r.pushed + ', pulled ' + r.pulled + ' (rev ' + r.serverRevision + ')'
await loadSyncStatus()
} catch (e) {
syncResult = 'err: ' + String(e)
}
syncLoading = false
}
</script>
<div class="app">
@ -914,7 +989,14 @@
</div>
{/if}
</nav>
<div class="sidebar-footer"><span class="version">{version}</span></div>
<div class="sidebar-footer">
<button class="sidebar-sync-btn" on:click={openSettings} title="Настройки синхронизации">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
<span class="sync-dot" class:active={syncStatus?.configured}></span>
<span class="sidebar-sync-label">Синхронизация</span>
</button>
<span class="version">{version}</span>
</div>
</aside>
<!-- Main -->
@ -930,6 +1012,16 @@
<span class="crumb placeholder">Выберите раздел или дело</span>
{/if}
</div>
<div class="header-right">
{#if syncStatus?.configured}
<button class="header-sync-btn" on:click={runSyncNow} disabled={syncLoading} title="Синхронизировать">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
{#if syncStatus.unpushedOps > 0}
<span class="sync-badge">{syncStatus.unpushedOps}</span>
{/if}
</button>
{/if}
</div>
</header>
{#if error}
@ -1425,6 +1517,48 @@
on:cancel={handleCancel}
/>
{/if}
{#if showSettings}
<div class="modal-overlay" on:click|self={closeSettings}>
<div class="modal modal-sync">
<h3>Настройки синхронизации</h3>
{#if syncStatus}
<div class="sync-status">
<div class="sync-row"><span class="sync-label">Статус</span><span class="sync-value">{syncStatus.configured ? 'Включена' : 'Отключена'}</span></div>
<div class="sync-row"><span class="sync-label">Сервер</span><span class="sync-value mono">{syncStatus.serverUrl || '—'}</span></div>
<div class="sync-row"><span class="sync-label">Устройство</span><span class="sync-value mono">{syncStatus.deviceId || '—'}</span></div>
<div class="sync-row"><span class="sync-label">Неотправлено</span><span class="sync-value">{syncStatus.unpushedOps}</span></div>
<div class="sync-row"><span class="sync-label">Последняя синх.</span><span class="sync-value">{syncStatus.lastSyncAt || '—'}</span></div>
</div>
{/if}
<div class="form-group">
<label>URL сервера</label>
<input type="text" placeholder="https://example.com:47732" bind:value={syncServerUrl} />
</div>
<div class="form-group">
<label>Логин</label>
<input type="text" placeholder="username" bind:value={syncUsername} />
</div>
<div class="form-group">
<label>Пароль</label>
<input type="password" placeholder="password" bind:value={syncPassword} />
</div>
<div class="form-group">
<label>Автосинхронизация (мин)</label>
<input type="number" placeholder="0 = отключено" bind:value={syncInterval} min="0" />
</div>
{#if syncResult}
<div class="sync-result">{syncResult}</div>
{/if}
<div class="modal-actions">
<button class="btn" on:click={testConnection} disabled={syncLoading || !syncServerUrl}>Проверить</button>
<button class="btn btn-primary" on:click={saveSyncConfig} disabled={syncLoading}>Подключиться</button>
<button class="btn" on:click={runSyncNow} disabled={syncLoading || !syncStatus?.configured}>Синхронизировать</button>
<button class="btn" on:click={closeSettings}>Закрыть</button>
</div>
</div>
</div>
{/if}
</main>
</div>
@ -1444,12 +1578,18 @@
.nav-item:hover { background: #222233; }
.nav-item.selected { background: #2a2a4a; color: #fff; font-weight: 500; }
.nav-empty { padding: 8px 20px; color: #555; font-size: 12px; }
.sidebar-footer { padding: 12px 20px; border-top: 1px solid #2a2a3c; flex-shrink: 0; }
.version { font-size: 11px; color: #555; }
.sidebar-footer { padding: 8px 12px; border-top: 1px solid #2a2a3c; flex-shrink: 0; display: flex; flex-direction: column; gap: 4px; }
.version { font-size: 11px; color: #555; text-align: center; }
/* Main */
.main { flex: 1; display: flex; flex-direction: column; height: 100vh; min-width: 0; overflow: hidden; background: #13131f; }
.header { padding: 12px 24px; border-bottom: 1px solid #2a2a3c; display: flex; align-items: center; flex-shrink: 0; min-height: 48px; }
.header-left { display: flex; align-items: center; gap: 8px; flex: 1; }
.header-right { display: flex; align-items: center; gap: 8px; }
.header-sync-btn { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; padding: 6px 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; color: #b0b0c0; font-family: inherit; font-size: 13px; position: relative; }
.header-sync-btn:hover { background: #222233; color: #e4e4ef; border-color: #6366f1; }
.header-sync-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.sync-badge { background: #6366f1; color: #fff; font-size: 10px; border-radius: 50%; width: 16px; height: 16px; display: inline-flex; align-items: center; justify-content: center; position: absolute; top: -6px; right: -6px; }
.crumb { font-size: 14px; font-weight: 500; }
.crumb.placeholder { color: #666; }
.crumb-type { font-size: 11px; color: #555; background: #1e1e2e; padding: 2px 8px; border-radius: 10px; margin-left: 8px; }
@ -1636,4 +1776,18 @@
.activity-feed-type { font-size: 11px; color: #666; }
.activity-feed-target { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; }
.activity-feed-time { font-size: 11px; color: #555; }
/* Sync */
.sidebar-sync-btn { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; padding: 8px 12px; cursor: pointer; width: 100%; display: flex; align-items: center; gap: 8px; color: #888; font-family: inherit; font-size: 13px; }
.sidebar-sync-btn:hover { background: #222233; color: #e4e4ef; border-color: #6366f1; }
.sidebar-sync-label { flex: 1; text-align: left; }
.sync-dot { width: 8px; height: 8px; border-radius: 50%; background: #4a4a4a; flex-shrink: 0; }
.sync-dot.active { background: #4ade80; box-shadow: 0 0 6px rgba(74,222,128,0.5); }
.modal-sync { width: 460px; }
.sync-status { background: #13131f; border-radius: 8px; padding: 12px; margin-bottom: 16px; }
.sync-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; }
.sync-label { color: #666; }
.sync-value { color: #e4e4ef; }
.sync-value.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
.sync-result { font-size: 12px; color: #6366f1; padding: 4px 0; }
</style>

View File

@ -27,6 +27,7 @@ type SyncConfig struct {
APIKey string `yaml:"api_key"`
DeviceID string `yaml:"device_id"`
AutoSync bool `yaml:"auto_sync"`
SyncInterval int `yaml:"sync_interval"`
}
type BrowserConfig struct {

View File

@ -0,0 +1,29 @@
package storage
// migration010 — sync_ops table for recording operations to push to sync server.
const migration010 = `
CREATE TABLE IF NOT EXISTS sync_ops (
id TEXT PRIMARY KEY,
op_id TEXT NOT NULL UNIQUE,
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 DEFAULT '{}',
created_at TEXT NOT NULL,
pushed_at TEXT,
applied_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_sync_ops_pushed ON sync_ops(pushed_at);
CREATE INDEX IF NOT EXISTS idx_sync_ops_entity ON sync_ops(entity_type, entity_id);
CREATE TABLE IF NOT EXISTS sync_state (
device_id TEXT PRIMARY KEY,
server_url TEXT NOT NULL DEFAULT '',
api_key TEXT NOT NULL DEFAULT '',
last_push_rev INTEGER NOT NULL DEFAULT 0,
last_pull_rev INTEGER NOT NULL DEFAULT 0,
last_sync_at TEXT
);
`

View File

@ -66,6 +66,7 @@ var migrationFiles = map[int]string{
// 7: migration007 (FTS5) — created lazily by search.Rebuild()
8: migration008,
9: migration009,
10: migration010,
}
func (db *DB) runInitialSchema() error {

View File

@ -0,0 +1,80 @@
package sync
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
)
// BlobDir returns the path to .verstak/blobs/ inside the vault.
func BlobDir(vaultRoot string) string {
return filepath.Join(vaultRoot, ".verstak", "blobs")
}
// BlobPath returns the on-disk path for a SHA-256 hash.
func BlobPath(blobsDir, shaHex string) string {
return filepath.Join(blobsDir, shaHex[:2], shaHex[2:4], shaHex)
}
// HashFile computes SHA-256 of a file.
func HashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// HashBytes computes SHA-256 of byte data.
func HashBytes(data []byte) string {
h := sha256.Sum256(data)
return hex.EncodeToString(h[:])
}
// StoreBlob copies a file into the blob store, returns its SHA-256.
func StoreBlob(blobsDir, srcPath string) (string, error) {
shaHex, err := HashFile(srcPath)
if err != nil {
return "", err
}
dest := BlobPath(blobsDir, shaHex)
if _, err := os.Stat(dest); err == nil {
return shaHex, nil // already exists
}
if err := os.MkdirAll(filepath.Dir(dest), 0750); err != nil {
return "", err
}
src, err := os.Open(srcPath)
if err != nil {
return "", err
}
defer src.Close()
dst, err := os.Create(dest)
if err != nil {
return "", err
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
return "", err
}
_ = dst.Sync()
return shaHex, nil
}
// ReadBlob reads a blob by SHA-256 hash.
func ReadBlob(blobsDir, shaHex string) ([]byte, error) {
return os.ReadFile(BlobPath(blobsDir, shaHex))
}
// Ensure the package compiles without unused errors.
var _ = fmt.Sprintf

View File

@ -0,0 +1,243 @@
package sync
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"time"
)
// Client communicates with the Verstak Sync Server.
type Client struct {
ServerURL string
APIKey string
DeviceID string
VaultRoot string
HTTP *http.Client
}
// NewClient creates a sync client.
func NewClient(serverURL, apiKey, deviceID, vaultRoot string) *Client {
return &Client{
ServerURL: serverURL,
APIKey: apiKey,
DeviceID: deviceID,
VaultRoot: vaultRoot,
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
// RegisterDevice calls POST /api/v1/device/register and returns the API key.
func (c *Client) RegisterDevice(name string) (apiKey string, err error) {
body := map[string]string{"name": name}
var resp struct {
DeviceID string `json:"device_id"`
APIKey string `json:"api_key"`
}
if err := c.post("/api/v1/device/register", body, &resp); err != nil {
return "", err
}
return resp.APIKey, nil
}
// RegisterDeviceWithAuth registers a device with user credentials.
func (c *Client) RegisterDeviceWithAuth(name, username, password string) (deviceID, apiKey string, err error) {
body := map[string]string{"name": name, "username": username, "password": password}
var resp struct {
DeviceID string `json:"device_id"`
APIKey string `json:"api_key"`
}
// Temporarily clear API key for this request (server expects login/password, not API key).
savedKey := c.APIKey
c.APIKey = ""
err = c.post("/api/v1/device/register", body, &resp)
c.APIKey = savedKey
if err != nil {
return "", "", err
}
return resp.DeviceID, resp.APIKey, nil
}
// Login authenticates with user credentials and returns a session token.
func (c *Client) Login(username, password string) (token string, err error) {
body := map[string]string{"username": username, "password": password}
var resp struct {
Token string `json:"token"`
}
savedKey := c.APIKey
c.APIKey = ""
err = c.post("/api/v1/auth/login", body, &resp)
c.APIKey = savedKey
if err != nil {
return "", err
}
return resp.Token, nil
}
// PushRequest is the payload for POST /sync/push.
type PushRequest struct {
DeviceID string `json:"device_id"`
Ops []PushOp `json:"ops"`
}
// PushOp is a single operation in a push request.
type PushOp struct {
OpID string `json:"op_id"`
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
OpType string `json:"op_type"`
PayloadJSON string `json:"payload_json"`
CreatedAt string `json:"created_at"`
}
// PushResponse is the response from POST /sync/push.
type PushResponse struct {
Accepted []string `json:"accepted"`
Count int `json:"count"`
}
// Push sends local operations to the server.
func (c *Client) Push(ops []Op) (*PushResponse, error) {
pushOps := make([]PushOp, len(ops))
for i, op := range ops {
pushOps[i] = PushOp{
OpID: op.OpID,
EntityType: op.EntityType,
EntityID: op.EntityID,
OpType: op.OpType,
PayloadJSON: op.PayloadJSON,
CreatedAt: op.CreatedAt,
}
}
req := PushRequest{DeviceID: c.DeviceID, Ops: pushOps}
var resp PushResponse
if err := c.post("/api/v1/sync/push", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// PullRequest is the payload for POST /sync/pull.
type PullRequest struct {
SinceRevision int `json:"since_revision"`
}
// PullResponse is the response from POST /sync/pull.
type PullResponse struct {
ServerRevision int `json:"server_revision"`
Ops []Op `json:"ops"`
}
// Pull fetches remote operations since a given revision.
func (c *Client) Pull(sinceRevision int) (*PullResponse, error) {
req := PullRequest{SinceRevision: sinceRevision}
var resp PullResponse
if err := c.post("/api/v1/sync/pull", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// UploadBlob uploads a file to the server and returns its SHA-256.
func (c *Client) UploadBlob(localPath string) (sha256 string, err error) {
var b bytes.Buffer
w := multipart.NewWriter(&b)
fw, err := w.CreateFormFile("file", filepath.Base(localPath))
if err != nil {
return "", err
}
f, err := os.Open(localPath)
if err != nil {
return "", err
}
defer f.Close()
if _, err := io.Copy(fw, f); err != nil {
return "", err
}
w.Close()
req, err := http.NewRequest("POST", c.ServerURL+"/api/v1/blobs/", &b)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := c.HTTP.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result struct {
SHA256 string `json:"sha256"`
Size int `json:"size"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result.SHA256, nil
}
// DownloadBlob downloads a blob by SHA-256 hash.
func (c *Client) DownloadBlob(sha256, destPath string) error {
req, err := http.NewRequest("GET", c.ServerURL+"/api/v1/blobs/"+sha256, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("download blob: HTTP %d", resp.StatusCode)
}
out, err := os.Create(destPath)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
// --- internal ---
func (c *Client) post(path string, body, result interface{}) error {
var b bytes.Buffer
if err := json.NewEncoder(&b).Encode(body); err != nil {
return err
}
req, err := http.NewRequest("POST", c.ServerURL+path, &b)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(resp.Body)
return fmt.Errorf("server %d: %s", resp.StatusCode, string(data))
}
if result != nil {
return json.NewDecoder(resp.Body).Decode(result)
}
return nil
}

155
internal/core/sync/sync.go Normal file
View File

@ -0,0 +1,155 @@
package sync
import (
"database/sql"
"encoding/json"
"fmt"
"time"
"verstak/internal/core/storage"
"verstak/internal/core/util"
)
// Entity types (matches activity targets).
const (
EntityNode = "node"
EntityNote = "note"
EntityFile = "file"
EntityFolder = "folder"
EntityAction = "action"
EntityWorklog = "worklog"
)
// Op types.
const (
OpCreate = "create"
OpUpdate = "update"
OpDelete = "delete"
OpMove = "move"
)
// Op represents a sync operation.
type Op struct {
ID string `json:"id"`
OpID string `json:"op_id"`
DeviceID string `json:"device_id,omitempty"`
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
OpType string `json:"op_type"`
PayloadJSON string `json:"payload_json"`
CreatedAt string `json:"created_at"`
PushedAt *string `json:"pushed_at,omitempty"`
}
// Service records and manages sync operations.
type Service struct {
db *storage.DB
deviceID string
}
// NewService creates a sync service.
func NewService(db *storage.DB, deviceID string) *Service {
return &Service{db: db, deviceID: deviceID}
}
// RecordOp writes a sync operation to the local sync_ops table.
func (s *Service) RecordOp(entityType, entityID, opType string, payload interface{}) error {
id := util.UUID7()
now := time.Now().UTC().Format(time.RFC3339)
var payloadStr string
if payload != nil {
b, err := json.Marshal(payload)
if err != nil {
return err
}
payloadStr = string(b)
}
_, err := s.db.Exec(
`INSERT INTO sync_ops (id, op_id, device_id, entity_type, entity_id, op_type, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
id, id, s.deviceID, entityType, entityID, opType, payloadStr, now,
)
return err
}
// GetUnpushedOps returns ops that have not been pushed yet.
func (s *Service) GetUnpushedOps() ([]Op, error) {
rows, err := s.db.Query(
`SELECT id, op_id, device_id, entity_type, entity_id, op_type, payload_json, created_at, pushed_at
FROM sync_ops WHERE pushed_at IS NULL ORDER BY created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
return scanOps(rows)
}
// MarkPushed marks ops as pushed to server.
func (s *Service) MarkPushed(opIDs []string) error {
now := time.Now().UTC().Format(time.RFC3339)
for _, id := range opIDs {
_, err := s.db.Exec("UPDATE sync_ops SET pushed_at=? WHERE op_id=?", now, id)
if err != nil {
return err
}
}
return nil
}
// MarkApplied marks remote ops as applied locally.
func (s *Service) MarkApplied(opIDs []string) error {
now := time.Now().UTC().Format(time.RFC3339)
for _, id := range opIDs {
_, err := s.db.Exec("UPDATE sync_ops SET applied_at=? WHERE op_id=?", now, id)
if err != nil {
return err
}
}
return nil
}
// GetState returns the current sync state.
func (s *Service) GetState() (serverURL, apiKey string, lastPushRev int, lastSyncAt string, err error) {
err = s.db.QueryRow(
`SELECT server_url, api_key, last_push_rev, COALESCE(last_sync_at,'') FROM sync_state WHERE device_id=?`,
s.deviceID).Scan(&serverURL, &apiKey, &lastPushRev, &lastSyncAt)
if err == sql.ErrNoRows {
return "", "", 0, "", nil
}
return
}
// SetState saves sync connection state.
func (s *Service) SetState(serverURL, apiKey string) error {
_, err := s.db.Exec(
`INSERT INTO sync_state (device_id, server_url, api_key, last_push_rev, last_sync_at)
VALUES (?, ?, ?, 0, '')
ON CONFLICT(device_id) DO UPDATE SET server_url=excluded.server_url, api_key=excluded.api_key`,
s.deviceID, serverURL, apiKey,
)
return err
}
// --- helpers ---
func scanOps(rows *sql.Rows) ([]Op, error) {
var out []Op
for rows.Next() {
var o Op
var pushedAt sql.NullString
if err := rows.Scan(&o.ID, &o.OpID, &o.DeviceID, &o.EntityType, &o.EntityID,
&o.OpType, &o.PayloadJSON, &o.CreatedAt, &pushedAt); err != nil {
return nil, err
}
if pushedAt.Valid {
o.PushedAt = &pushedAt.String
}
out = append(out, o)
}
return out, rows.Err()
}
// MustVar ensures the package is not considered unused.
var _ = fmt.Sprintf

65
test_smoke_sync.sh Executable file
View File

@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
echo "=== Verstak Sync E2E Smoke Test ==="
SERVER_PORT=18999
SDIR=$(mktemp -d)
VD=$(mktemp -d)
cleanup() { kill "$SPID" 2>/dev/null || true; rm -rf "$SDIR" "$VD"; }
trap cleanup EXIT
echo ":: Build"
go build -o /tmp/vs-server ./cmd/verstak-server/
go build -o /tmp/vs-cli ./cmd/verstak/
echo ":: Start server"
/tmp/vs-server --port "$SERVER_PORT" --data "$SDIR" --admin-user admin --admin-pass pass > /dev/null 2>&1 &
SPID=$!
sleep 2
echo ":: Health"
curl -sf "http://localhost:$SERVER_PORT/api/v1/health" | grep -q '"ok"' && echo " OK"
echo ":: Register device"
# Register user first
curl -sf -X POST "http://localhost:$SERVER_PORT/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{"username":"smokeuser","email":"smoke@test.local","password":"mypass123"}' > /dev/null
# Confirm email
TOKEN=$(sqlite3 "$SDIR/server.db" "SELECT token FROM server_email_tokens WHERE purpose='confirm' LIMIT 1")
curl -sf "http://localhost:$SERVER_PORT/api/v1/auth/confirm?token=$TOKEN" > /dev/null
# Register device with user creds
REG=$(curl -sf -X POST "http://localhost:$SERVER_PORT/api/v1/device/register" \
-H "Content-Type: application/json" -d '{"name":"smoke","username":"smokeuser","password":"mypass123"}')
DID=$(echo "$REG" | python3 -c "import sys,json;print(json.load(sys.stdin)['device_id'])")
AKEY=$(echo "$REG" | python3 -c "import sys,json;print(json.load(sys.stdin)['api_key'])")
echo " device=$DID"
echo ":: Init vault"
/tmp/vs-cli init --vault "$VD"
echo ":: Configure sync"
cat > "$VD/.verstak/config.yml" <<YML
sync:
server_url: http://localhost:$SERVER_PORT
api_key: $AKEY
device_id: $DID
auto_sync: false
YML
echo ":: Insert sync op (simulate recorded op)"
sqlite3 "$VD/.verstak/index.db" \
"INSERT INTO sync_ops (id, op_id, device_id, entity_type, entity_id, op_type, payload_json, created_at) VALUES ('e1','e1','$DID','node','n1','create','{\"title\":\"t\"}',datetime('now'));"
echo ":: Push"
/tmp/vs-cli sync push --vault "$VD" | grep -q "Pushed 1" && echo " OK"
echo ":: Pull"
/tmp/vs-cli sync pull --vault "$VD" | grep -q "Pulled" && echo " OK"
echo ":: Status"
/tmp/vs-cli sync status --vault "$VD"
echo ""
echo "=== ALL TESTS PASSED ==="