Compare commits
No commits in common. "99e47fcb17be66da221166d210f0b227668c1a86" and "edc708a106ce9a8831c404d82f60ede9df2fc4f1" have entirely different histories.
99e47fcb17
...
edc708a106
|
|
@ -24,7 +24,6 @@ frontend/node_modules/
|
||||||
frontend/bindings/
|
frontend/bindings/
|
||||||
/verstak-gui
|
/verstak-gui
|
||||||
/verstak-cli
|
/verstak-cli
|
||||||
/verstak-server
|
|
||||||
|
|
||||||
# Vault data
|
# Vault data
|
||||||
.verstak/
|
.verstak/
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,12 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -39,7 +37,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -389,7 +386,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -484,7 +480,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -506,7 +501,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -579,7 +573,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -591,7 +584,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -610,11 +602,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
@ -625,7 +612,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -642,7 +628,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -667,11 +652,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -707,7 +687,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -746,7 +725,6 @@ 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,
|
||||||
|
|
@ -757,7 +735,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -797,7 +774,6 @@ 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
|
||||||
|
|
@ -836,122 +812,6 @@ 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,14 +8,12 @@ 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"
|
||||||
|
|
@ -55,16 +53,6 @@ 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,
|
||||||
|
|
@ -75,7 +63,6 @@ func main() {
|
||||||
worklog: worklogSvc,
|
worklog: worklogSvc,
|
||||||
search: searchSvc,
|
search: searchSvc,
|
||||||
plugins: pm,
|
plugins: pm,
|
||||||
sync: syncSvc,
|
|
||||||
vault: abs,
|
vault: abs,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
#!/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"
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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
|
|
@ -1,22 +0,0 @@
|
||||||
[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,11 +8,9 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
@ -40,8 +38,6 @@ 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:
|
||||||
|
|
@ -60,7 +56,6 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -602,162 +597,6 @@ 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,7 +37,6 @@
|
||||||
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]] — установка и настройка сервера синхронизации.
|
|
||||||
|
|
||||||
## Главные принципы
|
## Главные принципы
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
# Сервер синхронизации Верстак — руководство
|
|
||||||
|
|
||||||
## 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,16 +98,6 @@
|
||||||
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: 'Заметки' },
|
||||||
|
|
@ -148,7 +138,6 @@
|
||||||
window.addEventListener('keydown', handleKeydown)
|
window.addEventListener('keydown', handleKeydown)
|
||||||
|
|
||||||
loading = false
|
loading = false
|
||||||
loadSyncStatus()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
|
@ -893,70 +882,6 @@
|
||||||
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">
|
||||||
|
|
@ -989,14 +914,7 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer"><span class="version">{version}</span></div>
|
||||||
<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 -->
|
||||||
|
|
@ -1012,16 +930,6 @@
|
||||||
<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}
|
||||||
|
|
@ -1517,48 +1425,6 @@
|
||||||
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>
|
||||||
|
|
||||||
|
|
@ -1578,18 +1444,12 @@
|
||||||
.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: 8px 12px; border-top: 1px solid #2a2a3c; flex-shrink: 0; display: flex; flex-direction: column; gap: 4px; }
|
.sidebar-footer { padding: 12px 20px; border-top: 1px solid #2a2a3c; flex-shrink: 0; }
|
||||||
.version { font-size: 11px; color: #555; text-align: center; }
|
.version { font-size: 11px; color: #555; }
|
||||||
|
|
||||||
/* 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; }
|
||||||
|
|
@ -1776,18 +1636,4 @@
|
||||||
.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,7 +27,6 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
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,7 +66,6 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,243 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
#!/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