Compare commits
18 Commits
edc708a106
...
99e47fcb17
| Author | SHA1 | Date |
|---|---|---|
|
|
99e47fcb17 | |
|
|
0ef54c31f8 | |
|
|
b3662d4876 | |
|
|
f8dc436709 | |
|
|
241a9d8c06 | |
|
|
5db3da3618 | |
|
|
e828ebd44e | |
|
|
84c0bcbcab | |
|
|
a1a50863c5 | |
|
|
1abe8c4fa0 | |
|
|
5b2cec5bcc | |
|
|
1a20edac44 | |
|
|
ad684eb118 | |
|
|
10c6d06e38 | |
|
|
ec928e3be6 | |
|
|
c5e0060fee | |
|
|
834b5ef0d4 | |
|
|
4145b4d74a |
|
|
@ -24,6 +24,7 @@ frontend/node_modules/
|
||||||
frontend/bindings/
|
frontend/bindings/
|
||||||
/verstak-gui
|
/verstak-gui
|
||||||
/verstak-cli
|
/verstak-cli
|
||||||
|
/verstak-server
|
||||||
|
|
||||||
# Vault data
|
# Vault data
|
||||||
.verstak/
|
.verstak/
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,14 @@ import (
|
||||||
|
|
||||||
"verstak/internal/core/actions"
|
"verstak/internal/core/actions"
|
||||||
"verstak/internal/core/activity"
|
"verstak/internal/core/activity"
|
||||||
|
"verstak/internal/core/config"
|
||||||
"verstak/internal/core/files"
|
"verstak/internal/core/files"
|
||||||
"verstak/internal/core/notes"
|
"verstak/internal/core/notes"
|
||||||
"verstak/internal/core/nodes"
|
"verstak/internal/core/nodes"
|
||||||
"verstak/internal/core/plugins"
|
"verstak/internal/core/plugins"
|
||||||
"verstak/internal/core/search"
|
"verstak/internal/core/search"
|
||||||
"verstak/internal/core/storage"
|
"verstak/internal/core/storage"
|
||||||
|
syncsvc "verstak/internal/core/sync"
|
||||||
"verstak/internal/core/worklog"
|
"verstak/internal/core/worklog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -37,6 +39,7 @@ type App struct {
|
||||||
worklog *worklog.Service
|
worklog *worklog.Service
|
||||||
search *search.Service
|
search *search.Service
|
||||||
plugins *plugins.Manager
|
plugins *plugins.Manager
|
||||||
|
sync *syncsvc.Service
|
||||||
vault string
|
vault string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -386,6 +389,7 @@ func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, e
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
_ = a.activity.Record(n.ID, activity.TargetNode, n.ID, "", activity.TypeNodeCreated, title, "")
|
_ = 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)
|
dto := toNodeDTO(n)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
@ -480,6 +484,7 @@ func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
_ = a.activity.Record(parentID, activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, "")
|
_ = 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)
|
dto := toNodeDTO(node)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
@ -501,6 +506,7 @@ func (a *App) SaveNote(noteID, content string) error {
|
||||||
pid = *n.ParentID
|
pid = *n.ParentID
|
||||||
}
|
}
|
||||||
_ = a.activity.Record(pid, activity.TargetNote, noteID, "", activity.TypeNoteUpdated, n.Title, "")
|
_ = 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -573,6 +579,7 @@ func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) {
|
||||||
}
|
}
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
|
_ = 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
|
return toNodeDTOs(nodes), nil
|
||||||
}
|
}
|
||||||
|
|
@ -584,6 +591,7 @@ func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
|
||||||
}
|
}
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
|
_ = 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
|
return toNodeDTOs(nodes), nil
|
||||||
}
|
}
|
||||||
|
|
@ -602,6 +610,11 @@ func (a *App) DeleteFileOrFolder(nodeID string) error {
|
||||||
targetType = activity.TargetFolder
|
targetType = activity.TargetFolder
|
||||||
}
|
}
|
||||||
_ = a.activity.Record(pid, targetType, nodeID, "", evType, n.Title, "")
|
_ = 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)
|
return a.files.DeleteNodeAndChildren(nodeID)
|
||||||
}
|
}
|
||||||
|
|
@ -612,6 +625,7 @@ func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
_ = a.activity.Record(parentID, activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, "")
|
_ = 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)
|
dto := toNodeDTO(node)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
@ -628,6 +642,7 @@ func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) {
|
||||||
pid = *n.ParentID
|
pid = *n.ParentID
|
||||||
}
|
}
|
||||||
_ = a.activity.Record(pid, activity.TargetFile, node.ID, "", activity.TypeFileCopied, node.Title, "")
|
_ = 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)
|
dto := toNodeDTO(node)
|
||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
@ -652,6 +667,11 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
|
||||||
targetType = activity.TargetFolder
|
targetType = activity.TargetFolder
|
||||||
}
|
}
|
||||||
_ = a.activity.Record(pid, targetType, nodeID, "", evType, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`)
|
_ = 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -687,6 +707,7 @@ func (a *App) MoveNode(nodeID, newParentID string) error {
|
||||||
pid = *node.ParentID
|
pid = *node.ParentID
|
||||||
}
|
}
|
||||||
_ = a.activity.Record(pid, activity.TargetFile, nodeID, "", activity.TypeFileMoved, node.Title, `{"to":"`+newParentID+`"}`)
|
_ = 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -725,6 +746,7 @@ func (a *App) CreateAction(nodeID, kind, title, data string) (*ActionDTO, error)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
_ = a.sync.RecordOp(syncsvc.EntityAction, rec.ID, syncsvc.OpCreate, map[string]string{"title": rec.Title, "kind": rec.Kind})
|
||||||
return &ActionDTO{
|
return &ActionDTO{
|
||||||
ID: rec.ID,
|
ID: rec.ID,
|
||||||
NodeID: rec.NodeID,
|
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 {
|
func (a *App) DeleteAction(id string) error {
|
||||||
|
_ = a.sync.RecordOp(syncsvc.EntityAction, id, syncsvc.OpDelete, nil)
|
||||||
return a.actions.Delete(id)
|
return a.actions.Delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -774,6 +797,7 @@ func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, e
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, map[string]string{"summary": summary})
|
||||||
mins := 0
|
mins := 0
|
||||||
if entry.Minutes != nil {
|
if entry.Minutes != nil {
|
||||||
mins = *entry.Minutes
|
mins = *entry.Minutes
|
||||||
|
|
@ -812,6 +836,122 @@ func (a *App) Search(query string) ([]SearchResultDTO, error) {
|
||||||
return out, nil
|
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)
|
// File Dialogs (Wails v2 Runtime)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,14 @@ import (
|
||||||
|
|
||||||
"verstak/internal/core/actions"
|
"verstak/internal/core/actions"
|
||||||
"verstak/internal/core/activity"
|
"verstak/internal/core/activity"
|
||||||
|
"verstak/internal/core/config"
|
||||||
"verstak/internal/core/files"
|
"verstak/internal/core/files"
|
||||||
"verstak/internal/core/notes"
|
"verstak/internal/core/notes"
|
||||||
"verstak/internal/core/nodes"
|
"verstak/internal/core/nodes"
|
||||||
"verstak/internal/core/plugins"
|
"verstak/internal/core/plugins"
|
||||||
"verstak/internal/core/search"
|
"verstak/internal/core/search"
|
||||||
"verstak/internal/core/storage"
|
"verstak/internal/core/storage"
|
||||||
|
syncsvc "verstak/internal/core/sync"
|
||||||
"verstak/internal/core/worklog"
|
"verstak/internal/core/worklog"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2"
|
"github.com/wailsapp/wails/v2"
|
||||||
|
|
@ -53,6 +55,16 @@ func main() {
|
||||||
pm := plugins.NewManager(abs)
|
pm := plugins.NewManager(abs)
|
||||||
pm.Discover()
|
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{
|
app := &App{
|
||||||
db: db,
|
db: db,
|
||||||
nodes: nodeRepo,
|
nodes: nodeRepo,
|
||||||
|
|
@ -63,6 +75,7 @@ func main() {
|
||||||
worklog: worklogSvc,
|
worklog: worklogSvc,
|
||||||
search: searchSvc,
|
search: searchSvc,
|
||||||
plugins: pm,
|
plugins: pm,
|
||||||
|
sync: syncSvc,
|
||||||
vault: abs,
|
vault: abs,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||||
|
|
@ -8,9 +8,11 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"verstak/internal/core/actions"
|
"verstak/internal/core/actions"
|
||||||
|
"verstak/internal/core/config"
|
||||||
"verstak/internal/core/plugins"
|
"verstak/internal/core/plugins"
|
||||||
"verstak/internal/core/search"
|
"verstak/internal/core/search"
|
||||||
"verstak/internal/core/storage"
|
"verstak/internal/core/storage"
|
||||||
|
syncsvc "verstak/internal/core/sync"
|
||||||
"verstak/internal/core/vault"
|
"verstak/internal/core/vault"
|
||||||
"verstak/internal/core/worklog"
|
"verstak/internal/core/worklog"
|
||||||
)
|
)
|
||||||
|
|
@ -38,6 +40,8 @@ func main() {
|
||||||
runLog(os.Args[2:])
|
runLog(os.Args[2:])
|
||||||
case "index":
|
case "index":
|
||||||
runIndex(os.Args[2:])
|
runIndex(os.Args[2:])
|
||||||
|
case "sync":
|
||||||
|
runSync(os.Args[2:])
|
||||||
case "plugin":
|
case "plugin":
|
||||||
runPlugin(os.Args[2:])
|
runPlugin(os.Args[2:])
|
||||||
default:
|
default:
|
||||||
|
|
@ -56,6 +60,7 @@ func usage() {
|
||||||
fmt.Println(" node Manage nodes")
|
fmt.Println(" node Manage nodes")
|
||||||
fmt.Println(" action Manage actions")
|
fmt.Println(" action Manage actions")
|
||||||
fmt.Println(" --version Show version")
|
fmt.Println(" --version Show version")
|
||||||
|
fmt.Println(" sync Sync with server (push/pull/status)")
|
||||||
fmt.Println(" --help Show this help")
|
fmt.Println(" --help Show this help")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -597,6 +602,162 @@ func runIndexRebuild(args []string) {
|
||||||
fmt.Printf("indexed %d nodes\n", count)
|
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 ---
|
// --- plugin ---
|
||||||
|
|
||||||
func runPlugin(args []string) {
|
func runPlugin(args []string) {
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@
|
||||||
7. [[07_AI_Coder_Prompts]] — промпты для ИИ-кодера.
|
7. [[07_AI_Coder_Prompts]] — промпты для ИИ-кодера.
|
||||||
8. [[08_MVP_Checklist]] — чеклист первого MVP.
|
8. [[08_MVP_Checklist]] — чеклист первого MVP.
|
||||||
9. [[09_Extensibility]] — архитектура плагинов (Lua + шаблоны дел).
|
9. [[09_Extensibility]] — архитектура плагинов (Lua + шаблоны дел).
|
||||||
|
10. [[10_Sync_Server_Guide]] — установка и настройка сервера синхронизации.
|
||||||
|
|
||||||
## Главные принципы
|
## Главные принципы
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}` | Удалить ключ |
|
||||||
|
|
@ -98,6 +98,16 @@
|
||||||
let renameValue = ''
|
let renameValue = ''
|
||||||
let renameError = ''
|
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 = [
|
const tabs = [
|
||||||
{ id: 'overview', label: 'Обзор' },
|
{ id: 'overview', label: 'Обзор' },
|
||||||
{ id: 'notes', label: 'Заметки' },
|
{ id: 'notes', label: 'Заметки' },
|
||||||
|
|
@ -138,6 +148,7 @@
|
||||||
window.addEventListener('keydown', handleKeydown)
|
window.addEventListener('keydown', handleKeydown)
|
||||||
|
|
||||||
loading = false
|
loading = false
|
||||||
|
loadSyncStatus()
|
||||||
})
|
})
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
|
@ -882,6 +893,70 @@
|
||||||
error = String(e)
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="app">
|
<div class="app">
|
||||||
|
|
@ -914,7 +989,14 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</nav>
|
</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>
|
</aside>
|
||||||
|
|
||||||
<!-- Main -->
|
<!-- Main -->
|
||||||
|
|
@ -930,6 +1012,16 @@
|
||||||
<span class="crumb placeholder">Выберите раздел или дело</span>
|
<span class="crumb placeholder">Выберите раздел или дело</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
|
|
@ -1425,6 +1517,48 @@
|
||||||
on:cancel={handleCancel}
|
on:cancel={handleCancel}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1444,12 +1578,18 @@
|
||||||
.nav-item:hover { background: #222233; }
|
.nav-item:hover { background: #222233; }
|
||||||
.nav-item.selected { background: #2a2a4a; color: #fff; font-weight: 500; }
|
.nav-item.selected { background: #2a2a4a; color: #fff; font-weight: 500; }
|
||||||
.nav-empty { padding: 8px 20px; color: #555; font-size: 12px; }
|
.nav-empty { padding: 8px 20px; color: #555; font-size: 12px; }
|
||||||
.sidebar-footer { padding: 12px 20px; border-top: 1px solid #2a2a3c; flex-shrink: 0; }
|
.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; }
|
.version { font-size: 11px; color: #555; text-align: center; }
|
||||||
|
|
||||||
/* Main */
|
/* Main */
|
||||||
.main { flex: 1; display: flex; flex-direction: column; height: 100vh; min-width: 0; overflow: hidden; background: #13131f; }
|
.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 { 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 { font-size: 14px; font-weight: 500; }
|
||||||
.crumb.placeholder { color: #666; }
|
.crumb.placeholder { color: #666; }
|
||||||
.crumb-type { font-size: 11px; color: #555; background: #1e1e2e; padding: 2px 8px; border-radius: 10px; margin-left: 8px; }
|
.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-type { font-size: 11px; color: #666; }
|
||||||
.activity-feed-target { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; }
|
.activity-feed-target { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; }
|
||||||
.activity-feed-time { font-size: 11px; color: #555; }
|
.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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ type SyncConfig struct {
|
||||||
APIKey string `yaml:"api_key"`
|
APIKey string `yaml:"api_key"`
|
||||||
DeviceID string `yaml:"device_id"`
|
DeviceID string `yaml:"device_id"`
|
||||||
AutoSync bool `yaml:"auto_sync"`
|
AutoSync bool `yaml:"auto_sync"`
|
||||||
|
SyncInterval int `yaml:"sync_interval"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BrowserConfig struct {
|
type BrowserConfig struct {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
@ -66,6 +66,7 @@ var migrationFiles = map[int]string{
|
||||||
// 7: migration007 (FTS5) — created lazily by search.Rebuild()
|
// 7: migration007 (FTS5) — created lazily by search.Rebuild()
|
||||||
8: migration008,
|
8: migration008,
|
||||||
9: migration009,
|
9: migration009,
|
||||||
|
10: migration010,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) runInitialSchema() error {
|
func (db *DB) runInitialSchema() error {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 ==="
|
||||||
Loading…
Reference in New Issue