Compare commits
No commits in common. "50e7e958445f798f0ebdcfff6f2dd81ce22bef46" and "87c8dfcbea0e6e467051e71cc798a589362676e4" have entirely different histories.
50e7e95844
...
87c8dfcbea
|
|
@ -25,7 +25,6 @@ frontend/bindings/
|
|||
/verstak-gui
|
||||
/verstak-cli
|
||||
/verstak-server
|
||||
/verstak
|
||||
|
||||
# Vault data
|
||||
.verstak/
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package main
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
|
@ -23,7 +22,6 @@ import (
|
|||
"verstak/internal/core/plugins"
|
||||
"verstak/internal/core/search"
|
||||
"verstak/internal/core/storage"
|
||||
"verstak/internal/core/util"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/worklog"
|
||||
)
|
||||
|
|
@ -62,7 +60,6 @@ func (a *App) autoSyncLoop() {
|
|||
ticker := time.NewTicker(checkInterval)
|
||||
defer ticker.Stop()
|
||||
log.Printf("[autosync] started, vault=%s", a.vault)
|
||||
var lastSync time.Time
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
|
|
@ -77,27 +74,21 @@ func (a *App) autoSyncLoop() {
|
|||
serverURL = sURL
|
||||
}
|
||||
if serverURL == "" {
|
||||
log.Printf("[autosync] no server URL")
|
||||
continue
|
||||
}
|
||||
interval := 0
|
||||
if cfg != nil {
|
||||
interval = cfg.Sync.SyncInterval
|
||||
}
|
||||
if interval <= 0 {
|
||||
continue
|
||||
}
|
||||
if !lastSync.IsZero() && time.Since(lastSync) < time.Duration(interval)*time.Minute {
|
||||
if cfg != nil && cfg.Sync.SyncInterval <= 0 {
|
||||
log.Printf("[autosync] interval=%d, skipping", cfg.Sync.SyncInterval)
|
||||
continue
|
||||
}
|
||||
deviceToken := config.LoadDeviceToken(a.vault)
|
||||
if deviceToken == "" {
|
||||
log.Printf("[autosync] no device token")
|
||||
continue
|
||||
}
|
||||
log.Printf("[autosync] running SyncNow...")
|
||||
if _, err := a.SyncNow(); err != nil {
|
||||
log.Printf("[autosync] SyncNow error: %v", err)
|
||||
} else {
|
||||
lastSync = time.Now()
|
||||
}
|
||||
case <-a.ctx.Done():
|
||||
log.Printf("[autosync] stopped")
|
||||
|
|
@ -442,68 +433,11 @@ func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, e
|
|||
return nil, err
|
||||
}
|
||||
_ = a.activity.Record(n.ID, activity.TargetNode, n.ID, "", activity.TypeNodeCreated, title, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNode, n.ID, syncsvc.OpCreate, nodePayload(n))
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNode, n.ID, syncsvc.OpCreate, map[string]string{"title": title})
|
||||
dto := toNodeDTO(n)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func nodePayload(n *nodes.Node) map[string]interface{} {
|
||||
pid := ""
|
||||
if n.ParentID != nil {
|
||||
pid = *n.ParentID
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": n.ID,
|
||||
"parent_id": pid,
|
||||
"type": n.Type,
|
||||
"title": n.Title,
|
||||
"slug": n.Slug,
|
||||
"section": n.Section,
|
||||
"sort_order": n.SortOrder,
|
||||
"created_at": n.CreatedAt.Format(time.RFC3339),
|
||||
"updated_at": n.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) filePayload(n *nodes.Node) map[string]interface{} {
|
||||
p := map[string]interface{}{
|
||||
"node_id": n.ID,
|
||||
"type": n.Type,
|
||||
"title": n.Title,
|
||||
"slug": n.Slug,
|
||||
"created_at": n.CreatedAt.Format(time.RFC3339),
|
||||
"updated_at": n.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
if n.ParentID != nil {
|
||||
p["parent_id"] = *n.ParentID
|
||||
}
|
||||
// Look up the linked file record, if any.
|
||||
if recs, err := a.files.ListByNode(n.ID); err == nil && len(recs) > 0 {
|
||||
rec := recs[0]
|
||||
p["filename"] = rec.Filename
|
||||
p["path"] = rec.Path
|
||||
p["storage_mode"] = rec.StorageMode
|
||||
p["size"] = rec.Size
|
||||
p["sha256"] = rec.SHA256
|
||||
p["mime"] = rec.MIME
|
||||
p["file_id"] = rec.ID
|
||||
// Compute blob SHA-256 for vault files.
|
||||
if rec.StorageMode == "vault" {
|
||||
if rec.SHA256 != "" {
|
||||
p["blob_sha256"] = rec.SHA256
|
||||
} else {
|
||||
absPath := filepath.Join(a.vault, rec.Path)
|
||||
if hash, err := syncsvc.HashFile(absPath); err == nil {
|
||||
p["blob_sha256"] = hash
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
p["filename"] = n.Title
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (a *App) DeleteNode(id string) error {
|
||||
return a.nodes.SoftDelete(id)
|
||||
}
|
||||
|
|
@ -589,30 +523,16 @@ func (a *App) ListNotes(nodeID string) ([]NodeDTO, error) {
|
|||
|
||||
// CreateNote creates a note under a parent node.
|
||||
func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) {
|
||||
node, fileRec, err := a.notes.Create(parentID, title, "")
|
||||
node, _, err := a.notes.Create(parentID, title, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content, _ := a.notes.Read(node.ID)
|
||||
_ = a.activity.Record(parentID, activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNote, node.ID, syncsvc.OpCreate, notePayload(node, fileRec, content))
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNote, node.ID, syncsvc.OpCreate, map[string]string{"title": title})
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func notePayload(node *nodes.Node, fileRec *files.Record, content string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"node_id": node.ID,
|
||||
"file_id": fileRec.ID,
|
||||
"format": "markdown",
|
||||
"content": content,
|
||||
"filename": fileRec.Filename,
|
||||
"path": fileRec.Path,
|
||||
"created_at": node.CreatedAt.Format(time.RFC3339),
|
||||
"updated_at": node.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// ReadNote reads note content.
|
||||
func (a *App) ReadNote(noteID string) (string, error) {
|
||||
return a.notes.Read(noteID)
|
||||
|
|
@ -630,11 +550,7 @@ func (a *App) SaveNote(noteID, content string) error {
|
|||
pid = *n.ParentID
|
||||
}
|
||||
_ = a.activity.Record(pid, activity.TargetNote, noteID, "", activity.TypeNoteUpdated, n.Title, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNote, noteID, syncsvc.OpUpdate, map[string]interface{}{
|
||||
"node_id": noteID,
|
||||
"content": content,
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNote, noteID, syncsvc.OpUpdate, map[string]string{"title": n.Title})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -707,7 +623,7 @@ func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) {
|
|||
}
|
||||
for _, n := range nodes {
|
||||
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, a.filePayload(&n))
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, map[string]string{"title": n.Title})
|
||||
}
|
||||
return toNodeDTOs(nodes), nil
|
||||
}
|
||||
|
|
@ -719,7 +635,7 @@ func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
|
|||
}
|
||||
for _, n := range nodes {
|
||||
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, a.filePayload(&n))
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, map[string]string{"title": n.Title})
|
||||
}
|
||||
return toNodeDTOs(nodes), nil
|
||||
}
|
||||
|
|
@ -753,7 +669,7 @@ func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) {
|
|||
return nil, err
|
||||
}
|
||||
_ = a.activity.Record(parentID, activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, a.filePayload(node))
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, map[string]string{"title": filename})
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
|
|
@ -770,7 +686,7 @@ func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) {
|
|||
pid = *n.ParentID
|
||||
}
|
||||
_ = a.activity.Record(pid, activity.TargetFile, node.ID, "", activity.TypeFileCopied, node.Title, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, a.filePayload(node))
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, map[string]string{"title": node.Title})
|
||||
dto := toNodeDTO(node)
|
||||
return &dto, nil
|
||||
}
|
||||
|
|
@ -799,10 +715,7 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
|
|||
if n.Type == nodes.TypeFolder {
|
||||
syncEntity = syncsvc.EntityFolder
|
||||
}
|
||||
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpUpdate, map[string]interface{}{
|
||||
"title": newTitle,
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpUpdate, map[string]string{"title": newTitle})
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -838,10 +751,7 @@ func (a *App) MoveNode(nodeID, newParentID string) error {
|
|||
pid = *node.ParentID
|
||||
}
|
||||
_ = a.activity.Record(pid, activity.TargetFile, nodeID, "", activity.TypeFileMoved, node.Title, `{"to":"`+newParentID+`"}`)
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, nodeID, syncsvc.OpMove, map[string]interface{}{
|
||||
"parent_id": newParentID,
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFile, nodeID, syncsvc.OpMove, map[string]string{"title": node.Title})
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -880,7 +790,7 @@ func (a *App) CreateAction(nodeID, kind, title, data string) (*ActionDTO, error)
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.sync.RecordOp(syncsvc.EntityAction, rec.ID, syncsvc.OpCreate, actionPayload(rec))
|
||||
_ = a.sync.RecordOp(syncsvc.EntityAction, rec.ID, syncsvc.OpCreate, map[string]string{"title": rec.Title, "kind": rec.Kind})
|
||||
return &ActionDTO{
|
||||
ID: rec.ID,
|
||||
NodeID: rec.NodeID,
|
||||
|
|
@ -890,23 +800,6 @@ func (a *App) CreateAction(nodeID, kind, title, data string) (*ActionDTO, error)
|
|||
}, nil
|
||||
}
|
||||
|
||||
func actionPayload(rec *actions.Record) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"id": rec.ID,
|
||||
"node_id": rec.NodeID,
|
||||
"title": rec.Title,
|
||||
"kind": rec.Kind,
|
||||
"command": rec.Command,
|
||||
"args": rec.Args,
|
||||
"working_dir": rec.WorkingDir,
|
||||
"url": rec.URL,
|
||||
"confirm_required": rec.ConfirmRequired,
|
||||
"capture_output": rec.CaptureOutput,
|
||||
"created_at": rec.CreatedAt.Format(time.RFC3339),
|
||||
"updated_at": rec.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) DeleteAction(id string) error {
|
||||
_ = a.sync.RecordOp(syncsvc.EntityAction, id, syncsvc.OpDelete, nil)
|
||||
return a.actions.Delete(id)
|
||||
|
|
@ -948,7 +841,7 @@ func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, e
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
|
||||
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, map[string]string{"summary": summary})
|
||||
mins := 0
|
||||
if entry.Minutes != nil {
|
||||
mins = *entry.Minutes
|
||||
|
|
@ -963,32 +856,6 @@ func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, e
|
|||
return dto, nil
|
||||
}
|
||||
|
||||
func worklogPayload(entry *worklog.Entry) map[string]interface{} {
|
||||
mins := 0
|
||||
if entry.Minutes != nil {
|
||||
mins = *entry.Minutes
|
||||
}
|
||||
p := map[string]interface{}{
|
||||
"id": entry.ID,
|
||||
"node_id": entry.NodeID,
|
||||
"summary": entry.Summary,
|
||||
"details": entry.Details,
|
||||
"minutes": mins,
|
||||
"date": entry.Date,
|
||||
"approximate": entry.Approximate,
|
||||
"billable": entry.Billable,
|
||||
"created_at": entry.CreatedAt.Format(time.RFC3339),
|
||||
"updated_at": entry.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
if entry.StartedAt != nil {
|
||||
p["started_at"] = entry.StartedAt.Format(time.RFC3339)
|
||||
}
|
||||
if entry.EndedAt != nil {
|
||||
p["ended_at"] = entry.EndedAt.Format(time.RFC3339)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Search
|
||||
// ============================================================
|
||||
|
|
@ -1121,9 +988,9 @@ func (a *App) SyncDisconnect() error {
|
|||
}
|
||||
|
||||
func (a *App) SyncTestConnection(serverURL, username, password string) error {
|
||||
// Use a dedicated auth test that does NOT create a device.
|
||||
client := syncsvc.NewClient(serverURL, "", "", a.vault)
|
||||
return client.TestAuth(serverURL, username, password)
|
||||
_, _, err := client.PairDevice(serverURL, username, password, "test-connection", "verstak-gui/v2")
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) SyncSetInterval(minutes int) error {
|
||||
|
|
@ -1160,14 +1027,11 @@ func (a *App) SyncNow() (map[string]interface{}, error) {
|
|||
client := syncsvc.NewClient(serverURL, apiKey, deviceID, a.vault)
|
||||
client.DeviceToken = deviceToken
|
||||
|
||||
// Push unpushed ops — set last_seen_server_seq on each.
|
||||
// Push unpushed ops.
|
||||
unpushed, err := a.sync.GetUnpushedOps()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get ops: %w", err)
|
||||
}
|
||||
for i := range unpushed {
|
||||
unpushed[i].LastSeenServerSeq = lastPullSeq
|
||||
}
|
||||
pushResult := &syncsvc.PushResponse{}
|
||||
if len(unpushed) > 0 {
|
||||
pushResult, err = client.Push(unpushed)
|
||||
|
|
@ -1185,15 +1049,11 @@ func (a *App) SyncNow() (map[string]interface{}, error) {
|
|||
return nil, fmt.Errorf("pull: %w", err)
|
||||
}
|
||||
|
||||
// Apply each pulled op to the local vault.
|
||||
var applyErrors []string
|
||||
for _, op := range pullResult.Ops {
|
||||
if err := a.applyRemoteOp(op); err != nil {
|
||||
applyErrors = append(applyErrors, fmt.Sprintf("%s/%s: %v", op.EntityType, op.OpID, err))
|
||||
}
|
||||
_ = a.sync.RecordRemoteOp(op)
|
||||
}
|
||||
if len(pullResult.Ops) > 0 {
|
||||
// Apply pulled ops locally (record as remote ops, mark applied).
|
||||
for _, op := range pullResult.Ops {
|
||||
_ = a.sync.RecordRemoteOp(op)
|
||||
}
|
||||
opIDs := make([]string, len(pullResult.Ops))
|
||||
for i, op := range pullResult.Ops {
|
||||
opIDs[i] = op.OpID
|
||||
|
|
@ -1201,523 +1061,17 @@ func (a *App) SyncNow() (map[string]interface{}, error) {
|
|||
_ = a.sync.MarkApplied(opIDs)
|
||||
}
|
||||
|
||||
// Report conflicts.
|
||||
if len(pushResult.Conflicts) > 0 {
|
||||
log.Printf("[sync] %d conflict(s) detected on push", len(pushResult.Conflicts))
|
||||
for _, c := range pushResult.Conflicts {
|
||||
log.Printf("[sync] conflict: op=%v entity=%v/%v",
|
||||
c["op_id"], c["entity_type"], c["entity_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// Update sync state.
|
||||
if pullResult.ServerSequence > lastPullSeq {
|
||||
_ = a.sync.SetLastPullSeq(pullResult.ServerSequence)
|
||||
}
|
||||
_ = a.sync.SetLastSyncAt(time.Now().UTC().Format(time.RFC3339))
|
||||
|
||||
result := map[string]interface{}{
|
||||
return map[string]interface{}{
|
||||
"pushed": len(pushResult.Accepted),
|
||||
"pulled": len(pullResult.Ops),
|
||||
"serverSequence": pullResult.ServerSequence,
|
||||
}
|
||||
if len(applyErrors) > 0 {
|
||||
result["applyErrors"] = applyErrors
|
||||
}
|
||||
if len(pushResult.Conflicts) > 0 {
|
||||
result["conflicts"] = pushResult.Conflicts
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// applyRemoteOp dispatches a remote sync operation to the correct entity handler.
|
||||
func (a *App) applyRemoteOp(op syncsvc.Op) error {
|
||||
switch op.EntityType {
|
||||
case syncsvc.EntityNode:
|
||||
return a.applyRemoteNodeOp(op)
|
||||
case syncsvc.EntityNote:
|
||||
return a.applyRemoteNoteOp(op)
|
||||
case syncsvc.EntityFile, syncsvc.EntityFolder:
|
||||
return a.applyRemoteFileOrFolderOp(op)
|
||||
case syncsvc.EntityAction:
|
||||
return a.applyRemoteActionOp(op)
|
||||
case syncsvc.EntityWorklog:
|
||||
return a.applyRemoteWorklogOp(op)
|
||||
}
|
||||
return nil // unknown entity type, skip silently
|
||||
}
|
||||
|
||||
// --- apply helpers ---
|
||||
|
||||
func (a *App) applyRemoteNodeOp(op syncsvc.Op) error {
|
||||
switch op.OpType {
|
||||
case syncsvc.OpCreate:
|
||||
return a.applyRemoteNodeCreate(op)
|
||||
case syncsvc.OpUpdate:
|
||||
return a.applyRemoteNodeUpdate(op)
|
||||
case syncsvc.OpMove:
|
||||
return a.applyRemoteNodeMove(op)
|
||||
case syncsvc.OpDelete:
|
||||
return a.applyRemoteNodeDelete(op)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNodeCreate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
ParentID string `json:"parent_id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Section string `json:"section"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal node create: %w", err)
|
||||
}
|
||||
if payload.ID == "" || payload.Type == "" || payload.Title == "" {
|
||||
return fmt.Errorf("incomplete node payload")
|
||||
}
|
||||
|
||||
// Check if node already exists (e.g., created by a prior file/note op).
|
||||
if _, err := a.nodes.Get(payload.ID); err == nil {
|
||||
return nil // already exists
|
||||
}
|
||||
|
||||
// Insert directly (bypass slug uniqueness / validation for remote ops).
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if payload.CreatedAt == "" {
|
||||
payload.CreatedAt = now
|
||||
}
|
||||
if payload.UpdatedAt == "" {
|
||||
payload.UpdatedAt = now
|
||||
}
|
||||
var parent interface{}
|
||||
if payload.ParentID != "" {
|
||||
parent = payload.ParentID
|
||||
}
|
||||
var section interface{}
|
||||
if payload.Section != "" {
|
||||
section = payload.Section
|
||||
}
|
||||
slug := payload.Slug
|
||||
if slug == "" {
|
||||
slug = nodes.Slugify(payload.Title)
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,section,sort_order,created_at,updated_at,revision,device_id)
|
||||
VALUES (?,?,?,?,?,?,0,?,?,1,NULL)`,
|
||||
payload.ID, parent, payload.Type, payload.Title, slug, section,
|
||||
payload.CreatedAt, payload.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNodeUpdate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
Title string `json:"title"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal node update: %w", err)
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if payload.UpdatedAt != "" {
|
||||
now = payload.UpdatedAt
|
||||
}
|
||||
if payload.Title != "" {
|
||||
slug := nodes.Slugify(payload.Title)
|
||||
_, err := a.db.Exec(
|
||||
`UPDATE nodes SET title=?, slug=?, updated_at=? WHERE id=?`,
|
||||
payload.Title, slug, now, op.EntityID)
|
||||
return err
|
||||
}
|
||||
// No title = just touch.
|
||||
_, err := a.db.Exec(`UPDATE nodes SET updated_at=? WHERE id=?`, now, op.EntityID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNodeMove(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
ParentID string `json:"parent_id"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal node move: %w", err)
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if payload.UpdatedAt != "" {
|
||||
now = payload.UpdatedAt
|
||||
}
|
||||
var parent interface{}
|
||||
if payload.ParentID != "" {
|
||||
parent = payload.ParentID
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`UPDATE nodes SET parent_id=?, updated_at=? WHERE id=?`,
|
||||
parent, now, op.EntityID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNodeDelete(op syncsvc.Op) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := a.db.Exec(
|
||||
`UPDATE nodes SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`,
|
||||
now, now, op.EntityID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNoteOp(op syncsvc.Op) error {
|
||||
switch op.OpType {
|
||||
case syncsvc.OpCreate:
|
||||
return a.applyRemoteNoteCreate(op)
|
||||
case syncsvc.OpUpdate:
|
||||
return a.applyRemoteNoteUpdate(op)
|
||||
case syncsvc.OpDelete:
|
||||
return a.applyRemoteNodeDelete(op)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
NodeID string `json:"node_id"`
|
||||
FileID string `json:"file_id"`
|
||||
Format string `json:"format"`
|
||||
Content string `json:"content"`
|
||||
Filename string `json:"filename"`
|
||||
Path string `json:"path"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal note create: %w", err)
|
||||
}
|
||||
if payload.NodeID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
// Ensure the parent node exists (create a placeholder if not).
|
||||
if _, err := a.nodes.Get(payload.NodeID); err != nil {
|
||||
slug := nodes.Slugify("remote-note")
|
||||
_, e := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO nodes (id,type,title,slug,created_at,updated_at,revision)
|
||||
VALUES (?,'note','remote-note',?,?,?,1)`,
|
||||
payload.NodeID, slug, now, now)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
|
||||
// Write the .md file.
|
||||
dest := filepath.Join(a.vault, payload.Path)
|
||||
if payload.Path == "" {
|
||||
filename := payload.Filename
|
||||
if filename == "" {
|
||||
filename = payload.NodeID[:8] + ".md"
|
||||
}
|
||||
dest = filepath.Join(a.vault, "spaces", filename)
|
||||
payload.Path, _ = filepath.Rel(a.vault, dest)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(dest, []byte(payload.Content), 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
info, _ := os.Stat(dest)
|
||||
size := int64(0)
|
||||
if info != nil {
|
||||
size = info.Size()
|
||||
}
|
||||
|
||||
// Create file record.
|
||||
fileID := payload.FileID
|
||||
if fileID == "" {
|
||||
fileID = util.UUID7()
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO files (id,node_id,filename,path,storage_mode,size,mime,created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,'vault',?,'text/plain',?,?,0)`,
|
||||
fileID, payload.NodeID, filepath.Base(dest), payload.Path, size, now, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create notes link.
|
||||
format := payload.Format
|
||||
if format == "" {
|
||||
format = "markdown"
|
||||
}
|
||||
_, err = a.db.Exec(
|
||||
`INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
|
||||
payload.NodeID, fileID, format)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteNoteUpdate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
NodeID string `json:"node_id"`
|
||||
Content string `json:"content"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal note update: %w", err)
|
||||
}
|
||||
if payload.NodeID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find the note's file path.
|
||||
var filePath, storageMode string
|
||||
err := a.db.QueryRow(
|
||||
`SELECT f.path, f.storage_mode FROM notes n JOIN files f ON n.file_id = f.id WHERE n.node_id=?`,
|
||||
payload.NodeID).Scan(&filePath, &storageMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("note record not found: %w", err)
|
||||
}
|
||||
|
||||
var abs string
|
||||
if storageMode == "vault" {
|
||||
abs = filepath.Join(a.vault, filePath)
|
||||
} else {
|
||||
abs = filePath
|
||||
}
|
||||
if err := os.WriteFile(abs, []byte(payload.Content), 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
info, _ := os.Stat(abs)
|
||||
size := int64(0)
|
||||
if info != nil {
|
||||
size = info.Size()
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, e := a.db.Exec(
|
||||
`UPDATE files SET size=?, updated_at=? WHERE path=? AND storage_mode=?`,
|
||||
size, now, filePath, storageMode)
|
||||
return e
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteFileOrFolderOp(op syncsvc.Op) error {
|
||||
switch op.OpType {
|
||||
case syncsvc.OpCreate:
|
||||
return a.applyRemoteFileCreate(op)
|
||||
case syncsvc.OpUpdate:
|
||||
return a.applyRemoteNodeUpdate(op)
|
||||
case syncsvc.OpMove:
|
||||
return a.applyRemoteNodeMove(op)
|
||||
case syncsvc.OpDelete:
|
||||
return a.applyRemoteNodeDelete(op)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteFileCreate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
NodeID string `json:"node_id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
ParentID string `json:"parent_id"`
|
||||
Filename string `json:"filename"`
|
||||
Path string `json:"path"`
|
||||
StorageMode string `json:"storage_mode"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
MIME string `json:"mime"`
|
||||
FileID string `json:"file_id"`
|
||||
BlobSHA256 string `json:"blob_sha256"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal file create: %w", err)
|
||||
}
|
||||
if payload.NodeID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
// Create the node if not exists.
|
||||
if _, err := a.nodes.Get(payload.NodeID); err != nil {
|
||||
slug := payload.Slug
|
||||
if slug == "" {
|
||||
slug = nodes.Slugify(payload.Title)
|
||||
}
|
||||
ntype := payload.Type
|
||||
if ntype == "" {
|
||||
ntype = "file"
|
||||
}
|
||||
var parent interface{}
|
||||
if payload.ParentID != "" {
|
||||
parent = payload.ParentID
|
||||
}
|
||||
_, e := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,created_at,updated_at,revision)
|
||||
VALUES (?,?,?,?,?,?,?,1)`,
|
||||
payload.NodeID, parent, ntype, payload.Title, slug, now, now)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
|
||||
// Download blob if needed and not already present on disk.
|
||||
if payload.BlobSHA256 != "" && payload.StorageMode == "vault" {
|
||||
blobsDir := syncsvc.BlobDir(a.vault)
|
||||
blobPath := syncsvc.BlobPath(blobsDir, payload.BlobSHA256)
|
||||
if _, err := os.Stat(blobPath); os.IsNotExist(err) {
|
||||
// Download from server.
|
||||
serverURL, apiKey, _, _, _ := a.sync.GetState()
|
||||
deviceToken := config.LoadDeviceToken(a.vault)
|
||||
cli := syncsvc.NewClient(serverURL, apiKey, "", a.vault)
|
||||
cli.DeviceToken = deviceToken
|
||||
if err := cli.DownloadBlob(payload.BlobSHA256, blobPath); err != nil {
|
||||
log.Printf("[sync] blob download failed for %s: %v", payload.BlobSHA256, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Place file in vault.
|
||||
dest := filepath.Join(a.vault, payload.Path)
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o750); err == nil {
|
||||
// Copy blob to actual vault location.
|
||||
input, rErr := os.ReadFile(blobPath)
|
||||
if rErr == nil {
|
||||
_ = os.WriteFile(dest, input, 0o640)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create file record.
|
||||
fileID := payload.FileID
|
||||
if fileID == "" {
|
||||
fileID = util.UUID7()
|
||||
}
|
||||
storageMode := payload.StorageMode
|
||||
if storageMode == "" {
|
||||
storageMode = "vault"
|
||||
}
|
||||
mime := payload.MIME
|
||||
if mime == "" {
|
||||
mime = "application/octet-stream"
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,0)`,
|
||||
fileID, payload.NodeID, payload.Filename, payload.Path, storageMode,
|
||||
payload.Size, payload.SHA256, mime, now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteActionOp(op syncsvc.Op) error {
|
||||
switch op.OpType {
|
||||
case syncsvc.OpCreate:
|
||||
return a.applyRemoteActionCreate(op)
|
||||
case syncsvc.OpDelete:
|
||||
_, err := a.db.Exec(`DELETE FROM actions WHERE id=?`, op.EntityID)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteActionCreate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"node_id"`
|
||||
Title string `json:"title"`
|
||||
Kind string `json:"kind"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
WorkingDir string `json:"working_dir"`
|
||||
URL string `json:"url"`
|
||||
ConfirmRequired bool `json:"confirm_required"`
|
||||
CaptureOutput bool `json:"capture_output"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal action create: %w", err)
|
||||
}
|
||||
if payload.ID == "" || payload.NodeID == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO actions (id,node_id,title,kind,command,args_json,working_dir,url,confirm_required,capture_output,created_at,updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
payload.ID, payload.NodeID, payload.Title, payload.Kind,
|
||||
payload.Command, jsonArgs(payload.Args), payload.WorkingDir, payload.URL,
|
||||
boolToInt(payload.ConfirmRequired), boolToInt(payload.CaptureOutput),
|
||||
payload.CreatedAt, payload.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteWorklogOp(op syncsvc.Op) error {
|
||||
switch op.OpType {
|
||||
case syncsvc.OpCreate:
|
||||
return a.applyRemoteWorklogCreate(op)
|
||||
case syncsvc.OpDelete:
|
||||
_, err := a.db.Exec(`DELETE FROM worklog_entries WHERE id=?`, op.EntityID)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteWorklogCreate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"node_id"`
|
||||
Summary string `json:"summary"`
|
||||
Details string `json:"details"`
|
||||
Minutes int `json:"minutes"`
|
||||
Date string `json:"date"`
|
||||
StartedAt string `json:"started_at"`
|
||||
EndedAt string `json:"ended_at"`
|
||||
Approximate bool `json:"approximate"`
|
||||
Billable bool `json:"billable"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal worklog create: %w", err)
|
||||
}
|
||||
if payload.ID == "" || payload.NodeID == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO worklog_entries (id,node_id,started_at,ended_at,date,minutes,approximate,billable,summary,details,created_at,updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
payload.ID, payload.NodeID, strPtr(payload.StartedAt), strPtr(payload.EndedAt),
|
||||
payload.Date, payload.Minutes, boolToInt(payload.Approximate), boolToInt(payload.Billable),
|
||||
payload.Summary, payload.Details, payload.CreatedAt, payload.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- small helpers ---
|
||||
|
||||
func jsonArgs(args []string) string {
|
||||
if len(args) == 0 {
|
||||
return ""
|
||||
}
|
||||
b, _ := json.Marshal(args)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func strPtr(s string) interface{} {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -418,7 +418,6 @@ func (s *Server) routes() *http.ServeMux {
|
|||
mux.HandleFunc("/api/v1/sync/pull", s.handleSyncPull)
|
||||
mux.HandleFunc("/api/v1/blobs/", s.handleBlobs)
|
||||
mux.HandleFunc("/api/client/pair", s.handleClientPair)
|
||||
mux.HandleFunc("/api/auth/test", s.handleAuthTest)
|
||||
mux.HandleFunc("/api/client/revoke-current", s.handleClientRevoke)
|
||||
mux.HandleFunc("/api/client/me", s.handleClientMe)
|
||||
mux.HandleFunc("/api/client/revoke-device", s.handleClientRevokeDevice)
|
||||
|
|
@ -828,46 +827,6 @@ func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
|
|||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthTest(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "bad json")
|
||||
return
|
||||
}
|
||||
if req.Username == "" || req.Password == "" {
|
||||
jsonErr(w, 400, "username and password required")
|
||||
return
|
||||
}
|
||||
var hash string
|
||||
var confirmed, blocked int
|
||||
err := s.db.QueryRow("SELECT password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
||||
req.Username, strings.ToLower(req.Username)).Scan(&hash, &confirmed, &blocked)
|
||||
if err != nil {
|
||||
jsonErr(w, 401, "invalid credentials")
|
||||
return
|
||||
}
|
||||
if blocked != 0 {
|
||||
jsonErr(w, 403, "account blocked")
|
||||
return
|
||||
}
|
||||
if confirmed == 0 {
|
||||
jsonErr(w, 403, "email not confirmed")
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
|
||||
jsonErr(w, 401, "invalid credentials")
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleClientRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
|
|
@ -1739,7 +1698,7 @@ func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
html := strings.ReplaceAll(resetPasswordHTML, "{TOKEN}", token)
|
||||
html := fmt.Sprintf(resetPasswordHTML, token)
|
||||
w.Write([]byte(html))
|
||||
case "POST":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
|
|
@ -1982,7 +1941,7 @@ func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
|
|||
smtpSecurity := s.smtpGet("smtp_security")
|
||||
srvURL := s.smtpGet("server_url")
|
||||
|
||||
html := `<!DOCTYPE html>
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — Admin</title>
|
||||
|
|
@ -2101,14 +2060,8 @@ function testSMTP(){
|
|||
<pre id="health-result">Загрузка...</pre>
|
||||
</div>
|
||||
</div>
|
||||
_ = smtpURL
|
||||
_ = smtpUser
|
||||
_ = smtpFrom
|
||||
_ = smtpSecurity
|
||||
_ = smtpHost
|
||||
_ = smtpPort
|
||||
|
||||
</body></html>`
|
||||
</body></html>`)
|
||||
w.Write([]byte(html))
|
||||
}
|
||||
|
||||
|
|
@ -2809,7 +2762,7 @@ button:hover{background:#4f46e5}
|
|||
</head><body>
|
||||
<form method="POST">
|
||||
<h1>Новый пароль</h1>
|
||||
<input type="hidden" name="token" value="{TOKEN}">
|
||||
<input type="hidden" name="token" value="%s">
|
||||
<label>Новый пароль</label>
|
||||
<input type="password" name="password" minlength="8" required autofocus>
|
||||
<label>Подтвердите пароль</label>
|
||||
|
|
|
|||
|
|
@ -672,11 +672,6 @@ func runSyncPush(args []string) {
|
|||
return
|
||||
}
|
||||
|
||||
_, _, lastSeq, _, _ := syncSvc.GetState()
|
||||
for i := range unpushed {
|
||||
unpushed[i].LastSeenServerSeq = lastSeq
|
||||
}
|
||||
|
||||
result, err := client.Push(unpushed)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Push failed: %v\n", err)
|
||||
|
|
@ -689,9 +684,6 @@ func runSyncPush(args []string) {
|
|||
}
|
||||
|
||||
fmt.Printf("Pushed %d ops, accepted %d\n", len(unpushed), len(result.Accepted))
|
||||
if len(result.Conflicts) > 0 {
|
||||
fmt.Printf("WARNING: %d conflict(s) detected\n", len(result.Conflicts))
|
||||
}
|
||||
}
|
||||
|
||||
func runSyncPull(args []string) {
|
||||
|
|
|
|||
|
|
@ -130,19 +130,6 @@ func (c *Client) Login(username, password string) (token string, err error) {
|
|||
return resp.Token, nil
|
||||
}
|
||||
|
||||
// TestAuth checks credentials without creating a device.
|
||||
func (c *Client) TestAuth(serverURL, username, password string) error {
|
||||
body := map[string]string{"username": username, "password": password}
|
||||
savedURL := c.ServerURL
|
||||
savedKey := c.APIKey
|
||||
c.ServerURL = serverURL
|
||||
c.APIKey = ""
|
||||
err := c.post("/api/auth/test", body, nil)
|
||||
c.ServerURL = savedURL
|
||||
c.APIKey = savedKey
|
||||
return err
|
||||
}
|
||||
|
||||
// PushRequest is the payload for POST /sync/push.
|
||||
type PushRequest struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
|
|
@ -174,14 +161,12 @@ 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,
|
||||
ClientSequence: op.ClientSequence,
|
||||
LastSeenServerSeq: op.LastSeenServerSeq,
|
||||
CreatedAt: op.CreatedAt,
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -30,18 +30,16 @@ const (
|
|||
|
||||
// Op represents a sync operation.
|
||||
type Op struct {
|
||||
ID string `json:"id"`
|
||||
OpID string `json:"op_id"`
|
||||
ServerSequence int `json:"server_sequence,omitempty"`
|
||||
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"`
|
||||
ClientSequence int `json:"client_sequence,omitempty"`
|
||||
LastSeenServerSeq int `json:"last_seen_server_seq,omitempty"`
|
||||
ID string `json:"id"`
|
||||
OpID string `json:"op_id"`
|
||||
ServerSequence int `json:"server_sequence,omitempty"`
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,311 +0,0 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// TestE2ESync starts a real server process and tests two-client sync.
|
||||
func TestE2ESync(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping e2e sync test in short mode")
|
||||
}
|
||||
|
||||
// Find the server binary (must be pre-built).
|
||||
serverBin := findServerBin(t)
|
||||
if serverBin == "" {
|
||||
t.Skip("verstak-server binary not found; build with 'go build -o verstak-server ./cmd/verstak-server'")
|
||||
}
|
||||
|
||||
// Create temp directories.
|
||||
serverDir := t.TempDir()
|
||||
vaultA := t.TempDir()
|
||||
vaultB := t.TempDir()
|
||||
|
||||
// Init vaults.
|
||||
os.MkdirAll(filepath.Join(vaultA, ".verstak"), 0750)
|
||||
os.MkdirAll(filepath.Join(vaultB, ".verstak"), 0750)
|
||||
|
||||
// Pick a random port.
|
||||
serverPort := pickPort(t)
|
||||
|
||||
// Start server.
|
||||
serverDataDir := filepath.Join(serverDir, "data")
|
||||
os.MkdirAll(serverDataDir, 0750)
|
||||
|
||||
cmd := exec.Command(serverBin,
|
||||
"-port", fmt.Sprintf("%d", serverPort),
|
||||
"-data", serverDataDir,
|
||||
"-admin-user", "admin",
|
||||
"-admin-pass", "admin",
|
||||
)
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start server: %v", err)
|
||||
}
|
||||
defer cmd.Process.Kill()
|
||||
|
||||
// Wait for server.
|
||||
serverURL := fmt.Sprintf("http://127.0.0.1:%d", serverPort)
|
||||
waitForServer(t, serverURL, 10*time.Second)
|
||||
|
||||
// Register user.
|
||||
regBody := fmt.Sprintf(`{"username":"testuser","email":"test@test.com","password":"password123"}`)
|
||||
resp, err := http.Post(serverURL+"/api/v1/auth/register", "application/json", strings.NewReader(regBody))
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
// Confirm email by directly updating the server DB.
|
||||
dbPath := filepath.Join(serverDataDir, "server.db")
|
||||
confirmUser(t, dbPath, "testuser")
|
||||
|
||||
// Test auth (the new endpoint).
|
||||
testBody := fmt.Sprintf(`{"username":"testuser","password":"password123"}`)
|
||||
resp, err = http.Post(serverURL+"/api/auth/test", "application/json", strings.NewReader(testBody))
|
||||
if err != nil {
|
||||
t.Fatalf("auth test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
t.Fatalf("auth test status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Pair device A.
|
||||
pairBody := fmt.Sprintf(`{"login":"testuser","password":"password123","device_name":"Client A","client_version":"test/v1"}`)
|
||||
resp, err = http.Post(serverURL+"/api/client/pair", "application/json", strings.NewReader(pairBody))
|
||||
if err != nil {
|
||||
t.Fatalf("pair A: %v", err)
|
||||
}
|
||||
var pairResp struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceToken string `json:"device_token"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&pairResp); err != nil {
|
||||
t.Fatalf("decode pair A: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
deviceIDA := pairResp.DeviceID
|
||||
tokenA := pairResp.DeviceToken
|
||||
t.Logf("Client A: device=%s token=%s...%s", deviceIDA, tokenA[:16], tokenA[len(tokenA)-8:])
|
||||
|
||||
// Pair device B.
|
||||
pairBody = fmt.Sprintf(`{"login":"testuser","password":"password123","device_name":"Client B","client_version":"test/v1"}`)
|
||||
resp, err = http.Post(serverURL+"/api/client/pair", "application/json", strings.NewReader(pairBody))
|
||||
if err != nil {
|
||||
t.Fatalf("pair B: %v", err)
|
||||
}
|
||||
var pairRespB struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceToken string `json:"device_token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&pairRespB); err != nil {
|
||||
t.Fatalf("decode pair B: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
deviceIDB := pairRespB.DeviceID
|
||||
tokenB := pairRespB.DeviceToken
|
||||
t.Logf("Client B: device=%s token=%s...%s", deviceIDB, tokenB[:16], tokenB[len(tokenB)-8:])
|
||||
|
||||
// Now create a node on Client A by posting a sync op.
|
||||
// Simulating what the GUI does: push a "node create" op.
|
||||
nodeID := "test-node-001"
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
pushPayload := map[string]interface{}{
|
||||
"device_id": deviceIDA,
|
||||
"ops": []map[string]interface{}{
|
||||
{
|
||||
"op_id": "op-node-create-001",
|
||||
"entity_type": "node",
|
||||
"entity_id": nodeID,
|
||||
"op_type": "create",
|
||||
"payload_json": fmt.Sprintf(
|
||||
`{"id":"%s","parent_id":"","type":"case","title":"Test Project","slug":"test-project","section":"projects","created_at":"%s","updated_at":"%s"}`,
|
||||
nodeID, now, now),
|
||||
"client_sequence": 1,
|
||||
"last_seen_server_seq": 0,
|
||||
"created_at": now,
|
||||
},
|
||||
},
|
||||
"idempotency_key": "e2e-test-push-1",
|
||||
}
|
||||
|
||||
pushBody, _ := json.Marshal(pushPayload)
|
||||
req, _ := http.NewRequest("POST", serverURL+"/api/v1/sync/push", bytes.NewReader(pushBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+tokenA)
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("push A: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
t.Fatalf("push A status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var pushRespA struct {
|
||||
Accepted []string `json:"accepted"`
|
||||
Conflicts []interface{} `json:"conflicts"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&pushRespA)
|
||||
resp.Body.Close()
|
||||
t.Logf("Push A accepted: %v", pushRespA.Accepted)
|
||||
|
||||
// Pull from Client B — should get the node op.
|
||||
pullReq := map[string]interface{}{"since_sequence": 0}
|
||||
pullBody, _ := json.Marshal(pullReq)
|
||||
req, _ = http.NewRequest("POST", serverURL+"/api/v1/sync/pull", bytes.NewReader(pullBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+tokenB)
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("pull B: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
t.Fatalf("pull B status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var pullRespB struct {
|
||||
ServerSequence int `json:"server_sequence"`
|
||||
Ops []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"`
|
||||
} `json:"ops"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&pullRespB)
|
||||
resp.Body.Close()
|
||||
|
||||
if len(pullRespB.Ops) == 0 {
|
||||
t.Fatal("Client B pulled 0 ops, expected at least 1")
|
||||
}
|
||||
t.Logf("Client B pulled %d ops (server seq=%d)", len(pullRespB.Ops), pullRespB.ServerSequence)
|
||||
|
||||
foundNodeOp := false
|
||||
for _, op := range pullRespB.Ops {
|
||||
if op.EntityType == "node" && op.EntityID == nodeID && op.OpType == "create" {
|
||||
foundNodeOp = true
|
||||
|
||||
// Verify payload contents.
|
||||
var payload map[string]interface{}
|
||||
json.Unmarshal([]byte(op.PayloadJSON), &payload)
|
||||
if payload["title"] != "Test Project" {
|
||||
t.Errorf("expected title 'Test Project', got %v", payload["title"])
|
||||
}
|
||||
if payload["type"] != "case" {
|
||||
t.Errorf("expected type 'case', got %v", payload["type"])
|
||||
}
|
||||
if payload["section"] != "projects" {
|
||||
t.Errorf("expected section 'projects', got %v", payload["section"])
|
||||
}
|
||||
t.Logf("Node payload verified: title=%v type=%v section=%v",
|
||||
payload["title"], payload["type"], payload["section"])
|
||||
}
|
||||
}
|
||||
if !foundNodeOp {
|
||||
t.Errorf("node create op not found in pulled ops")
|
||||
}
|
||||
|
||||
// Test the auth test endpoint doesn't create devices.
|
||||
// Count devices before via direct DB access.
|
||||
beforeCount := countDevices(t, dbPath)
|
||||
t.Logf("Devices before auth test calls: %d (should be 2: A + B)", beforeCount)
|
||||
|
||||
// Call test auth multiple times.
|
||||
for i := 0; i < 3; i++ {
|
||||
testBody := fmt.Sprintf(`{"username":"testuser","password":"password123"}`)
|
||||
resp, err = http.Post(serverURL+"/api/auth/test", "application/json", strings.NewReader(testBody))
|
||||
if err != nil {
|
||||
t.Fatalf("auth test iteration %d: %v", i, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// Count devices after — should be same.
|
||||
afterCount := countDevices(t, dbPath)
|
||||
if afterCount != beforeCount {
|
||||
t.Errorf("device count changed after auth test: before=%d after=%d (should be equal)", beforeCount, afterCount)
|
||||
}
|
||||
|
||||
t.Log("E2E sync test passed!")
|
||||
}
|
||||
|
||||
func findServerBin(t *testing.T) string {
|
||||
t.Helper()
|
||||
// Check common locations.
|
||||
candidates := []string{
|
||||
"verstak-server",
|
||||
"../../../verstak-server",
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pickPort(t *testing.T) int {
|
||||
t.Helper()
|
||||
// Try a few ports — for testing, a fixed port is OK since tests use temp dirs.
|
||||
return 18999
|
||||
}
|
||||
|
||||
func waitForServer(t *testing.T, url string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := http.Get(url + "/api/v1/health")
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
return
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("server not ready within %v at %s", timeout, url)
|
||||
}
|
||||
|
||||
func confirmUser(t *testing.T, dbPath, username string) {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open server db for confirm: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
_, err = db.Exec("UPDATE server_users SET confirmed=1 WHERE username=?", username)
|
||||
if err != nil {
|
||||
t.Fatalf("confirm user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func countDevices(t *testing.T, dbPath string) int {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open server db for count: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
var count int
|
||||
db.QueryRow("SELECT COUNT(*) FROM server_devices").Scan(&count)
|
||||
return count
|
||||
}
|
||||
Loading…
Reference in New Issue