Compare commits
12 Commits
99e47fcb17
...
87c8dfcbea
| Author | SHA1 | Date |
|---|---|---|
|
|
87c8dfcbea | |
|
|
7fe02fc8df | |
|
|
b0d992b0d6 | |
|
|
e5860ca076 | |
|
|
daed8e0aba | |
|
|
fa6f988368 | |
|
|
c8cdb089a6 | |
|
|
4afcc0e135 | |
|
|
61928cf28e | |
|
|
04af88940b | |
|
|
015c8fdec7 | |
|
|
0f5c584c50 |
|
|
@ -3,6 +3,7 @@ package main
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -51,6 +52,49 @@ func (a *App) startup(ctx context.Context) {
|
||||||
wailsruntime.EventsEmit(ctx, "files-dropped", paths)
|
wailsruntime.EventsEmit(ctx, "files-dropped", paths)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
go a.autoSyncLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) autoSyncLoop() {
|
||||||
|
const checkInterval = 60 * time.Second
|
||||||
|
ticker := time.NewTicker(checkInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
log.Printf("[autosync] started, vault=%s", a.vault)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
serverURL := ""
|
||||||
|
cfg, err := config.Load(a.vault)
|
||||||
|
if err == nil {
|
||||||
|
serverURL = cfg.Sync.ServerURL
|
||||||
|
}
|
||||||
|
// Fall back to SQLite sync_state if config doesn't have it.
|
||||||
|
if serverURL == "" {
|
||||||
|
sURL, _, _, _, _ := a.sync.GetState()
|
||||||
|
serverURL = sURL
|
||||||
|
}
|
||||||
|
if serverURL == "" {
|
||||||
|
log.Printf("[autosync] no server URL")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
case <-a.ctx.Done():
|
||||||
|
log.Printf("[autosync] stopped")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -844,6 +888,10 @@ type SyncStatusDTO struct {
|
||||||
Configured bool `json:"configured"`
|
Configured bool `json:"configured"`
|
||||||
ServerURL string `json:"serverUrl"`
|
ServerURL string `json:"serverUrl"`
|
||||||
DeviceID string `json:"deviceId"`
|
DeviceID string `json:"deviceId"`
|
||||||
|
DeviceName string `json:"deviceName"`
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Revoked bool `json:"revoked"`
|
||||||
|
TokenStored bool `json:"tokenStored"`
|
||||||
UnpushedOps int `json:"unpushedOps"`
|
UnpushedOps int `json:"unpushedOps"`
|
||||||
LastSyncAt string `json:"lastSyncAt"`
|
LastSyncAt string `json:"lastSyncAt"`
|
||||||
SyncInterval int `json:"syncInterval"`
|
SyncInterval int `json:"syncInterval"`
|
||||||
|
|
@ -854,65 +902,120 @@ func (a *App) SyncStatus() (*SyncStatusDTO, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &SyncStatusDTO{}, nil
|
return &SyncStatusDTO{}, nil
|
||||||
}
|
}
|
||||||
unpushed, _ := a.sync.GetUnpushedOps()
|
|
||||||
cfg, _ := config.Load(a.vault)
|
cfg, _ := config.Load(a.vault)
|
||||||
|
deviceToken := config.LoadDeviceToken(a.vault)
|
||||||
dto := &SyncStatusDTO{
|
dto := &SyncStatusDTO{
|
||||||
Configured: serverURL != "" && apiKey != "",
|
Configured: serverURL != "" && (apiKey != "" || deviceToken != ""),
|
||||||
ServerURL: serverURL,
|
ServerURL: serverURL,
|
||||||
UnpushedOps: len(unpushed),
|
|
||||||
LastSyncAt: lastSyncAt,
|
LastSyncAt: lastSyncAt,
|
||||||
|
UnpushedOps: 0,
|
||||||
|
TokenStored: deviceToken != "",
|
||||||
}
|
}
|
||||||
if cfg != nil {
|
if cfg != nil {
|
||||||
dto.DeviceID = cfg.Sync.DeviceID
|
dto.DeviceID = cfg.Sync.DeviceID
|
||||||
dto.SyncInterval = cfg.Sync.SyncInterval
|
dto.SyncInterval = cfg.Sync.SyncInterval
|
||||||
}
|
}
|
||||||
|
unpushed, _ := a.sync.GetUnpushedOps()
|
||||||
|
dto.UnpushedOps = len(unpushed)
|
||||||
|
|
||||||
|
if deviceToken != "" {
|
||||||
|
client := syncsvc.NewClient(serverURL, "", "", a.vault)
|
||||||
|
client.DeviceToken = deviceToken
|
||||||
|
if cfg != nil {
|
||||||
|
client.DeviceID = cfg.Sync.DeviceID
|
||||||
|
}
|
||||||
|
if info, err := client.GetMe(); err == nil {
|
||||||
|
dto.DeviceName = info.DeviceName
|
||||||
|
dto.DeviceID = info.DeviceID
|
||||||
|
dto.Connected = true
|
||||||
|
if info.RevokedAt != "" {
|
||||||
|
dto.Revoked = true
|
||||||
|
dto.Connected = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return dto, nil
|
return dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) SyncConfigure(serverURL, username, password string) error {
|
func (a *App) SyncConfigure(serverURL, username, password string) error {
|
||||||
// Register device on server with user credentials.
|
|
||||||
hostname, _ := os.Hostname()
|
hostname, _ := os.Hostname()
|
||||||
if hostname == "" {
|
if hostname == "" {
|
||||||
hostname = "unknown"
|
hostname = "unknown"
|
||||||
}
|
}
|
||||||
client := syncsvc.NewClient(serverURL, "", "", a.vault)
|
client := syncsvc.NewClient(serverURL, "", "", a.vault)
|
||||||
deviceID, apiKey, err := client.RegisterDeviceWithAuth(hostname, username, password)
|
deviceID, deviceToken, err := client.PairDevice(serverURL, username, password, hostname, "verstak-gui/v2")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("register: %w", err)
|
return fmt.Errorf("pair: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := a.sync.SetState(serverURL, apiKey); err != nil {
|
// Save token to separate file with 0600 perms.
|
||||||
|
if err := config.SaveDeviceToken(a.vault, deviceToken); err != nil {
|
||||||
|
return fmt.Errorf("save token: %w", err)
|
||||||
|
}
|
||||||
|
if err := a.sync.SetState(serverURL, ""); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Persist to vault config.
|
|
||||||
cfg, err := config.Load(a.vault)
|
cfg, err := config.Load(a.vault)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
cfg = &config.Config{}
|
||||||
}
|
}
|
||||||
cfg.Sync.ServerURL = serverURL
|
cfg.Sync.ServerURL = serverURL
|
||||||
cfg.Sync.APIKey = apiKey
|
|
||||||
cfg.Sync.DeviceID = deviceID
|
cfg.Sync.DeviceID = deviceID
|
||||||
|
cfg.Sync.APIKey = ""
|
||||||
return config.Save(a.vault, cfg)
|
return config.Save(a.vault, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) SyncDisconnect() error {
|
||||||
|
deviceToken := config.LoadDeviceToken(a.vault)
|
||||||
|
cfg, err := config.Load(a.vault)
|
||||||
|
if err != nil {
|
||||||
|
cfg = &config.Config{}
|
||||||
|
}
|
||||||
|
// Revoke token on server if we have one.
|
||||||
|
if deviceToken != "" {
|
||||||
|
client := syncsvc.NewClient(cfg.Sync.ServerURL, "", "", a.vault)
|
||||||
|
client.DeviceToken = deviceToken
|
||||||
|
_ = client.RevokeCurrent()
|
||||||
|
}
|
||||||
|
config.RemoveDeviceToken(a.vault)
|
||||||
|
cfg.Sync.ServerURL = ""
|
||||||
|
cfg.Sync.DeviceID = ""
|
||||||
|
cfg.Sync.APIKey = ""
|
||||||
|
if err := config.Save(a.vault, cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.sync.SetState("", "")
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) SyncTestConnection(serverURL, username, password string) error {
|
func (a *App) SyncTestConnection(serverURL, username, password string) error {
|
||||||
client := syncsvc.NewClient(serverURL, "", "", a.vault)
|
client := syncsvc.NewClient(serverURL, "", "", a.vault)
|
||||||
_, _, err := client.RegisterDeviceWithAuth("test-connection", username, password)
|
_, _, err := client.PairDevice(serverURL, username, password, "test-connection", "verstak-gui/v2")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) SyncSetInterval(minutes int) error {
|
func (a *App) SyncSetInterval(minutes int) error {
|
||||||
cfg, err := config.Load(a.vault)
|
cfg, err := config.Load(a.vault)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
cfg = &config.Config{}
|
||||||
|
}
|
||||||
|
// If config lost the server URL, restore from sync_state.
|
||||||
|
if cfg.Sync.ServerURL == "" {
|
||||||
|
sURL, _, _, _, _ := a.sync.GetState()
|
||||||
|
if sURL != "" {
|
||||||
|
cfg.Sync.ServerURL = sURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.Sync.DeviceID == "" {
|
||||||
|
cfg.Sync.DeviceID = a.sync.GetDeviceID()
|
||||||
}
|
}
|
||||||
cfg.Sync.SyncInterval = minutes
|
cfg.Sync.SyncInterval = minutes
|
||||||
return config.Save(a.vault, cfg)
|
return config.Save(a.vault, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) SyncNow() (map[string]interface{}, error) {
|
func (a *App) SyncNow() (map[string]interface{}, error) {
|
||||||
serverURL, apiKey, lastRev, _, err := a.sync.GetState()
|
serverURL, apiKey, lastPullSeq, _, err := a.sync.GetState()
|
||||||
if err != nil || serverURL == "" || apiKey == "" {
|
deviceToken := config.LoadDeviceToken(a.vault)
|
||||||
|
if err != nil || serverURL == "" || (apiKey == "" && deviceToken == "") {
|
||||||
return nil, fmt.Errorf("sync not configured")
|
return nil, fmt.Errorf("sync not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -922,6 +1025,7 @@ func (a *App) SyncNow() (map[string]interface{}, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
client := syncsvc.NewClient(serverURL, apiKey, deviceID, a.vault)
|
client := syncsvc.NewClient(serverURL, apiKey, deviceID, a.vault)
|
||||||
|
client.DeviceToken = deviceToken
|
||||||
|
|
||||||
// Push unpushed ops.
|
// Push unpushed ops.
|
||||||
unpushed, err := a.sync.GetUnpushedOps()
|
unpushed, err := a.sync.GetUnpushedOps()
|
||||||
|
|
@ -940,15 +1044,33 @@ func (a *App) SyncNow() (map[string]interface{}, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pull remote ops.
|
// Pull remote ops.
|
||||||
pullResult, err := client.Pull(lastRev)
|
pullResult, err := client.Pull(lastPullSeq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("pull: %w", err)
|
return nil, fmt.Errorf("pull: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
_ = a.sync.MarkApplied(opIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sync state.
|
||||||
|
if pullResult.ServerSequence > lastPullSeq {
|
||||||
|
_ = a.sync.SetLastPullSeq(pullResult.ServerSequence)
|
||||||
|
}
|
||||||
|
_ = a.sync.SetLastSyncAt(time.Now().UTC().Format(time.RFC3339))
|
||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"pushed": len(pushResult.Accepted),
|
"pushed": len(pushResult.Accepted),
|
||||||
"pulled": len(pullResult.Ops),
|
"pulled": len(pullResult.Ops),
|
||||||
"serverRevision": pullResult.ServerRevision,
|
"serverSequence": pullResult.ServerSequence,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -16,8 +16,8 @@
|
||||||
background: #13131f;
|
background: #13131f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/main-DZkGJWBF.js"></script>
|
<script type="module" crossorigin src="/assets/main-CvznySlT.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-BnVt-oqm.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-Bkv7FuGB.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -704,12 +704,12 @@ func runSyncPull(args []string) {
|
||||||
syncSvc := syncsvc.NewService(db, deviceID)
|
syncSvc := syncsvc.NewService(db, deviceID)
|
||||||
client := syncsvc.NewClient(cfg.Sync.ServerURL, cfg.Sync.APIKey, deviceID, abs)
|
client := syncsvc.NewClient(cfg.Sync.ServerURL, cfg.Sync.APIKey, deviceID, abs)
|
||||||
|
|
||||||
_, _, lastRev, _, err := syncSvc.GetState()
|
_, _, lastSeq, _, err := syncSvc.GetState()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastRev = 0
|
lastSeq = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := client.Pull(lastRev)
|
result, err := client.Pull(lastSeq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Pull failed: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Pull failed: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|
@ -725,7 +725,7 @@ func runSyncPull(args []string) {
|
||||||
syncSvc.MarkApplied(opIDs)
|
syncSvc.MarkApplied(opIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Pulled %d ops (server rev: %d)\n", len(result.Ops), result.ServerRevision)
|
fmt.Printf("Pulled %d ops (server seq: %d)\n", len(result.Ops), result.ServerSequence)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runSyncStatus(args []string) {
|
func runSyncStatus(args []string) {
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,9 @@ sudo ./verstak-server/install.sh \
|
||||||
|
|
||||||
### Что там есть
|
### Что там есть
|
||||||
|
|
||||||
- **Дашборд** — статистика: количество устройств, количество операций, список API-ключей.
|
- **Дашборд** — статистика: количество устройств, количество операций, список API-ключей, форма настройки SMTP.
|
||||||
- **Управление API-ключами** — просмотр, создание и удаление ключей устройств.
|
- **Управление API-ключами** — просмотр, создание и удаление ключей устройств.
|
||||||
|
- **Настройка SMTP** — сервер, порт, логин, пароль, email отправителя, URL сервера (для ссылок в письмах). SMTP нужен для отправки писем подтверждения email и сброса пароля.
|
||||||
|
|
||||||
Сессия живёт 24 часа. После перезапуска сервера все сессии сбрасываются (хранятся в памяти).
|
Сессия живёт 24 часа. После перезапуска сервера все сессии сбрасываются (хранятся в памяти).
|
||||||
|
|
||||||
|
|
@ -69,7 +70,28 @@ sudo ./verstak-server/install.sh \
|
||||||
|
|
||||||
Можно запустить сервер с другими `--admin-user`/`--admin-pass` — добавится второй администратор. Повторный запуск с тем же именем меняет пароль.
|
Можно запустить сервер с другими `--admin-user`/`--admin-pass` — добавится второй администратор. Повторный запуск с тем же именем меняет пароль.
|
||||||
|
|
||||||
## 4. API-ключи (устройства)
|
## 4. Регистрация пользователей
|
||||||
|
|
||||||
|
### Как зарегистрироваться
|
||||||
|
|
||||||
|
Откройте в браузере: `http://<сервер>:47732/register`
|
||||||
|
|
||||||
|
Форма принимает:
|
||||||
|
- **Логин** — латинские буквы и цифры
|
||||||
|
- **Email** — для подтверждения и сброса пароля
|
||||||
|
- **Пароль** — минимум 8 символов, латинские буквы и цифры
|
||||||
|
|
||||||
|
После отправки формы на email приходит письмо с ссылкой подтверждения (если SMTP настроен). Если SMTP не настроен, подтверждение происходит автоматически, и можно сразу логиниться.
|
||||||
|
|
||||||
|
### Как войти
|
||||||
|
|
||||||
|
`http://<сервер>:47732/login` — форма логина принимает логин или email.
|
||||||
|
|
||||||
|
### Личный кабинет
|
||||||
|
|
||||||
|
После входа — `/dashboard` — список устройств пользователя с полными API-ключами, кнопками копирования и удаления, форма добавления нового устройства.
|
||||||
|
|
||||||
|
## 5. API-ключи (устройства)
|
||||||
|
|
||||||
### Что такое API-ключ
|
### Что такое API-ключ
|
||||||
|
|
||||||
|
|
@ -77,21 +99,27 @@ API-ключ — это токен, который клиент (Верстак
|
||||||
|
|
||||||
### Как создать
|
### Как создать
|
||||||
|
|
||||||
Через админ-панель:
|
#### Через веб-интерфейс пользователя
|
||||||
1. Зайти в `/admin/dashboard`.
|
|
||||||
|
1. Зайти в `/login`, войти.
|
||||||
|
2. В `/dashboard` ввести имя устройства и нажать "Connect".
|
||||||
|
3. Скопировать сгенерированный ключ.
|
||||||
|
|
||||||
|
#### Через админ-панель
|
||||||
|
|
||||||
|
1. Зайти в `/admin/login`.
|
||||||
2. В разделе "API Keys" ввести имя устройства и нажать "Create".
|
2. В разделе "API Keys" ввести имя устройства и нажать "Create".
|
||||||
3. Скопировать сгенерированный ключ.
|
3. Скопировать сгенерированный ключ.
|
||||||
|
|
||||||
Через API (требует логин и пароль администратора):
|
#### Через API (требует логин+пароль пользователя)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:47732/api/v1/device/register \
|
curl -X POST http://localhost:47732/api/v1/device/register \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"name":"мой-ноутбук","username":"admin","password":"пароль-админа"}'
|
-d '{"name":"мой-ноутбук","username":"ivan","password":"пароль-пользователя"}'
|
||||||
```
|
```
|
||||||
Ответ: `{"device_id":"...","api_key":"..."}`
|
Ответ: `{"device_id":"...","api_key":"..."}`
|
||||||
|
|
||||||
**Важно:** не выставляйте сервер в интернет без HTTPS (через reverse proxy). До создания полноценной системы пользователей регистрация устройств требует учётных данных администратора.
|
|
||||||
|
|
||||||
### Как использовать
|
### Как использовать
|
||||||
|
|
||||||
Ключ передаётся в заголовке `Authorization: Bearer <ключ>`:
|
Ключ передаётся в заголовке `Authorization: Bearer <ключ>`:
|
||||||
|
|
@ -102,11 +130,11 @@ curl -X POST http://localhost:47732/api/v1/sync/push \
|
||||||
-d '{"device_id":"b10c5d8e3f2a","ops":[...]}'
|
-d '{"device_id":"b10c5d8e3f2a","ops":[...]}'
|
||||||
```
|
```
|
||||||
|
|
||||||
В клиенте Верстак достаточно вбить URL сервера и API-ключ в настройках (GUI Settings или `config.yml`).
|
В GUI Верстак достаточно указать URL сервера, логин и пароль — устройство зарегистрируется автоматически.
|
||||||
|
|
||||||
### Один ключ на все устройства или отдельный на каждое?
|
### Один ключ на все устройства или отдельный на каждое?
|
||||||
|
|
||||||
**На каждое устройство — отдельный ключ.** Так вы сможете отозвать доступ конкретному устройству (удалить ключ в админ-панели), не затронув остальные.
|
**На каждое устройство — отдельный ключ.** Так вы сможете отозвать доступ конкретному устройству (удалить ключ в личном кабинете), не затронув остальные.
|
||||||
|
|
||||||
Технически один и тот же ключ можно использовать на нескольких устройствах, но:
|
Технически один и тот же ключ можно использовать на нескольких устройствах, но:
|
||||||
- его нельзя будет удалить, не отключив все устройства разом;
|
- его нельзя будет удалить, не отключив все устройства разом;
|
||||||
|
|
@ -115,7 +143,7 @@ curl -X POST http://localhost:47732/api/v1/sync/push \
|
||||||
|
|
||||||
**Рекомендация:** создавайте отдельный ключ для каждого клиента (ПК, ноутбук, телефон).
|
**Рекомендация:** создавайте отдельный ключ для каждого клиента (ПК, ноутбук, телефон).
|
||||||
|
|
||||||
## 5. Настройка клиента
|
## 6. Настройка клиента
|
||||||
|
|
||||||
### CLI
|
### CLI
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -133,25 +161,35 @@ sync:
|
||||||
auto_sync: false
|
auto_sync: false
|
||||||
```
|
```
|
||||||
|
|
||||||
### GUI
|
API-ключ и device_id можно получить, зарегистрировав устройство через API:
|
||||||
В графическом интерфейсе нажмите на иконку шестерёнки в левом нижнем углу → откроется окно настроек синхронизации. Укажите URL сервера и API-ключ, нажмите "Сохранить". Кнопка "Синхронизировать" запускает push + pull.
|
```bash
|
||||||
|
curl -s http://сервер:47732/api/v1/device/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"мой-пк","username":"ivan","password":"мой-пароль"}' | jq .
|
||||||
|
```
|
||||||
|
|
||||||
## 6. Безопасность
|
### GUI
|
||||||
|
В графическом интерфейсе нажмите на иконку шестерёнки в левом нижнем углу → откроется окно настроек синхронизации. Укажите URL сервера, логин и пароль, нажмите "Test Connection" для проверки, затем "Сохранить". Кнопка "Синхронизировать" (или пункт меню в сайдбаре) запускает push + pull.
|
||||||
|
|
||||||
|
## 7. Безопасность
|
||||||
|
|
||||||
Сервер **не поддерживает HTTPS**. В production используйте reverse proxy (nginx, Caddy) для терминирования TLS.
|
Сервер **не поддерживает HTTPS**. В production используйте reverse proxy (nginx, Caddy) для терминирования TLS.
|
||||||
|
|
||||||
Что стоит учесть:
|
Что стоит учесть:
|
||||||
- Регистрация устройств открыта — любой, кто достучался до сервера, может создать ключ.
|
- Регистрация устройств требует аутентификации пользователя.
|
||||||
- Нет logout'а — сессия живёт 24 часа или до перезапуска сервера.
|
- Подтверждение email обязательно, если настроен SMTP (иначе подтверждение автоматическое).
|
||||||
|
- Нет logout'а для админа — сессия живёт 24 часа или до перезапуска сервера.
|
||||||
- Нет rate limiting'а — возможен перебор пароля.
|
- Нет rate limiting'а — возможен перебор пароля.
|
||||||
- Пароль хранится в bcrypt — база данных не должна быть общедоступной.
|
- Пароли хранятся в bcrypt — база данных не должна быть общедоступной.
|
||||||
|
- SMTP-пароль хранится в открытом виде в конфиге сервера.
|
||||||
|
|
||||||
Рекомендации для production:
|
Рекомендации для production:
|
||||||
- Закрыть порт сервера фаерволом (только доверенные IP).
|
- Закрыть порт сервера фаерволом (только доверенные IP).
|
||||||
- Использовать VPN (WireGuard/OpenVPN) для доступа между устройствами.
|
- Использовать VPN (WireGuard/OpenVPN) для доступа между устройствами.
|
||||||
- Или поставить nginx/Caddy перед сервером с HTTPS и базовой аутентификацией на `/api/v1/device/register`.
|
- Поставить nginx/Caddy перед сервером с HTTPS.
|
||||||
|
- Настроить SMTP для полноценного подтверждения email и сброса пароля.
|
||||||
|
|
||||||
## 7. Полный API
|
## 8. Полный API
|
||||||
|
|
||||||
### Открытые endpoint'ы
|
### Открытые endpoint'ы
|
||||||
|
|
||||||
|
|
@ -168,11 +206,22 @@ sync:
|
||||||
| POST | `/api/v1/blobs/` | Загрузить blob (multipart) |
|
| POST | `/api/v1/blobs/` | Загрузить blob (multipart) |
|
||||||
| GET | `/api/v1/blobs/{sha256}` | Скачать blob |
|
| GET | `/api/v1/blobs/{sha256}` | Скачать blob |
|
||||||
|
|
||||||
### Требуют логин+пароль администратора
|
### Требуют логин+пароль (body JSON)
|
||||||
|
|
||||||
| Метод | Путь | Описание |
|
| Метод | Путь | Описание |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| POST | `/api/v1/device/register` | Регистрация устройства (body: name + username + password) |
|
| POST | `/api/v1/auth/register` | Регистрация (username, email, password) |
|
||||||
|
| GET | `/api/v1/auth/confirm?token=` | Подтверждение email |
|
||||||
|
| POST | `/api/v1/auth/login` | Вход, возвращает Bearer-токен |
|
||||||
|
| POST | `/api/v1/auth/forgot` | Запрос сброса пароля (email) |
|
||||||
|
| POST | `/api/v1/auth/reset` | Сброс пароля (token, password) |
|
||||||
|
| POST | `/api/v1/device/register` | Регистрация устройства (name, username, password) |
|
||||||
|
|
||||||
|
### Требуют сессию пользователя (Bearer token или cookie)
|
||||||
|
|
||||||
|
| Метод | Путь | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/v1/user/devices` | Список устройств пользователя |
|
||||||
|
|
||||||
### Требуют сессию админа (cookie)
|
### Требуют сессию админа (cookie)
|
||||||
|
|
||||||
|
|
@ -183,3 +232,13 @@ sync:
|
||||||
| GET | `/admin/api/keys` | Список API-ключей (JSON) |
|
| GET | `/admin/api/keys` | Список API-ключей (JSON) |
|
||||||
| POST | `/admin/api/keys` | Создать ключ |
|
| POST | `/admin/api/keys` | Создать ключ |
|
||||||
| DELETE | `/admin/api/keys/{id}` | Удалить ключ |
|
| DELETE | `/admin/api/keys/{id}` | Удалить ключ |
|
||||||
|
| POST | `/admin/api/smtp` | Сохранить SMTP-конфигурацию |
|
||||||
|
|
||||||
|
### Пользовательский веб-интерфейс (cookie)
|
||||||
|
|
||||||
|
| Метод | Путь | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| GET/POST | `/register` | Форма регистрации |
|
||||||
|
| GET/POST | `/login` | Форма входа |
|
||||||
|
| GET | `/dashboard` | Личный кабинет (устройства) |
|
||||||
|
| GET | `/logout` | Выход |
|
||||||
|
|
|
||||||
|
|
@ -925,7 +925,22 @@
|
||||||
if (syncInterval > 0) {
|
if (syncInterval > 0) {
|
||||||
await wailsCall('SyncSetInterval', syncInterval)
|
await wailsCall('SyncSetInterval', syncInterval)
|
||||||
}
|
}
|
||||||
syncResult = 'ok'
|
syncPassword = ''
|
||||||
|
syncUsername = ''
|
||||||
|
await loadSyncStatus()
|
||||||
|
showSettings = false
|
||||||
|
} catch (e) {
|
||||||
|
syncResult = 'err: ' + String(e)
|
||||||
|
}
|
||||||
|
syncLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSyncInterval() {
|
||||||
|
syncLoading = true
|
||||||
|
syncResult = ''
|
||||||
|
try {
|
||||||
|
await wailsCall('SyncSetInterval', syncInterval)
|
||||||
|
syncResult = 'интервал сохранён'
|
||||||
await loadSyncStatus()
|
await loadSyncStatus()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
syncResult = 'err: ' + String(e)
|
syncResult = 'err: ' + String(e)
|
||||||
|
|
@ -945,12 +960,25 @@
|
||||||
syncLoading = false
|
syncLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function disconnectSync() {
|
||||||
|
syncLoading = true
|
||||||
|
syncResult = ''
|
||||||
|
try {
|
||||||
|
await wailsCall('SyncDisconnect')
|
||||||
|
syncResult = 'disconnected'
|
||||||
|
await loadSyncStatus()
|
||||||
|
} catch (e) {
|
||||||
|
syncResult = 'err: ' + String(e)
|
||||||
|
}
|
||||||
|
syncLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
async function runSyncNow() {
|
async function runSyncNow() {
|
||||||
syncLoading = true
|
syncLoading = true
|
||||||
syncResult = ''
|
syncResult = ''
|
||||||
try {
|
try {
|
||||||
const r = await wailsCall('SyncNow')
|
const r = await wailsCall('SyncNow')
|
||||||
syncResult = 'pushed ' + r.pushed + ', pulled ' + r.pulled + ' (rev ' + r.serverRevision + ')'
|
syncResult = 'pushed ' + r.pushed + ', pulled ' + r.pulled + ' (seq ' + r.serverSequence + ')'
|
||||||
await loadSyncStatus()
|
await loadSyncStatus()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
syncResult = 'err: ' + String(e)
|
syncResult = 'err: ' + String(e)
|
||||||
|
|
@ -1524,13 +1552,42 @@
|
||||||
<h3>Настройки синхронизации</h3>
|
<h3>Настройки синхронизации</h3>
|
||||||
{#if syncStatus}
|
{#if syncStatus}
|
||||||
<div class="sync-status">
|
<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">
|
||||||
<div class="sync-row"><span class="sync-label">Сервер</span><span class="sync-value mono">{syncStatus.serverUrl || '—'}</span></div>
|
<span class="sync-label">Статус</span>
|
||||||
<div class="sync-row"><span class="sync-label">Устройство</span><span class="sync-value mono">{syncStatus.deviceId || '—'}</span></div>
|
<span class="sync-value">
|
||||||
|
{#if syncStatus.revoked}
|
||||||
|
<span style="color:#ff6b6b">Отозвано</span>
|
||||||
|
{:else if syncStatus.connected}
|
||||||
|
<span style="color:#34d399">Подключено</span>
|
||||||
|
{:else if syncStatus.configured}
|
||||||
|
<span style="color:#f59e0b">Не подключено</span>
|
||||||
|
{:else}
|
||||||
|
<span style="color:#666">Отключена</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if syncStatus.serverUrl}
|
||||||
|
<div class="sync-row"><span class="sync-label">Сервер</span><span class="sync-value mono">{syncStatus.serverUrl}</span></div>
|
||||||
|
{/if}
|
||||||
|
{#if syncStatus.deviceName}
|
||||||
|
<div class="sync-row"><span class="sync-label">Устройство</span><span class="sync-value">{syncStatus.deviceName}</span></div>
|
||||||
|
{/if}
|
||||||
|
{#if syncStatus.deviceId && !syncStatus.deviceName}
|
||||||
|
<div class="sync-row"><span class="sync-label">ID устройства</span><span class="sync-value mono">{syncStatus.deviceId}</span></div>
|
||||||
|
{/if}
|
||||||
<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.unpushedOps}</span></div>
|
||||||
<div class="sync-row"><span class="sync-label">Последняя синх.</span><span class="sync-value">{syncStatus.lastSyncAt || '—'}</span></div>
|
{#if syncStatus.lastSyncAt}
|
||||||
|
<div class="sync-row"><span class="sync-label">Последняя синх.</span><span class="sync-value">{syncStatus.lastSyncAt}</span></div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if syncStatus?.configured}
|
||||||
|
<div class="sync-connected-actions">
|
||||||
|
<button class="btn" on:click={runSyncNow} disabled={syncLoading}>Синхронизировать</button>
|
||||||
|
<button class="btn btn-danger" on:click={disconnectSync} disabled={syncLoading}>Отключиться</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>URL сервера</label>
|
<label>URL сервера</label>
|
||||||
<input type="text" placeholder="https://example.com:47732" bind:value={syncServerUrl} />
|
<input type="text" placeholder="https://example.com:47732" bind:value={syncServerUrl} />
|
||||||
|
|
@ -1543,17 +1600,25 @@
|
||||||
<label>Пароль</label>
|
<label>Пароль</label>
|
||||||
<input type="password" placeholder="password" bind:value={syncPassword} />
|
<input type="password" placeholder="password" bind:value={syncPassword} />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="modal-actions" style="margin-top:12px">
|
||||||
<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" on:click={testConnection} disabled={syncLoading || !syncServerUrl}>Проверить</button>
|
||||||
<button class="btn btn-primary" on:click={saveSyncConfig} disabled={syncLoading}>Подключиться</button>
|
<button class="btn btn-primary" on:click={saveSyncConfig} disabled={syncLoading}>Подключиться</button>
|
||||||
<button class="btn" on:click={runSyncNow} disabled={syncLoading || !syncStatus?.configured}>Синхронизировать</button>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div style="margin-top:16px;padding-top:16px;border-top:1px solid #2a2a3c">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Автосинхронизация (мин, 0 = отключено)</label>
|
||||||
|
<input type="number" placeholder="0" bind:value={syncInterval} min="0" />
|
||||||
|
</div>
|
||||||
|
<button class="btn" on:click={saveSyncInterval} disabled={syncLoading}>Сохранить интервал</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if syncResult}
|
||||||
|
<div class="sync-result" style="margin-top:8px">{syncResult}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="modal-actions" style="margin-top:12px">
|
||||||
<button class="btn" on:click={closeSettings}>Закрыть</button>
|
<button class="btn" on:click={closeSettings}>Закрыть</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1586,8 +1651,8 @@
|
||||||
.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-left { display: flex; align-items: center; gap: 8px; flex: 1; }
|
||||||
.header-right { display: flex; align-items: center; gap: 8px; }
|
.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 { background: #1e1e38; border: 1px solid #6366f1; border-radius: 8px; padding: 6px 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; color: #c0c0f0; font-family: inherit; font-size: 13px; position: relative; }
|
||||||
.header-sync-btn:hover { background: #222233; color: #e4e4ef; border-color: #6366f1; }
|
.header-sync-btn:hover { background: #2a2a50; color: #e4e4ef; border-color: #818cf8; }
|
||||||
.header-sync-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.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; }
|
.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; }
|
||||||
|
|
@ -1778,8 +1843,8 @@
|
||||||
.activity-feed-time { font-size: 11px; color: #555; }
|
.activity-feed-time { font-size: 11px; color: #555; }
|
||||||
|
|
||||||
/* Sync */
|
/* 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 { background: #1e1e38; border: 1px solid #6366f1; border-radius: 8px; padding: 8px 12px; cursor: pointer; width: 100%; display: flex; align-items: center; gap: 8px; color: #c0c0f0; font-family: inherit; font-size: 13px; }
|
||||||
.sidebar-sync-btn:hover { background: #222233; color: #e4e4ef; border-color: #6366f1; }
|
.sidebar-sync-btn:hover { background: #2a2a50; color: #e4e4ef; border-color: #818cf8; }
|
||||||
.sidebar-sync-label { flex: 1; text-align: left; }
|
.sidebar-sync-label { flex: 1; text-align: left; }
|
||||||
.sync-dot { width: 8px; height: 8px; border-radius: 50%; background: #4a4a4a; flex-shrink: 0; }
|
.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); }
|
.sync-dot.active { background: #4ade80; box-shadow: 0 0 6px rgba(74,222,128,0.5); }
|
||||||
|
|
@ -1790,4 +1855,5 @@
|
||||||
.sync-value { color: #e4e4ef; }
|
.sync-value { color: #e4e4ef; }
|
||||||
.sync-value.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
|
.sync-value.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
|
||||||
.sync-result { font-size: 12px; color: #6366f1; padding: 4px 0; }
|
.sync-result { font-size: 12px; color: #6366f1; padding: 4px 0; }
|
||||||
|
.sync-connected-actions { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -5,6 +5,7 @@ go 1.25.0
|
||||||
require (
|
require (
|
||||||
github.com/mattn/go-sqlite3 v1.14.44
|
github.com/mattn/go-sqlite3 v1.14.44
|
||||||
github.com/wailsapp/wails/v2 v2.12.0
|
github.com/wailsapp/wails/v2 v2.12.0
|
||||||
|
golang.org/x/crypto v0.33.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -33,7 +34,6 @@ require (
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
golang.org/x/crypto v0.33.0 // indirect
|
|
||||||
golang.org/x/net v0.35.0 // indirect
|
golang.org/x/net v0.35.0 // indirect
|
||||||
golang.org/x/sys v0.30.0 // indirect
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
golang.org/x/text v0.22.0 // indirect
|
golang.org/x/text v0.22.0 // indirect
|
||||||
|
|
|
||||||
|
|
@ -67,3 +67,41 @@ func Save(vaultRoot string, cfg *Config) error {
|
||||||
func MetaDir(vaultRoot string) string {
|
func MetaDir(vaultRoot string) string {
|
||||||
return filepath.Join(vaultRoot, ".verstak")
|
return filepath.Join(vaultRoot, ".verstak")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeviceTokenPath returns the path to the device_token file.
|
||||||
|
func DeviceTokenPath(vaultRoot string) string {
|
||||||
|
return filepath.Join(vaultRoot, ".verstak", "device_token.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveDeviceToken writes the device token to a separate file with 0600 perms.
|
||||||
|
func SaveDeviceToken(vaultRoot, token string) error {
|
||||||
|
path := DeviceTokenPath(vaultRoot)
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data := fmt.Sprintf(`{"device_token":%q}`, token)
|
||||||
|
return os.WriteFile(path, []byte(data), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadDeviceToken reads the device token from the separate file.
|
||||||
|
func LoadDeviceToken(vaultRoot string) string {
|
||||||
|
path := DeviceTokenPath(vaultRoot)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var v struct {
|
||||||
|
DeviceToken string `yaml:"device_token"`
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(data, &v); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return v.DeviceToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveDeviceToken deletes the device token file.
|
||||||
|
func RemoveDeviceToken(vaultRoot string) error {
|
||||||
|
path := DeviceTokenPath(vaultRoot)
|
||||||
|
return os.Remove(path)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
// migration011 — add last_pull_seq to sync_state.
|
||||||
|
const migration011 = `
|
||||||
|
ALTER TABLE sync_state ADD COLUMN last_pull_seq INTEGER NOT NULL DEFAULT 0;
|
||||||
|
`
|
||||||
|
|
@ -67,6 +67,7 @@ var migrationFiles = map[int]string{
|
||||||
8: migration008,
|
8: migration008,
|
||||||
9: migration009,
|
9: migration009,
|
||||||
10: migration010,
|
10: migration010,
|
||||||
|
11: migration011,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) runInitialSchema() error {
|
func (db *DB) runInitialSchema() error {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ import (
|
||||||
// Client communicates with the Verstak Sync Server.
|
// Client communicates with the Verstak Sync Server.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
ServerURL string
|
ServerURL string
|
||||||
APIKey string
|
APIKey string // legacy API key
|
||||||
|
DeviceToken string // new device token
|
||||||
DeviceID string
|
DeviceID string
|
||||||
VaultRoot string
|
VaultRoot string
|
||||||
HTTP *http.Client
|
HTTP *http.Client
|
||||||
|
|
@ -32,6 +33,56 @@ func NewClient(serverURL, apiKey, deviceID, vaultRoot string) *Client {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PairDevice calls POST /api/client/pair and returns device_id and device_token.
|
||||||
|
func (c *Client) PairDevice(serverURL, username, password, deviceName, clientVersion string) (deviceID, deviceToken string, err error) {
|
||||||
|
body := map[string]string{
|
||||||
|
"login": username,
|
||||||
|
"password": password,
|
||||||
|
"device_name": deviceName,
|
||||||
|
"client_version": clientVersion,
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
DeviceToken string `json:"device_token"`
|
||||||
|
}
|
||||||
|
savedURL := c.ServerURL
|
||||||
|
c.ServerURL = serverURL
|
||||||
|
err = c.post("/api/client/pair", body, &resp)
|
||||||
|
c.ServerURL = savedURL
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return resp.DeviceID, resp.DeviceToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMe calls GET /api/client/me and returns device info.
|
||||||
|
type DeviceInfo struct {
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
DeviceName string `json:"device_name"`
|
||||||
|
ClientVersion string `json:"client_version"`
|
||||||
|
LastSeen string `json:"last_seen"`
|
||||||
|
RevokedAt string `json:"revoked_at"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetMe() (*DeviceInfo, error) {
|
||||||
|
var resp DeviceInfo
|
||||||
|
if err := c.get("/api/client/me", &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeCurrent calls POST /api/client/revoke-current.
|
||||||
|
func (c *Client) RevokeCurrent() error {
|
||||||
|
var resp struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
return c.post("/api/client/revoke-current", nil, &resp)
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterDevice calls POST /api/v1/device/register and returns the API key.
|
// RegisterDevice calls POST /api/v1/device/register and returns the API key.
|
||||||
func (c *Client) RegisterDevice(name string) (apiKey string, err error) {
|
func (c *Client) RegisterDevice(name string) (apiKey string, err error) {
|
||||||
body := map[string]string{"name": name}
|
body := map[string]string{"name": name}
|
||||||
|
|
@ -82,6 +133,7 @@ func (c *Client) Login(username, password string) (token string, err error) {
|
||||||
// PushRequest is the payload for POST /sync/push.
|
// PushRequest is the payload for POST /sync/push.
|
||||||
type PushRequest struct {
|
type PushRequest struct {
|
||||||
DeviceID string `json:"device_id"`
|
DeviceID string `json:"device_id"`
|
||||||
|
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||||
Ops []PushOp `json:"ops"`
|
Ops []PushOp `json:"ops"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,6 +144,8 @@ type PushOp struct {
|
||||||
EntityID string `json:"entity_id"`
|
EntityID string `json:"entity_id"`
|
||||||
OpType string `json:"op_type"`
|
OpType string `json:"op_type"`
|
||||||
PayloadJSON string `json:"payload_json"`
|
PayloadJSON string `json:"payload_json"`
|
||||||
|
ClientSequence int `json:"client_sequence"`
|
||||||
|
LastSeenServerSeq int `json:"last_seen_server_seq"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,6 +153,7 @@ type PushOp struct {
|
||||||
type PushResponse struct {
|
type PushResponse struct {
|
||||||
Accepted []string `json:"accepted"`
|
Accepted []string `json:"accepted"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
|
Conflicts []map[string]interface{} `json:"conflicts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push sends local operations to the server.
|
// Push sends local operations to the server.
|
||||||
|
|
@ -124,18 +179,18 @@ func (c *Client) Push(ops []Op) (*PushResponse, error) {
|
||||||
|
|
||||||
// PullRequest is the payload for POST /sync/pull.
|
// PullRequest is the payload for POST /sync/pull.
|
||||||
type PullRequest struct {
|
type PullRequest struct {
|
||||||
SinceRevision int `json:"since_revision"`
|
SinceSequence int `json:"since_sequence"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PullResponse is the response from POST /sync/pull.
|
// PullResponse is the response from POST /sync/pull.
|
||||||
type PullResponse struct {
|
type PullResponse struct {
|
||||||
ServerRevision int `json:"server_revision"`
|
ServerSequence int `json:"server_sequence"`
|
||||||
Ops []Op `json:"ops"`
|
Ops []Op `json:"ops"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pull fetches remote operations since a given revision.
|
// Pull fetches remote operations since a given sequence.
|
||||||
func (c *Client) Pull(sinceRevision int) (*PullResponse, error) {
|
func (c *Client) Pull(sinceSequence int) (*PullResponse, error) {
|
||||||
req := PullRequest{SinceRevision: sinceRevision}
|
req := PullRequest{SinceSequence: sinceSequence}
|
||||||
var resp PullResponse
|
var resp PullResponse
|
||||||
if err := c.post("/api/v1/sync/pull", req, &resp); err != nil {
|
if err := c.post("/api/v1/sync/pull", req, &resp); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -166,7 +221,7 @@ func (c *Client) UploadBlob(localPath string) (sha256 string, err error) {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
|
||||||
|
|
||||||
resp, err := c.HTTP.Do(req)
|
resp, err := c.HTTP.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -190,7 +245,7 @@ func (c *Client) DownloadBlob(sha256, destPath string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
|
||||||
|
|
||||||
resp, err := c.HTTP.Do(req)
|
resp, err := c.HTTP.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -213,17 +268,50 @@ func (c *Client) DownloadBlob(sha256, destPath string) error {
|
||||||
|
|
||||||
// --- internal ---
|
// --- internal ---
|
||||||
|
|
||||||
|
func (c *Client) bearerToken() string {
|
||||||
|
if c.DeviceToken != "" {
|
||||||
|
return c.DeviceToken
|
||||||
|
}
|
||||||
|
return c.APIKey
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) post(path string, body, result interface{}) error {
|
func (c *Client) post(path string, body, result interface{}) error {
|
||||||
var b bytes.Buffer
|
var b bytes.Buffer
|
||||||
|
if body != nil {
|
||||||
if err := json.NewEncoder(&b).Encode(body); err != nil {
|
if err := json.NewEncoder(&b).Encode(body); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
req, err := http.NewRequest("POST", c.ServerURL+path, &b)
|
req, err := http.NewRequest("POST", c.ServerURL+path, &b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) get(path string, result interface{}) error {
|
||||||
|
req, err := http.NewRequest("GET", c.ServerURL+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
|
||||||
|
|
||||||
resp, err := c.HTTP.Do(req)
|
resp, err := c.HTTP.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ const (
|
||||||
type Op struct {
|
type Op struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
OpID string `json:"op_id"`
|
OpID string `json:"op_id"`
|
||||||
|
ServerSequence int `json:"server_sequence,omitempty"`
|
||||||
DeviceID string `json:"device_id,omitempty"`
|
DeviceID string `json:"device_id,omitempty"`
|
||||||
EntityType string `json:"entity_type"`
|
EntityType string `json:"entity_type"`
|
||||||
EntityID string `json:"entity_id"`
|
EntityID string `json:"entity_id"`
|
||||||
|
|
@ -74,6 +75,17 @@ func (s *Service) RecordOp(entityType, entityID, opType string, payload interfac
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecordRemoteOp writes a remote op to the local sync_ops table (already applied server-side).
|
||||||
|
func (s *Service) RecordRemoteOp(op Op) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`INSERT OR IGNORE INTO sync_ops (id, op_id, device_id, entity_type, entity_id, op_type, payload_json, created_at, pushed_at, applied_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
op.OpID+"-remote", op.OpID, op.DeviceID, op.EntityType, op.EntityID, op.OpType, op.PayloadJSON, op.CreatedAt, now, now,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// GetUnpushedOps returns ops that have not been pushed yet.
|
// GetUnpushedOps returns ops that have not been pushed yet.
|
||||||
func (s *Service) GetUnpushedOps() ([]Op, error) {
|
func (s *Service) GetUnpushedOps() ([]Op, error) {
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
|
|
@ -111,10 +123,10 @@ func (s *Service) MarkApplied(opIDs []string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetState returns the current sync state.
|
// GetState returns the current sync state.
|
||||||
func (s *Service) GetState() (serverURL, apiKey string, lastPushRev int, lastSyncAt string, err error) {
|
func (s *Service) GetState() (serverURL, apiKey string, lastPullSeq int, lastSyncAt string, err error) {
|
||||||
err = s.db.QueryRow(
|
err = s.db.QueryRow(
|
||||||
`SELECT server_url, api_key, last_push_rev, COALESCE(last_sync_at,'') FROM sync_state WHERE device_id=?`,
|
`SELECT server_url, api_key, last_pull_seq, COALESCE(last_sync_at,'') FROM sync_state WHERE device_id=?`,
|
||||||
s.deviceID).Scan(&serverURL, &apiKey, &lastPushRev, &lastSyncAt)
|
s.deviceID).Scan(&serverURL, &apiKey, &lastPullSeq, &lastSyncAt)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return "", "", 0, "", nil
|
return "", "", 0, "", nil
|
||||||
}
|
}
|
||||||
|
|
@ -124,14 +136,33 @@ func (s *Service) GetState() (serverURL, apiKey string, lastPushRev int, lastSyn
|
||||||
// SetState saves sync connection state.
|
// SetState saves sync connection state.
|
||||||
func (s *Service) SetState(serverURL, apiKey string) error {
|
func (s *Service) SetState(serverURL, apiKey string) error {
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`INSERT INTO sync_state (device_id, server_url, api_key, last_push_rev, last_sync_at)
|
`INSERT INTO sync_state (device_id, server_url, api_key, last_pull_seq, last_sync_at)
|
||||||
VALUES (?, ?, ?, 0, '')
|
VALUES (?, ?, ?, 0, '')
|
||||||
ON CONFLICT(device_id) DO UPDATE SET server_url=excluded.server_url, api_key=excluded.api_key`,
|
ON CONFLICT(device_id) DO UPDATE SET
|
||||||
|
server_url=excluded.server_url,
|
||||||
|
api_key=excluded.api_key`,
|
||||||
s.deviceID, serverURL, apiKey,
|
s.deviceID, serverURL, apiKey,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLastPullSeq updates the last pulled server sequence.
|
||||||
|
func (s *Service) SetLastPullSeq(seq int) error {
|
||||||
|
_, err := s.db.Exec("UPDATE sync_state SET last_pull_seq=? WHERE device_id=?", seq, s.deviceID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeviceID returns the device ID used by this service.
|
||||||
|
func (s *Service) GetDeviceID() string {
|
||||||
|
return s.deviceID
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLastSyncAt updates the last sync timestamp.
|
||||||
|
func (s *Service) SetLastSyncAt(t string) error {
|
||||||
|
_, err := s.db.Exec("UPDATE sync_state SET last_sync_at=? WHERE device_id=?", t, s.deviceID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
|
|
||||||
func scanOps(rows *sql.Rows) ([]Op, error) {
|
func scanOps(rows *sql.Rows) ([]Op, error) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue