21 changed files with 1933 additions and 351 deletions
+1
View File
@@ -13,3 +13,4 @@ server-data/
# OS
.DS_Store
Thumbs.db
release/
+17
View File
@@ -0,0 +1,17 @@
Verstak Sync Server
Copyright (C) 2026 Verstak contributors
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU Affero General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option) any
later version.
This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
details.
The complete license text is available at:
https://www.gnu.org/licenses/agpl-3.0.html
SPDX-License-Identifier: AGPL-3.0-or-later
+156 -8
View File
@@ -7,7 +7,7 @@ Standalone sync server for Verstak2 platform.
This server provides synchronization between devices running Verstak2. It handles:
- Device registration and authentication
- Operational transform-based sync
- Vault-scoped operation log sync with server sequence numbers and conflict detection
- Blob storage for attachments
- User management with email confirmation
@@ -24,6 +24,29 @@ This server provides synchronization between devices running Verstak2. It handle
./build/bin/verstak-sync-server --admin-user admin --admin-pass secret
```
## Release packages
Build a Linux amd64 archive locally:
```bash
./scripts/release.sh v0.1.0-alpha.1
```
It runs the server build and tests, then writes
`release/verstak-sync-server-linux-amd64-<version>.tar.gz` and `SHA256SUMS`.
The archive contains the server binary, systemd service file and install
script.
Publish those same assets to GitHub Releases:
```bash
./scripts/publish-github-release.sh v0.1.0-alpha.1
```
The publisher requires an authenticated [`gh`](https://cli.github.com/) CLI
and a clean local `main` equal to `origin/main`. It creates and pushes an
annotated tag when necessary, then creates or updates the GitHub Release.
## Configuration
| Flag | Default | Description |
@@ -33,23 +56,147 @@ This server provides synchronization between devices running Verstak2. It handle
| `--admin-user` | | Create admin user (first run) |
| `--admin-pass` | | Admin password (first run) |
Production installs use:
- binary: `/opt/verstak-sync-server/verstak-sync-server`;
- data directory: `/var/lib/verstak-sync-server`;
- port environment file: `/etc/verstak-server/env`;
- service: `verstak-server`.
Install from a built binary:
```bash
./scripts/build.sh
sudo ./scripts/install.sh \
--bin ./build/bin/verstak-sync-server \
--port 47732 \
--admin-user admin \
--admin-pass 'change-this-password'
```
The install script creates a locked-down system user, initializes the data
directory, writes `/etc/verstak-server/env`, installs the systemd unit, and
starts the service.
## Deployment
Run the service behind HTTPS in production. The sync server itself listens on
plain HTTP; terminate TLS in a reverse proxy such as nginx, Caddy, or a platform
load balancer, then forward to `127.0.0.1:47732`.
Basic service operations:
```bash
sudo systemctl status verstak-server
sudo journalctl -u verstak-server -f
curl http://127.0.0.1:47732/api/v1/health
```
Change the listen port:
```bash
echo 'VERSTAK_PORT=47733' | sudo tee /etc/verstak-server/env
sudo systemctl restart verstak-server
```
Upgrade the binary:
```bash
./scripts/build.sh
sudo systemctl stop verstak-server
sudo install -m 755 ./build/bin/verstak-sync-server /opt/verstak-sync-server/verstak-sync-server
sudo systemctl start verstak-server
```
Keep `--data` stable across upgrades. The data directory is the server's source
of truth.
## Backup And Restore
Back up the full data directory while the service is stopped. It contains:
- `server.db` - SQLite database with users, devices, operations, SMTP settings,
and blob metadata;
- `config.yml` - admin user configuration;
- `blobs/` - content-addressed blob files.
Create a backup:
```bash
sudo systemctl stop verstak-server
sudo tar --xattrs --acls -czf verstak-sync-backup-$(date +%Y%m%d-%H%M%S).tar.gz \
-C /var/lib verstak-sync-server
sudo systemctl start verstak-server
```
Restore onto a fresh host or after data loss:
```bash
sudo systemctl stop verstak-server
sudo mv /var/lib/verstak-sync-server /var/lib/verstak-sync-server.broken.$(date +%Y%m%d-%H%M%S) 2>/dev/null || true
sudo tar --xattrs --acls -xzf verstak-sync-backup-YYYYMMDD-HHMMSS.tar.gz -C /var/lib
sudo chown -R verstak:verstak /var/lib/verstak-sync-server
sudo chmod 750 /var/lib/verstak-sync-server
sudo systemctl start verstak-server
curl http://127.0.0.1:${VERSTAK_PORT:-47732}/api/v1/health
```
After restore, connected desktop clients keep their existing device tokens.
If a backup is older than some client changes, those clients may need to run
sync again so unpushed local operations are re-sent.
## Architecture
```
cmd/server/ - Entry point
internal/server/ - Server implementation
- server.go - Core server logic
- handlers.go - HTTP handlers
- routes.go - HTTP routing
- handlers_api.go - Sync, client, health, and blob handlers
- handlers_auth.go - User auth API handlers
- handlers_admin.go - Admin web/API handlers
- schema.go - Database schema
```
## API Endpoints
- `POST /api/push` - Push operations to server
- `GET /api/pull` - Pull operations from server
- `POST /api/device/pair` - Pair device with token
- `POST /api/user/register` - Register new user
- `POST /api/user/login` - User login
Desktop sync client:
- `POST /api/client/pair` - Pair a desktop client with username/password and its persistent `vault_id`, then return a device token
- `POST /api/auth/test` - Validate username/password from the desktop client
- `GET /api/client/me` - Return current authenticated client/device details
- `POST /api/client/revoke-current` - Revoke the current desktop device token
- `POST /api/client/revoke-device` - Revoke another device owned by the same user
- `POST /api/v1/sync/push` - Push local operations to the server operation log
- `POST /api/v1/sync/pull` - Pull operations since a server sequence number
- `POST /api/v1/blobs/` - Store a multipart `file` blob and return its SHA-256 hash
- `GET /api/v1/blobs/{sha256}` - Download a stored blob by SHA-256 hash
User API:
- `POST /api/v1/auth/register` - Register a user
- `GET /api/v1/auth/confirm?token=...` - Confirm email
- `POST /api/v1/auth/login` - User login
- `POST /api/v1/auth/forgot` - Request password reset
- `POST /api/v1/auth/reset` - Reset password
- `GET /api/v1/user/devices` - List devices for the current user session
Operational endpoints:
- `GET /api/v1/health` - Server health and basic storage status
- `/admin/...` - Admin web UI and admin JSON endpoints
- `/register`, `/login`, `/dashboard`, `/forgot`, `/reset`, `/logout` - User web UI
Sync operations are generic records with `entity_type`, `entity_id`, `op_type`,
`payload_json`, `device_id`, and sequencing metadata. A pairing token is bound
to one user and vault. The server derives the stored device ID and operation
scope from that token, ignores a caller-supplied `device_id` for authorization,
and returns only operations and cursors from the authenticated user/vault.
Verstak desktop owns the v2 payload semantics.
New device enrollment requires a non-empty `vault_id`. The `legacy:` prefix is
reserved for server-side migration of older records and cannot be selected by
new clients.
## Development
@@ -63,4 +210,5 @@ CGO_ENABLED=1 go build -o verstak-sync-server ./cmd/server
## License
MIT
Copyright © 2026 Verstak contributors. Licensed under
[GNU AGPLv3 or later](LICENSE).
@@ -0,0 +1,99 @@
# Sync Tenant Isolation Implementation Plan
> **For agentic workers:** execute this plan task by task with focused tests
> before each implementation change.
**Goal:** prevent cross-user and cross-vault operation visibility while binding
the persisted source device to the authenticated token.
**Architecture:** the server derives user, device, and vault scope from the
bearer token. SQLite rows carry that scope; desktop sends the immutable vault
ID only while creating a pairing. Existing unscoped devices and operations are
migrated into a deterministic legacy scope.
**Tech stack:** Go, `database/sql`, SQLite, `net/http`, desktop Go sync client.
## Task 1: Establish server behaviour tests
**Files:**
- Modify: `internal/server/server_test.go`
- [x] Add helpers that create confirmed users and token-authenticated devices
with a specified vault ID.
- [x] Add a failing test where separate users push and pull from the same
vault ID; each pull must contain only its own operation and cursor.
- [x] Add a failing test where one user owns devices in two vaults; pulls must
remain vault-local.
- [x] Add a failing test that sends another device's ID in `push`; assert the
stored and returned operation uses the authenticated device ID.
- [x] Add a failing test for identical idempotency keys in different scopes.
- [x] Run: `go test ./internal/server -run 'TestSync.*Isolation|TestSyncPush'`
and confirm the new assertions fail for the intended missing behaviour.
## Task 2: Add idempotent SQLite scope migration
**Files:**
- Modify: `internal/server/schema.go`
- Modify: `internal/server/server.go`
- Test: `internal/server/server_test.go`
- [x] Define `vault_id` on new devices and `user_id`/`vault_id` on new
operations; define scoped tombstone and idempotency primary keys.
- [x] Add startup migration helpers that inspect columns, add compatible
columns, backfill owner IDs, assign `legacy:<user_id>` to old scopes, and
rebuild the two tables whose primary keys change.
- [x] Add a failing legacy-schema fixture test, then make it pass by opening
the database through `NewServer` and asserting its operation has the
expected owner and legacy scope.
- [x] Run: `go test ./internal/server -run 'Test.*Migration|TestSync.*'`.
## Task 3: Apply authenticated scope to sync handlers
**Files:**
- Modify: `internal/server/middleware.go`
- Modify: `internal/server/handlers_api.go`
- Test: `internal/server/server_test.go`
- [x] Extend authenticated device lookup to provide the effective vault scope;
missing user ownership must not authorize sync operations.
- [x] Require `vault_id` when creating a new client pairing and store it with
the device.
- [x] Make push use authenticated device/user/vault values for inserts,
conflicts, revisions, tombstones, and idempotency lookup/storage.
- [x] Make pull filter operations and its reported cursor by authenticated
user/vault.
- [x] Run the focused tests from Task 1 until green, then
`go test ./internal/server`.
## Task 4: Send the current vault ID while pairing
**Files:**
- Modify: `../verstak-desktop/internal/core/sync/client.go`
- Modify: `../verstak-desktop/internal/core/sync/client_test.go`
- Modify: `../verstak-desktop/internal/api/app.go`
- Modify: `../verstak-desktop/internal/api/app_test.go`
- [x] Add `vault_id` to the pair request and expose it in the pairing client
method without changing push/pull wire compatibility.
- [x] Read the open vault metadata in `syncConfigure`; reject configuration if
the vault ID is absent.
- [x] Add a failing client/API test that captures the pair request and asserts
the persistent vault ID is sent.
- [x] Rebind desktop sync state, cursor, and persisted device identity whenever
a vault is created, opened, or switched.
- [x] Hydrate missing legacy vault device IDs from the authenticated sync
server before a token-based sync can use a global fallback.
- [x] Run: `go test ./internal/core/sync ./internal/api`.
## Task 5: Document, verify, and publish
**Files:**
- Modify: `README.md`
- Modify: `docs/superpowers/specs/2026-07-10-sync-tenant-isolation-design.md`
- [x] Document that pairing is vault-bound and that sync cursors are scoped.
- [x] Run `gofmt` on all changed Go files.
- [x] Run `go test ./...` in both `verstak-sync-server` and
`verstak-desktop`, then `git diff --check` in both repositories.
- [x] Commit and push the sync-server and desktop changes as coordinated
security commits.
@@ -0,0 +1,92 @@
# Sync Tenant Isolation Design
**Status:** approved for implementation by the project owner on 2026-07-10.
## Goal
Make sync operations private to the authenticated user and vault, and make the
server, not the request body, the authority for the originating device.
## Scope
- Bind new desktop pairings to the current vault ID.
- Persist `user_id` and `vault_id` with every sync operation.
- Scope push conflict detection, pull cursors, tombstones, and idempotency
responses to that pair of identifiers.
- Ignore the legacy `device_id` field in a push body for authorization and
storage; retain it in the wire format for backward-compatible decoding.
- Upgrade existing SQLite databases without deleting data.
Blob ownership, API-key retirement, reset-token handling, HTML escaping, and
upload limits are separate security slices and are deliberately not included
in this change.
## Data model
`server_devices` gains a nullable `vault_id`. A device created through either
enrollment endpoint must have a non-empty vault ID that does not use the
reserved `legacy:` prefix. The authenticated device therefore identifies one
user and one vault.
`server_ops` gains `user_id` and `vault_id`. New writes always set both from
the authenticated device. Pull and conflict queries filter both fields.
`server_tombstones` is rebuilt with a composite key of
`(user_id, vault_id, entity_type, entity_id)`. `server_idempotency_keys` is
rebuilt with a composite key of `(user_id, vault_id, idempotency_key)`.
Existing devices without `vault_id` use the explicit effective scope
`legacy:<user_id>`. Existing operations inherit their device owner and that
legacy scope during startup migration. This preserves existing single-vault
accounts while preventing data from crossing account boundaries. New pairings
never use the legacy scope.
Desktop sync state, operation queues, cursors, and persisted device IDs are
vault-local. The desktop recreates its sync service whenever the active vault
is created, opened, or switched. When it opens a legacy vault state without a
stored device ID, it obtains the authenticated ID from `/api/client/me` before
syncing and persists it locally.
## API contract
`POST /api/client/pair` accepts a required `vault_id`. The desktop gets it
from `.verstak/vault.json` and sends it while pairing.
`POST /api/v1/sync/push` keeps accepting `device_id` for old clients, but the
server ignores it. The stored operation device ID is always the authenticated
device. A request whose token is not associated with a user and effective
vault returns a client error.
`POST /api/v1/sync/pull` returns only operations from the authenticated
user/vault scope. `server_sequence` is the highest sequence in that scope;
global sequence gaps are not exposed as the caller's cursor.
## Migration and failure handling
Startup migration is idempotent. It checks SQLite table columns before adding
new operation/device fields, backfills `user_id` from each operation's device,
and assigns the explicit legacy scope when an old device has no vault ID.
Tables whose primary key must change are rebuilt transactionally.
The prior global idempotency cache is intentionally discarded during migration:
it is only a replay cache and retaining it could replay one tenant's response
for another tenant.
If an operation cannot be associated with a user, it remains unscoped and is
not readable through sync APIs. The server must not guess an owner from a
request body.
## Verification
Focused server tests must prove:
1. two users cannot pull each other's operations;
2. two vaults of one user cannot pull each other's operations;
3. a caller cannot forge another device through the push body;
4. scoped idempotency does not replay another tenant's response;
5. a legacy SQLite database is upgraded with its existing operation retained
in the matching legacy scope.
6. switching the active desktop vault rebinds the sync queue, cursor, and
device identity to the new vault.
Desktop tests must prove that pairing sends the opened vault's persistent ID.
+22 -16
View File
@@ -5,6 +5,8 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"html"
"log"
"net/http"
"strings"
"time"
@@ -80,7 +82,8 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
}
rows, err := s.db.Query("SELECT id, username, email, confirmed, blocked, created_at FROM server_users ORDER BY created_at DESC")
if err != nil {
http.Error(w, err.Error(), 500)
log.Printf("admin users: query failed: %v", err)
http.Error(w, t(s.locale(), "server.internalError"), http.StatusInternalServerError)
return
}
defer rows.Close()
@@ -113,8 +116,8 @@ td{padding:0.5rem;border-bottom:1px solid #0f3460}a{color:#4ecca3}</style></head
if u["blocked"].(int) != 0 {
blocked = "🚫"
}
w.Write([]byte(`<tr><td>` + u["username"].(string) + `</td><td>` + u["email"].(string) +
`</td><td>` + confirmed + `</td><td>` + blocked + `</td><td>` + u["created_at"].(string) + `</td></tr>`))
w.Write([]byte(`<tr><td>` + html.EscapeString(u["username"].(string)) + `</td><td>` + html.EscapeString(u["email"].(string)) +
`</td><td>` + confirmed + `</td><td>` + blocked + `</td><td>` + html.EscapeString(u["created_at"].(string)) + `</td></tr>`))
}
w.Write([]byte(`</table></body></html>`))
}
@@ -126,7 +129,8 @@ func (s *Server) handleAdminDevices(w http.ResponseWriter, r *http.Request) {
rows, err := s.db.Query(`SELECT d.id, d.name, d.client_version, COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at
FROM server_devices d ORDER BY d.created_at DESC`)
if err != nil {
http.Error(w, err.Error(), 500)
log.Printf("admin devices: query failed: %v", err)
http.Error(w, t(s.locale(), "server.internalError"), http.StatusInternalServerError)
return
}
defer rows.Close()
@@ -148,8 +152,8 @@ td{padding:0.5rem;border-bottom:1px solid #0f3460}a{color:#4ecca3}</style></head
if revokedAt == "" {
revokedAt = "-"
}
w.Write([]byte(`<tr><td>` + name + `</td><td style="font-family:monospace;font-size:0.8em">` + id +
`</td><td>` + clientVer + `</td><td>` + lastSeen + `</td><td>` + revokedAt + `</td><td>` + createdAt + `</td></tr>`))
w.Write([]byte(`<tr><td>` + html.EscapeString(name) + `</td><td style="font-family:monospace;font-size:0.8em">` + html.EscapeString(id) +
`</td><td>` + html.EscapeString(clientVer) + `</td><td>` + html.EscapeString(lastSeen) + `</td><td>` + html.EscapeString(revokedAt) + `</td><td>` + html.EscapeString(createdAt) + `</td></tr>`))
}
w.Write([]byte(`</table></body></html>`))
}
@@ -213,11 +217,12 @@ func (s *Server) handleAdminSMTPTest(w http.ResponseWriter, r *http.Request) {
to = from
}
if host == "" || port == "" || from == "" {
jsonOK(w, map[string]interface{}{"ok": false, "error": "host, port and from required"})
jsonOK(w, map[string]interface{}{"ok": false, "error": t(s.locale(), "admin.smtpRequired")})
return
}
if err := s.smtpTest(host, port, user, pass, security, from, to); err != nil {
jsonOK(w, map[string]interface{}{"ok": false, "error": err.Error()})
log.Printf("admin SMTP test failed: %v", err)
jsonOK(w, map[string]interface{}{"ok": false, "error": t(s.locale(), "admin.smtpTestFailed")})
return
}
jsonOK(w, map[string]interface{}{"ok": true})
@@ -234,7 +239,7 @@ func (s *Server) handleAdminAPIDevices(w http.ResponseWriter, r *http.Request) {
LEFT JOIN server_users u ON u.id = d.user_id
ORDER BY d.created_at DESC`)
if err != nil {
jsonErr(w, 500, err.Error())
jsonInternalError(w, err)
return
}
defer rows.Close()
@@ -264,7 +269,7 @@ func (s *Server) handleAdminAPIKeys(w http.ResponseWriter, r *http.Request) {
case "GET":
rows, err := s.db.Query("SELECT id, name, api_key FROM server_devices ORDER BY created_at")
if err != nil {
jsonErr(w, 500, err.Error())
jsonInternalError(w, err)
return
}
defer rows.Close()
@@ -312,7 +317,7 @@ func (s *Server) handleAdminAPIKeysDelete(w http.ResponseWriter, r *http.Request
id := strings.TrimPrefix(r.URL.Path, "/admin/api/keys/")
_, err := s.db.Exec("DELETE FROM server_devices WHERE id=?", id)
if err != nil {
jsonErr(w, 500, err.Error())
jsonInternalError(w, err)
return
}
s.db.Exec("DELETE FROM server_user_devices WHERE device_id=?", id)
@@ -372,7 +377,7 @@ func (s *Server) handleAdminAPIUsers(w http.ResponseWriter, r *http.Request) {
args = append(args, perPage, offset)
rows, err := s.db.Query(sql, args...)
if err != nil {
jsonErr(w, 500, err.Error())
jsonInternalError(w, err)
return
}
defer rows.Close()
@@ -432,7 +437,7 @@ func (s *Server) handleAdminAPIUserActions(w http.ResponseWriter, r *http.Reques
hash, _ := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost)
_, err := s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), id)
if err != nil {
jsonErr(w, 500, err.Error())
jsonInternalError(w, err)
return
}
jsonOK(w, map[string]interface{}{"status": "ok", "new_password": newPass})
@@ -455,7 +460,7 @@ func (s *Server) handleAdminAPIUserActions(w http.ResponseWriter, r *http.Reques
}
_, err := s.db.Exec("UPDATE server_users SET username=?, email=? WHERE id=?", editReq.Username, strings.ToLower(editReq.Email), id)
if err != nil {
jsonErr(w, 500, err.Error())
jsonInternalError(w, err)
return
}
jsonOK(w, map[string]interface{}{"status": "ok"})
@@ -533,8 +538,9 @@ func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(409)
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), "Username or email already taken", "/admin/create-user")))
} else {
log.Printf("admin create user: failed: %v", err)
w.WriteHeader(500)
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), err.Error(), "/admin/create-user")))
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "admin.createUserFailed"), "/admin/create-user")))
}
return
}
@@ -586,7 +592,7 @@ func (s *Server) handleAdminAPICreateUser(w http.ResponseWriter, r *http.Request
if strings.Contains(err.Error(), "UNIQUE") {
jsonErr(w, 409, "username or email already taken")
} else {
jsonErr(w, 500, err.Error())
jsonInternalError(w, err)
}
return
}
+52 -27
View File
@@ -50,6 +50,7 @@ func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
Password string `json:"password"`
DeviceName string `json:"device_name"`
ClientVersion string `json:"client_version"`
VaultID string `json:"vault_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "bad json")
@@ -59,6 +60,15 @@ func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, "login and password required")
return
}
req.VaultID = strings.TrimSpace(req.VaultID)
if req.VaultID == "" {
jsonErr(w, 400, "vault_id required")
return
}
if strings.HasPrefix(req.VaultID, "legacy:") {
jsonErr(w, 400, "vault_id uses reserved prefix")
return
}
if req.DeviceName == "" {
req.DeviceName = "unknown"
}
@@ -95,12 +105,12 @@ func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
apiKey := make([]byte, 20)
rand.Read(apiKey)
_, err = s.db.Exec(`INSERT INTO server_devices
(id, name, api_key, token_hash, token_prefix, token_suffix, user_id, client_version, last_ip, last_seen, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
(id, name, api_key, token_hash, token_prefix, token_suffix, user_id, vault_id, client_version, last_ip, last_seen, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
deviceID, req.DeviceName, hex.EncodeToString(apiKey), tokenHash, prefix, suffix,
userID, req.ClientVersion, ip, now, now)
userID, req.VaultID, req.ClientVersion, ip, now, now)
if err != nil {
jsonErr(w, 500, err.Error())
jsonInternalError(w, err)
return
}
s.db.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID)
@@ -272,6 +282,7 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
Name string `json:"name"`
Username string `json:"username"`
Password string `json:"password"`
VaultID string `json:"vault_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
@@ -285,6 +296,15 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 401, "username and password required")
return
}
req.VaultID = strings.TrimSpace(req.VaultID)
if req.VaultID == "" {
jsonErr(w, 400, "vault_id required")
return
}
if strings.HasPrefix(req.VaultID, "legacy:") {
jsonErr(w, 400, "vault_id uses reserved prefix")
return
}
var userID, hash string
var confirmed, blocked int
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
@@ -311,11 +331,11 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
deviceID := apiKey[:12]
now := time.Now().UTC().Format(time.RFC3339)
_, err = s.db.Exec(
"INSERT INTO server_devices (id, name, api_key, last_seen, created_at) VALUES (?, ?, ?, ?, ?)",
deviceID, req.Name, apiKey, now, now,
"INSERT INTO server_devices (id, name, api_key, user_id, vault_id, last_seen, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
deviceID, req.Name, apiKey, userID, req.VaultID, now, now,
)
if err != nil {
jsonErr(w, 500, err.Error())
jsonInternalError(w, err)
return
}
s.db.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID)
@@ -326,7 +346,7 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
_, _, ok := s.requireAuth(w, r)
scope, ok := s.requireSyncScope(w, r)
if !ok {
return
}
@@ -349,12 +369,14 @@ func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
} `json:"ops"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON: "+err.Error())
jsonErr(w, 400, "invalid JSON")
return
}
if req.IdempotencyKey != "" {
var cachedJSON string
err := s.db.QueryRow("SELECT response_json FROM server_idempotency_keys WHERE idempotency_key=?", req.IdempotencyKey).Scan(&cachedJSON)
err := s.db.QueryRow(`SELECT response_json FROM server_idempotency_keys
WHERE user_id=? AND vault_id=? AND idempotency_key=?`,
scope.UserID, scope.VaultID, req.IdempotencyKey).Scan(&cachedJSON)
if err == nil {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(cachedJSON))
@@ -371,9 +393,9 @@ func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
if op.LastSeenServerSeq > 0 {
conflictRows, err := s.db.Query(`
SELECT op_id, device_id, op_type, server_sequence FROM server_ops
WHERE entity_type=? AND entity_id=? AND device_id!=?
WHERE user_id=? AND vault_id=? AND entity_type=? AND entity_id=? AND device_id!=?
AND server_sequence > ? AND op_type != 'delete'
ORDER BY server_sequence`, op.EntityType, op.EntityID, req.DeviceID, op.LastSeenServerSeq)
ORDER BY server_sequence`, scope.UserID, scope.VaultID, op.EntityType, op.EntityID, scope.DeviceID, op.LastSeenServerSeq)
if err == nil {
for conflictRows.Next() {
var cOpID, cDevID, cOpType string
@@ -392,9 +414,9 @@ func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
}
}
res, err := s.db.Exec(
`INSERT OR IGNORE INTO server_ops (op_id, server_sequence, device_id, entity_type, entity_id, op_type, payload_json, idempotency_key, client_sequence, last_seen_server_seq, created_at, pushed_at)
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
op.OpID, req.DeviceID, op.EntityType, op.EntityID, op.OpType, op.PayloadJSON,
`INSERT OR IGNORE INTO server_ops (op_id, server_sequence, user_id, vault_id, device_id, entity_type, entity_id, op_type, payload_json, idempotency_key, client_sequence, last_seen_server_seq, created_at, pushed_at)
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
op.OpID, scope.UserID, scope.VaultID, scope.DeviceID, op.EntityType, op.EntityID, op.OpType, op.PayloadJSON,
req.IdempotencyKey, op.ClientSequence, op.LastSeenServerSeq, op.CreatedAt, now,
)
if err != nil {
@@ -404,15 +426,16 @@ func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
if n == 0 {
continue
}
seqRes, err := s.db.Exec("INSERT INTO server_revisions (op_id, device_id) VALUES (?, ?)", op.OpID, req.DeviceID)
seqRes, err := s.db.Exec("INSERT INTO server_revisions (op_id, device_id) VALUES (?, ?)", op.OpID, scope.DeviceID)
if err != nil {
continue
}
seq, _ := seqRes.LastInsertId()
s.db.Exec("UPDATE server_ops SET server_sequence=? WHERE op_id=?", seq, op.OpID)
if op.OpType == "delete" {
s.db.Exec(`INSERT OR REPLACE INTO server_tombstones (entity_type, entity_id, op_id, deleted_at) VALUES (?, ?, ?, ?)`,
op.EntityType, op.EntityID, op.OpID, now)
s.db.Exec(`INSERT OR REPLACE INTO server_tombstones
(user_id, vault_id, entity_type, entity_id, op_id, deleted_at) VALUES (?, ?, ?, ?, ?, ?)`,
scope.UserID, scope.VaultID, op.EntityType, op.EntityID, op.OpID, now)
}
accepted = append(accepted, op.OpID)
}
@@ -423,15 +446,16 @@ func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
}
if req.IdempotencyKey != "" {
if respJSON, err := json.Marshal(resp); err == nil {
s.db.Exec("INSERT OR IGNORE INTO server_idempotency_keys (idempotency_key, response_json, created_at) VALUES (?, ?, ?)",
req.IdempotencyKey, string(respJSON), now)
s.db.Exec(`INSERT OR IGNORE INTO server_idempotency_keys
(user_id, vault_id, idempotency_key, response_json, created_at) VALUES (?, ?, ?, ?, ?)`,
scope.UserID, scope.VaultID, req.IdempotencyKey, string(respJSON), now)
}
}
jsonOK(w, resp)
}
func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
_, _, ok := s.requireAuth(w, r)
scope, ok := s.requireSyncScope(w, r)
if !ok {
return
}
@@ -447,14 +471,15 @@ func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
return
}
var serverSeq int
s.db.QueryRow("SELECT COALESCE(MAX(server_sequence), 0) FROM server_ops").Scan(&serverSeq)
s.db.QueryRow(`SELECT COALESCE(MAX(server_sequence), 0) FROM server_ops
WHERE user_id=? AND vault_id=?`, scope.UserID, scope.VaultID).Scan(&serverSeq)
rows, err := s.db.Query(`
SELECT op_id, server_sequence, device_id, entity_type, entity_id, op_type, payload_json, created_at
FROM server_ops
WHERE server_sequence > ? AND server_sequence IS NOT NULL
ORDER BY server_sequence`, req.SinceSequence)
WHERE user_id=? AND vault_id=? AND server_sequence > ? AND server_sequence IS NOT NULL
ORDER BY server_sequence`, scope.UserID, scope.VaultID, req.SinceSequence)
if err != nil {
jsonErr(w, 500, err.Error())
jsonInternalError(w, err)
return
}
defer rows.Close()
@@ -468,7 +493,7 @@ func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
PayloadJSON string `json:"payload_json"`
CreatedAt string `json:"created_at"`
}
var ops []opDTO
ops := []opDTO{}
for rows.Next() {
var o opDTO
if err := rows.Scan(&o.OpID, &o.ServerSequence, &o.DeviceID, &o.EntityType, &o.EntityID, &o.OpType, &o.PayloadJSON, &o.CreatedAt); err != nil {
@@ -490,7 +515,7 @@ func (s *Server) handleBlobs(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "POST":
if err := r.ParseMultipartForm(200 << 20); err != nil {
jsonErr(w, 400, "multipart error: "+err.Error())
jsonErr(w, 400, "invalid multipart request")
return
}
file, header, err := r.FormFile("file")
+4 -10
View File
@@ -56,7 +56,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 409, "username or email already taken")
return
}
jsonErr(w, 500, err.Error())
jsonInternalError(w, err)
return
}
tok := make([]byte, 24)
@@ -193,24 +193,18 @@ func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, err)
return
}
var userID, expiresAt string
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='reset'",
req.Token).Scan(&userID, &expiresAt)
if err != nil {
_, err := s.resetPasswordWithToken(req.Token, req.NewPassword)
if err == errResetTokenInvalid {
jsonErr(w, 400, "invalid or expired token")
return
}
exp, err := time.Parse(time.RFC3339, expiresAt)
if err != nil || time.Now().After(exp) {
if err == errResetTokenExpired {
jsonErr(w, 400, "token expired")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
jsonErr(w, 500, "internal error")
return
}
s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), userID)
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", req.Token)
jsonOK(w, map[string]string{"status": "password reset"})
}
+21 -15
View File
@@ -4,8 +4,11 @@ import (
"crypto/rand"
"encoding/hex"
"fmt"
"html"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -75,8 +78,9 @@ func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(409)
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), "Username or email already taken", "/register")))
} else {
log.Printf("register web: create user failed: %v", err)
w.WriteHeader(500)
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), err.Error(), "/register")))
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.registrationFailed"), "/register")))
}
return
}
@@ -187,8 +191,8 @@ 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(locale), "{TOKEN}", token)
w.Write([]byte(html))
page := strings.ReplaceAll(resetPasswordHTML(locale), "{TOKEN}", html.EscapeString(token))
w.Write([]byte(page))
case "POST":
if err := r.ParseForm(); err != nil {
jsonErr(w, 400, "bad form")
@@ -204,23 +208,25 @@ func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) {
}
if err := validatePassword(newPass); err != "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), string(err), "/reset?token="+token)))
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), string(err), "/reset?token="+url.QueryEscape(token))))
return
}
if newPass != confirm {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.passwordsDoNotMatch"), "/reset?token="+token)))
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.passwordsDoNotMatch"), "/reset?token="+url.QueryEscape(token))))
return
}
var userID string
err := s.db.QueryRow("SELECT user_id FROM server_email_tokens WHERE token=? AND purpose='reset'", token).Scan(&userID)
if err != nil {
userID, err := s.resetPasswordWithToken(token, newPass)
if err == errResetTokenInvalid || err == errResetTokenExpired {
http.Redirect(w, r, "/forgot", http.StatusFound)
return
}
hash, _ := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost)
s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), userID)
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", token)
if err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "common.error"), "/forgot")))
return
}
log.Printf("reset: user %s reset password", userID)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(resetDoneHTML(locale)))
@@ -308,7 +314,7 @@ func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
created = created[:10]
}
status := "<span style='color:#34d399'>" + t(locale, "userDashboard.active") + "</span>"
revokeBtn := fmt.Sprintf(`<button class="btn btn-danger btn-sm" onclick="revokeDevice('%s')">%s</button>`, d.ID, t(locale, "userDashboard.revoke"))
revokeBtn := fmt.Sprintf(`<button class="btn btn-danger btn-sm" onclick="revokeDevice(%s)">%s</button>`, html.EscapeString(strconv.Quote(d.ID)), t(locale, "userDashboard.revoke"))
if d.RevokedAt != "" {
status = "<span style='color:#ff6b6b'>" + t(locale, "userDashboard.revoked") + "</span>"
revokeBtn = ""
@@ -319,11 +325,11 @@ func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
<td>%s</td>
<td>%s</td>
<td>%s %s</td>
</tr>`, d.Name, status, created, ls, d.ClientVer, revokeBtn)
</tr>`, html.EscapeString(d.Name), status, html.EscapeString(created), html.EscapeString(ls), html.EscapeString(d.ClientVer), revokeBtn)
}
}
w.Write([]byte(userDashboardHTML(locale, username, deviceRows)))
w.Write([]byte(userDashboardHTML(locale, html.EscapeString(username), deviceRows)))
}
func (s *Server) handleUserWebLogout(w http.ResponseWriter, r *http.Request) {
@@ -347,7 +353,7 @@ func (s *Server) handleUserDevices(w http.ResponseWriter, r *http.Request) {
WHERE ud.user_id = ?
ORDER BY d.created_at DESC`, userID)
if err != nil {
jsonErr(w, 500, err.Error())
jsonInternalError(w, err)
return
}
defer rows.Close()
+6
View File
@@ -4,6 +4,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"log"
"net/http"
)
@@ -18,6 +19,11 @@ func jsonErr(w http.ResponseWriter, code int, msg string) {
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
func jsonInternalError(w http.ResponseWriter, err error) {
log.Printf("request failed: %v", err)
jsonErr(w, http.StatusInternalServerError, "internal error")
}
func sha256Hex(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:])
+10
View File
@@ -59,6 +59,8 @@ var _translations = map[string]map[string]string{
"server.newPasswordResult": "Новый пароль для %s:\n",
"server.logout": "Выйти",
"server.error": "Ошибка",
"server.internalError": "Что-то пошло не так. Повторите попытку.",
"server.registrationFailed": "Не удалось зарегистрировать аккаунт. Повторите попытку.",
"userDashboard.devices": "Устройства",
"userDashboard.device": "Устройство",
"userDashboard.status": "Статус",
@@ -107,6 +109,8 @@ var _translations = map[string]map[string]string{
"admin.smtpTitle": "Настройки SMTP",
"admin.smtpTesting": "Проверка...",
"admin.smtpPassed": "✓ Тест пройден",
"admin.smtpRequired": "Укажите SMTP-сервер, порт и отправителя.",
"admin.smtpTestFailed": "Не удалось проверить настройки SMTP. Повторите попытку.",
"admin.revokeConfirm": "Вы уверены?",
"common.loading": "Загрузка...",
"common.ok": "OK",
@@ -139,6 +143,7 @@ var _translations = map[string]map[string]string{
"admin.blockUserMessage": "Заблокировать пользователя?",
"admin.createUser": "Создать пользователя",
"admin.createUserBtn": "Создать",
"admin.createUserFailed": "Не удалось создать пользователя. Повторите попытку.",
},
"en": {
"server.registerTitle": "Registration",
@@ -184,6 +189,8 @@ var _translations = map[string]map[string]string{
"server.newPasswordResult": "New password for %s:\n",
"server.logout": "Logout",
"server.error": "Error",
"server.internalError": "Something went wrong. Please try again.",
"server.registrationFailed": "Could not register the account. Please try again.",
"userDashboard.devices": "Devices",
"userDashboard.device": "Device",
"userDashboard.status": "Status",
@@ -232,6 +239,8 @@ var _translations = map[string]map[string]string{
"admin.smtpTitle": "SMTP Settings",
"admin.smtpTesting": "Testing...",
"admin.smtpPassed": "✓ Test passed",
"admin.smtpRequired": "Enter the SMTP server, port, and sender.",
"admin.smtpTestFailed": "Could not test the SMTP settings. Please try again.",
"admin.revokeConfirm": "Are you sure?",
"common.loading": "Loading...",
"common.ok": "OK",
@@ -264,5 +273,6 @@ var _translations = map[string]map[string]string{
"admin.blockUserMessage": "Block user?",
"admin.createUser": "Create User",
"admin.createUserBtn": "Create",
"admin.createUserFailed": "Could not create the user. Please try again.",
},
}
+59 -19
View File
@@ -7,7 +7,38 @@ import (
"time"
)
type authenticatedDevice struct {
DeviceID string
UserID string
VaultID string
}
func (s *Server) requireAuth(w http.ResponseWriter, r *http.Request) (deviceID, userID string, ok bool) {
device, ok := s.authenticateDevice(w, r)
if !ok {
return "", "", false
}
return device.DeviceID, device.UserID, true
}
func (s *Server) requireSyncScope(w http.ResponseWriter, r *http.Request) (authenticatedDevice, bool) {
device, ok := s.authenticateDevice(w, r)
if !ok {
return authenticatedDevice{}, false
}
if device.UserID == "" {
jsonErr(w, http.StatusForbidden, "device is not associated with a user")
return authenticatedDevice{}, false
}
device.VaultID = effectiveVaultScope(device.UserID, device.VaultID)
if device.VaultID == "" {
jsonErr(w, http.StatusForbidden, "device is not associated with a vault")
return authenticatedDevice{}, false
}
return device, true
}
func (s *Server) authenticateDevice(w http.ResponseWriter, r *http.Request) (authenticatedDevice, bool) {
key := r.Header.Get("Authorization")
key = strings.TrimPrefix(key, "Bearer ")
if key == "" {
@@ -15,34 +46,43 @@ func (s *Server) requireAuth(w http.ResponseWriter, r *http.Request) (deviceID,
}
if key == "" {
jsonErr(w, 401, "API key required")
return "", "", false
return authenticatedDevice{}, false
}
var device authenticatedDevice
var userID, vaultID, revokedAt sql.NullString
err := s.db.QueryRow(`SELECT id, user_id, vault_id, revoked_at
FROM server_devices WHERE token_hash=?`, sha256Hex(key)).
Scan(&device.DeviceID, &userID, &vaultID, &revokedAt)
if err != nil {
err = s.db.QueryRow(`SELECT id, user_id, vault_id, revoked_at
FROM server_devices WHERE api_key=?`, key).
Scan(&device.DeviceID, &userID, &vaultID, &revokedAt)
}
if err != nil {
jsonErr(w, 401, "invalid API key")
return authenticatedDevice{}, false
}
hash := sha256Hex(key)
var deviceIDVal, userIDVal, revokedAt sql.NullString
err := s.db.QueryRow("SELECT id, user_id, revoked_at FROM server_devices WHERE token_hash=?", hash).Scan(&deviceIDVal, &userIDVal, &revokedAt)
if err == nil {
if revokedAt.Valid && revokedAt.String != "" {
jsonErr(w, 401, "device revoked")
return "", "", false
return authenticatedDevice{}, false
}
if userIDVal.Valid && userIDVal.String != "" {
if userID.Valid {
device.UserID = userID.String
}
if vaultID.Valid {
device.VaultID = vaultID.String
}
if device.UserID != "" {
var blocked int
s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", userIDVal.String).Scan(&blocked)
s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", device.UserID).Scan(&blocked)
if blocked != 0 {
jsonErr(w, 403, "user blocked")
return "", "", false
return authenticatedDevice{}, false
}
}
s.db.Exec("UPDATE server_devices SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), deviceIDVal.String)
return deviceIDVal.String, userIDVal.String, true
}
var count int
err = s.db.QueryRow("SELECT COUNT(*) FROM server_devices WHERE api_key=?", key).Scan(&count)
if err != nil || count == 0 {
jsonErr(w, 401, "invalid API key")
return "", "", false
}
return "", "", true
s.db.Exec("UPDATE server_devices SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), device.DeviceID)
return device, true
}
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
+69
View File
@@ -0,0 +1,69 @@
package server
import (
"database/sql"
"errors"
"time"
"golang.org/x/crypto/bcrypt"
)
var (
errResetTokenInvalid = errors.New("invalid reset token")
errResetTokenExpired = errors.New("expired reset token")
)
func (s *Server) resetPasswordWithToken(token, newPassword string) (string, error) {
tx, err := s.db.Begin()
if err != nil {
return "", err
}
defer tx.Rollback()
var userID, expiresAt string
err = tx.QueryRow(`SELECT user_id, expires_at FROM server_email_tokens
WHERE token=? AND purpose='reset'`, token).Scan(&userID, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return "", errResetTokenInvalid
}
if err != nil {
return "", err
}
expiresAtTime, err := time.Parse(time.RFC3339, expiresAt)
if err != nil || !time.Now().Before(expiresAtTime) {
return "", errResetTokenExpired
}
deleted, err := tx.Exec(`DELETE FROM server_email_tokens
WHERE token=? AND purpose='reset' AND expires_at=?`, token, expiresAt)
if err != nil {
return "", err
}
deletedCount, err := deleted.RowsAffected()
if err != nil {
return "", err
}
if deletedCount != 1 {
return "", errResetTokenInvalid
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return "", err
}
updated, err := tx.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), userID)
if err != nil {
return "", err
}
updatedCount, err := updated.RowsAffected()
if err != nil {
return "", err
}
if updatedCount != 1 {
return "", errResetTokenInvalid
}
if err := tx.Commit(); err != nil {
return "", err
}
return userID, nil
}
+210 -3
View File
@@ -1,5 +1,10 @@
package server
import (
"database/sql"
"fmt"
)
const serverSchema = `
CREATE TABLE IF NOT EXISTS server_users (
id TEXT PRIMARY KEY,
@@ -20,6 +25,7 @@ CREATE TABLE IF NOT EXISTS server_devices (
token_prefix TEXT,
token_suffix TEXT,
user_id TEXT,
vault_id TEXT,
client_version TEXT,
last_ip TEXT,
last_seen TEXT,
@@ -36,6 +42,8 @@ CREATE TABLE IF NOT EXISTS server_user_devices (
CREATE TABLE IF NOT EXISTS server_ops (
op_id TEXT PRIMARY KEY,
server_sequence INTEGER,
user_id TEXT NOT NULL,
vault_id TEXT NOT NULL,
device_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
@@ -49,17 +57,22 @@ CREATE TABLE IF NOT EXISTS server_ops (
);
CREATE TABLE IF NOT EXISTS server_tombstones (
user_id TEXT NOT NULL,
vault_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
op_id TEXT NOT NULL,
deleted_at TEXT NOT NULL,
PRIMARY KEY (entity_type, entity_id)
PRIMARY KEY (user_id, vault_id, entity_type, entity_id)
);
CREATE TABLE IF NOT EXISTS server_idempotency_keys (
idempotency_key TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
vault_id TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
response_json TEXT NOT NULL,
created_at TEXT NOT NULL
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, vault_id, idempotency_key)
);
CREATE TABLE IF NOT EXISTS server_email_tokens (
@@ -107,3 +120,197 @@ CREATE TABLE IF NOT EXISTS server_smtp_config (
value TEXT NOT NULL
);
`
type sqliteColumn struct {
primaryKeyOrder int
}
func migrateServerSchema(db *sql.DB) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, change := range []struct {
table string
column string
definition string
}{
{"server_devices", "user_id", "TEXT"},
{"server_devices", "vault_id", "TEXT"},
{"server_ops", "user_id", "TEXT"},
{"server_ops", "vault_id", "TEXT"},
} {
if err := ensureSQLiteColumn(tx, change.table, change.column, change.definition); err != nil {
return err
}
}
if err := backfillDeviceOwners(tx); err != nil {
return err
}
if err := backfillOperationScope(tx); err != nil {
return err
}
if err := migrateTombstoneScope(tx); err != nil {
return err
}
if err := migrateIdempotencyScope(tx); err != nil {
return err
}
if _, err := tx.Exec(`CREATE INDEX IF NOT EXISTS idx_server_ops_scope_sequence
ON server_ops(user_id, vault_id, server_sequence)`); err != nil {
return err
}
if _, err := tx.Exec(`CREATE INDEX IF NOT EXISTS idx_server_devices_scope
ON server_devices(user_id, vault_id)`); err != nil {
return err
}
return tx.Commit()
}
func ensureSQLiteColumn(tx *sql.Tx, table, column, definition string) error {
columns, err := sqliteTableColumns(tx, table)
if err != nil {
return err
}
if _, ok := columns[column]; ok {
return nil
}
if _, err := tx.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition)); err != nil {
return fmt.Errorf("add %s.%s: %w", table, column, err)
}
return nil
}
func sqliteTableColumns(tx *sql.Tx, table string) (map[string]sqliteColumn, error) {
rows, err := tx.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
return nil, err
}
defer rows.Close()
columns := make(map[string]sqliteColumn)
for rows.Next() {
var cid, notNull, primaryKeyOrder int
var name, dataType string
var defaultValue sql.NullString
if err := rows.Scan(&cid, &name, &dataType, &notNull, &defaultValue, &primaryKeyOrder); err != nil {
return nil, err
}
columns[name] = sqliteColumn{primaryKeyOrder: primaryKeyOrder}
}
return columns, rows.Err()
}
func backfillDeviceOwners(tx *sql.Tx) error {
_, err := tx.Exec(`UPDATE server_devices
SET user_id = (
SELECT user_id FROM server_user_devices
WHERE device_id = server_devices.id
)
WHERE COALESCE(user_id, '') = ''
AND 1 = (
SELECT COUNT(*) FROM server_user_devices
WHERE device_id = server_devices.id
)`)
return err
}
func backfillOperationScope(tx *sql.Tx) error {
if _, err := tx.Exec(`UPDATE server_ops
SET user_id = (
SELECT user_id FROM server_devices
WHERE id = server_ops.device_id
)
WHERE COALESCE(user_id, '') = ''`); err != nil {
return err
}
_, err := tx.Exec(`UPDATE server_ops
SET vault_id = COALESCE(
NULLIF((SELECT vault_id FROM server_devices WHERE id = server_ops.device_id), ''),
'legacy:' || user_id
)
WHERE COALESCE(vault_id, '') = ''
AND COALESCE(user_id, '') != ''`)
return err
}
func migrateTombstoneScope(tx *sql.Tx) error {
if hasScopedPrimaryKey(tx, "server_tombstones", "user_id", "vault_id", "entity_type", "entity_id") {
return nil
}
if _, err := tx.Exec("ALTER TABLE server_tombstones RENAME TO server_tombstones_legacy"); err != nil {
return err
}
if _, err := tx.Exec(`CREATE TABLE server_tombstones (
user_id TEXT NOT NULL,
vault_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
op_id TEXT NOT NULL,
deleted_at TEXT NOT NULL,
PRIMARY KEY (user_id, vault_id, entity_type, entity_id)
)`); err != nil {
return err
}
if _, err := tx.Exec(`INSERT OR IGNORE INTO server_tombstones
(user_id, vault_id, entity_type, entity_id, op_id, deleted_at)
SELECT o.user_id, o.vault_id, legacy.entity_type, legacy.entity_id, legacy.op_id, legacy.deleted_at
FROM server_tombstones_legacy AS legacy
JOIN server_ops AS o ON o.op_id = legacy.op_id
WHERE COALESCE(o.user_id, '') != '' AND COALESCE(o.vault_id, '') != ''`); err != nil {
return err
}
_, err := tx.Exec("DROP TABLE server_tombstones_legacy")
return err
}
func migrateIdempotencyScope(tx *sql.Tx) error {
if hasScopedPrimaryKey(tx, "server_idempotency_keys", "user_id", "vault_id", "idempotency_key") {
return nil
}
if _, err := tx.Exec("ALTER TABLE server_idempotency_keys RENAME TO server_idempotency_keys_legacy"); err != nil {
return err
}
if _, err := tx.Exec(`CREATE TABLE server_idempotency_keys (
user_id TEXT NOT NULL,
vault_id TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
response_json TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, vault_id, idempotency_key)
)`); err != nil {
return err
}
// The previous cache was global. Discard it rather than risk replaying a
// response for another user or vault after the migration.
_, err := tx.Exec("DROP TABLE server_idempotency_keys_legacy")
return err
}
func hasScopedPrimaryKey(tx *sql.Tx, table string, want ...string) bool {
columns, err := sqliteTableColumns(tx, table)
if err != nil {
return false
}
for index, name := range want {
column, ok := columns[name]
if !ok || column.primaryKeyOrder != index+1 {
return false
}
}
return true
}
func effectiveVaultScope(userID, vaultID string) string {
if vaultID != "" {
return vaultID
}
if userID == "" {
return ""
}
return "legacy:" + userID
}
+4
View File
@@ -139,6 +139,10 @@ func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) {
return nil, fmt.Errorf("schema: %w", err)
}
}
if err := migrateServerSchema(db); err != nil {
db.Close()
return nil, fmt.Errorf("migrate schema: %w", err)
}
blobsDir := filepath.Join(dataDir, "blobs")
if err := os.MkdirAll(blobsDir, 0750); err != nil {
+635
View File
@@ -1,9 +1,20 @@
package server
import (
"bytes"
"database/sql"
"encoding/json"
"html"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
)
func TestNewServer(t *testing.T) {
@@ -52,3 +63,627 @@ func TestConfigSetAdmin(t *testing.T) {
t.Fatal("CheckAdmin should return false for unknown user")
}
}
func TestAdminDashboardSMTPSecuritySelectUsesApplicationStyles(t *testing.T) {
html := adminDashboardHTML("en", 0, 0, "", "", "", "", "starttls", "")
for _, expected := range []string{
`<select name="smtp_security" class="form-select">`,
`.form-select{`,
`appearance:none`,
`background-image:linear-gradient`,
`.form-select option{background:#13131f;color:#e4e4ef}`,
`.form-select:focus{outline:none;border-color:#6366f1`,
} {
if !strings.Contains(html, expected) {
t.Errorf("SMTP security select is missing application styling %q", expected)
}
}
}
func TestUserFacingServerErrorsDoNotExposeInternalDetails(t *testing.T) {
for _, name := range []string{"handlers_auth.go", "handlers_api.go", "handlers_admin.go", "handlers_web_user.go"} {
source, err := os.ReadFile(name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
for _, forbidden := range []string{
"jsonErr(w, 500, err.Error())",
"http.Error(w, err.Error(), 500)",
"errorPageHTML(locale, t(locale, \"common.error\"), err.Error()",
} {
if strings.Contains(string(source), forbidden) {
t.Errorf("%s exposes an internal error with %q", name, forbidden)
}
}
}
}
func TestSyncPushPullStoresSequencedOps(t *testing.T) {
dir := t.TempDir()
s, err := NewServer(filepath.Join(dir, "test.db"), filepath.Join(dir, "data"), &Config{Port: 47732})
if err != nil {
t.Fatalf("NewServer: %v", err)
}
defer s.Close()
s.SetupRoutes()
insertSyncUser(t, s, "user-a")
now := time.Now().UTC().Format(time.RFC3339)
if _, err := s.db.Exec(
"INSERT INTO server_devices (id, name, api_key, user_id, vault_id, last_seen, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
"device-a", "Device A", "api-key", "user-a", "vault-a", now, now,
); err != nil {
t.Fatalf("insert device: %v", err)
}
ts := httptest.NewServer(s.mux)
defer ts.Close()
pushBody := map[string]interface{}{
"device_id": "device-a",
"ops": []map[string]interface{}{
{
"op_id": "op-1",
"entity_type": "file",
"entity_id": "Docs/one.txt",
"op_type": "create",
"payload_json": `{"path":"Docs/one.txt","content":"hello"}`,
"created_at": "2026-06-27T00:00:00Z",
"client_sequence": 1,
},
},
}
pushResp := postJSON(t, ts.URL+"/api/v1/sync/push", "api-key", pushBody)
if got := int(pushResp["count"].(float64)); got != 1 {
t.Fatalf("push count = %d, want 1: %#v", got, pushResp)
}
accepted := pushResp["accepted"].([]interface{})
if len(accepted) != 1 || accepted[0] != "op-1" {
t.Fatalf("accepted = %#v", accepted)
}
pullResp := postJSON(t, ts.URL+"/api/v1/sync/pull", "api-key", map[string]interface{}{
"since_sequence": 0,
})
if got := int(pullResp["server_sequence"].(float64)); got != 1 {
t.Fatalf("server_sequence = %d, want 1: %#v", got, pullResp)
}
ops := pullResp["ops"].([]interface{})
if len(ops) != 1 {
t.Fatalf("ops len = %d, want 1: %#v", len(ops), ops)
}
op := ops[0].(map[string]interface{})
if op["op_id"] != "op-1" ||
op["device_id"] != "device-a" ||
op["entity_type"] != "file" ||
op["entity_id"] != "Docs/one.txt" ||
op["op_type"] != "create" ||
op["payload_json"] != `{"path":"Docs/one.txt","content":"hello"}` ||
int(op["server_sequence"].(float64)) != 1 {
t.Fatalf("pulled op = %#v", op)
}
pullAfterResp := postJSON(t, ts.URL+"/api/v1/sync/pull", "api-key", map[string]interface{}{
"since_sequence": 1,
})
if got := int(pullAfterResp["server_sequence"].(float64)); got != 1 {
t.Fatalf("server_sequence after = %d, want 1", got)
}
if ops := pullAfterResp["ops"].([]interface{}); len(ops) != 0 {
t.Fatalf("ops after seq len = %d, want 0: %#v", len(ops), ops)
}
}
func TestRevokedLegacyAPIKeyCannotPushOrPull(t *testing.T) {
dir := t.TempDir()
s, err := NewServer(filepath.Join(dir, "test.db"), filepath.Join(dir, "data"), &Config{Port: 47732})
if err != nil {
t.Fatalf("NewServer: %v", err)
}
defer s.Close()
s.SetupRoutes()
now := time.Now().UTC().Format(time.RFC3339)
if _, err := s.db.Exec(
"INSERT INTO server_devices (id, name, api_key, last_seen, revoked_at, created_at) VALUES (?, ?, ?, ?, ?, ?)",
"device-revoked", "Revoked Device", "revoked-key", now, now, now,
); err != nil {
t.Fatalf("insert device: %v", err)
}
ts := httptest.NewServer(s.mux)
defer ts.Close()
pushStatus, pushResp := postJSONStatus(t, ts.URL+"/api/v1/sync/push", "revoked-key", map[string]interface{}{
"device_id": "device-revoked",
"ops": []map[string]interface{}{},
})
if pushStatus != http.StatusUnauthorized || pushResp["error"] != "device revoked" {
t.Fatalf("push status=%d resp=%#v, want 401 device revoked", pushStatus, pushResp)
}
pullStatus, pullResp := postJSONStatus(t, ts.URL+"/api/v1/sync/pull", "revoked-key", map[string]interface{}{
"since_sequence": 0,
})
if pullStatus != http.StatusUnauthorized || pullResp["error"] != "device revoked" {
t.Fatalf("pull status=%d resp=%#v, want 401 device revoked", pullStatus, pullResp)
}
}
func TestSyncPullDoesNotExposeOtherUserOperations(t *testing.T) {
s, ts := newSyncHTTPServer(t)
defer s.Close()
defer ts.Close()
insertSyncUser(t, s, "user-a")
insertSyncUser(t, s, "user-b")
insertSyncDevice(t, s, "device-a", "user-a", "token-a")
insertSyncDevice(t, s, "device-b", "user-b", "token-b")
postJSON(t, ts.URL+"/api/v1/sync/push", "token-a", syncPushBody("device-a", "op-user-a", ""))
pull := postJSON(t, ts.URL+"/api/v1/sync/pull", "token-b", map[string]interface{}{
"since_sequence": 0,
})
if got := len(pull["ops"].([]interface{})); got != 0 {
t.Fatalf("other user pulled %d operation(s), want 0: %#v", got, pull)
}
if got := int(pull["server_sequence"].(float64)); got != 0 {
t.Fatalf("other user cursor = %d, want 0: %#v", got, pull)
}
}
func TestSyncPushUsesAuthenticatedDeviceIdentity(t *testing.T) {
s, ts := newSyncHTTPServer(t)
defer s.Close()
defer ts.Close()
insertSyncUser(t, s, "user-a")
insertSyncUser(t, s, "user-b")
insertSyncDevice(t, s, "device-authenticated", "user-a", "token-a")
insertSyncDevice(t, s, "device-forged", "user-b", "token-b")
postJSON(t, ts.URL+"/api/v1/sync/push", "token-a", syncPushBody("device-forged", "op-auth-device", ""))
var storedDeviceID string
if err := s.db.QueryRow("SELECT device_id FROM server_ops WHERE op_id=?", "op-auth-device").Scan(&storedDeviceID); err != nil {
t.Fatalf("read stored operation: %v", err)
}
if storedDeviceID != "device-authenticated" {
t.Fatalf("stored device = %q, want authenticated device", storedDeviceID)
}
}
func TestSyncPushScopesIdempotencyResponsesByUser(t *testing.T) {
s, ts := newSyncHTTPServer(t)
defer s.Close()
defer ts.Close()
insertSyncUser(t, s, "user-a")
insertSyncUser(t, s, "user-b")
insertSyncDevice(t, s, "device-a", "user-a", "token-a")
insertSyncDevice(t, s, "device-b", "user-b", "token-b")
postJSON(t, ts.URL+"/api/v1/sync/push", "token-a", syncPushBody("device-a", "op-user-a", "same-key"))
response := postJSON(t, ts.URL+"/api/v1/sync/push", "token-b", syncPushBody("device-b", "op-user-b", "same-key"))
accepted := response["accepted"].([]interface{})
if len(accepted) != 1 || accepted[0] != "op-user-b" {
t.Fatalf("user-b received idempotency response %#v, want only op-user-b", response)
}
}
func TestSyncPushDoesNotReportOtherTenantConflicts(t *testing.T) {
s, ts := newSyncHTTPServer(t)
defer s.Close()
defer ts.Close()
insertSyncUser(t, s, "user-a")
insertSyncUser(t, s, "user-b")
insertSyncDevice(t, s, "device-a", "user-a", "token-a")
insertSyncDevice(t, s, "device-b", "user-b", "token-b")
postJSON(t, ts.URL+"/api/v1/sync/push", "token-a", syncPushBody("device-a", "op-user-a-1", ""))
postJSON(t, ts.URL+"/api/v1/sync/push", "token-a", syncPushBody("device-a", "op-user-a-2", ""))
body := syncPushBody("device-b", "op-user-b", "")
body["ops"].([]map[string]interface{})[0]["last_seen_server_seq"] = 1
response := postJSON(t, ts.URL+"/api/v1/sync/push", "token-b", body)
if response["conflicts"] != nil {
t.Fatalf("other tenant conflict leaked into response: %#v", response)
}
}
func TestSyncPullDoesNotExposeOtherVaultOperations(t *testing.T) {
s, ts := newSyncHTTPServer(t)
defer s.Close()
defer ts.Close()
password := "correct horse battery staple"
insertPairableUser(t, s, "user-a", "alice", password)
deviceA := pairSyncDevice(t, ts.URL, "alice", password, "vault-a")
deviceB := pairSyncDevice(t, ts.URL, "alice", password, "vault-b")
postJSON(t, ts.URL+"/api/v1/sync/push", deviceA.token, syncPushBody(deviceA.id, "op-vault-a", ""))
postJSON(t, ts.URL+"/api/v1/sync/push", deviceB.token, syncPushBody(deviceB.id, "op-vault-b", ""))
pull := postJSON(t, ts.URL+"/api/v1/sync/pull", deviceA.token, map[string]interface{}{
"since_sequence": 0,
})
ops := pull["ops"].([]interface{})
if len(ops) != 1 || ops[0].(map[string]interface{})["op_id"] != "op-vault-a" {
t.Fatalf("vault-a pulled %#v, want only op-vault-a", pull)
}
}
func TestClientPairRequiresVaultID(t *testing.T) {
s, ts := newSyncHTTPServer(t)
defer s.Close()
defer ts.Close()
password := "correct horse battery staple"
insertPairableUser(t, s, "user-a", "alice", password)
status, response := postJSONStatus(t, ts.URL+"/api/client/pair", "", map[string]interface{}{
"login": "alice",
"password": password,
"device_name": "Desktop",
})
if status != http.StatusBadRequest || response["error"] != "vault_id required" {
t.Fatalf("pair without vault ID status=%d response=%#v, want 400 vault_id required", status, response)
}
}
func TestClientPairRejectsReservedLegacyVaultID(t *testing.T) {
s, ts := newSyncHTTPServer(t)
defer s.Close()
defer ts.Close()
password := "correct horse battery staple"
insertPairableUser(t, s, "user-a", "alice", password)
status, response := postJSONStatus(t, ts.URL+"/api/client/pair", "", map[string]interface{}{
"login": "alice",
"password": password,
"device_name": "Desktop",
"vault_id": "legacy:user-a",
})
if status != http.StatusBadRequest || response["error"] != "vault_id uses reserved prefix" {
t.Fatalf("pair with reserved vault ID status=%d response=%#v, want 400 reserved prefix", status, response)
}
}
func TestDeviceRegisterRequiresValidVaultID(t *testing.T) {
s, ts := newSyncHTTPServer(t)
defer s.Close()
defer ts.Close()
password := "correct horse battery staple"
insertPairableUser(t, s, "user-a", "alice", password)
status, response := postJSONStatus(t, ts.URL+"/api/v1/device/register", "", map[string]interface{}{
"name": "Desktop",
"username": "alice",
"password": password,
})
if status != http.StatusBadRequest || response["error"] != "vault_id required" {
t.Fatalf("register without vault ID status=%d response=%#v, want 400 vault_id required", status, response)
}
status, response = postJSONStatus(t, ts.URL+"/api/v1/device/register", "", map[string]interface{}{
"name": "Desktop",
"username": "alice",
"password": password,
"vault_id": "legacy:user-a",
})
if status != http.StatusBadRequest || response["error"] != "vault_id uses reserved prefix" {
t.Fatalf("register with reserved vault ID status=%d response=%#v, want 400 reserved prefix", status, response)
}
}
func TestWebResetRejectsExpiredToken(t *testing.T) {
s, _ := newSyncHTTPServer(t)
defer s.Close()
oldPassword := "correct horse battery staple"
insertPairableUser(t, s, "user-a", "alice", oldPassword)
now := time.Now().UTC().Format(time.RFC3339)
if _, err := s.db.Exec(`INSERT INTO server_email_tokens
(token, user_id, purpose, expires_at, created_at)
VALUES (?, ?, 'reset', ?, ?)`, "expired-reset-token", "user-a", time.Now().Add(-time.Hour).UTC().Format(time.RFC3339), now); err != nil {
t.Fatalf("insert reset token: %v", err)
}
response := postWebReset(t, s, "expired-reset-token", "new secret password")
if response.Code != http.StatusFound || response.Header().Get("Location") != "/forgot" {
t.Fatalf("expired reset response status=%d location=%q, want 302 /forgot", response.Code, response.Header().Get("Location"))
}
var passwordHash string
if err := s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", "user-a").Scan(&passwordHash); err != nil {
t.Fatalf("read password hash: %v", err)
}
if bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(oldPassword)) != nil {
t.Fatal("expired reset changed the password")
}
}
func TestServerRenderedPagesEscapeStoredValues(t *testing.T) {
s, _ := newSyncHTTPServer(t)
defer s.Close()
const maliciousText = `<img src=x onerror=alert(1)>`
const maliciousDeviceID = `device');alert(1);//`
now := time.Now().UTC().Format(time.RFC3339)
if _, err := s.db.Exec(`INSERT INTO server_users
(id, username, email, password_hash, confirmed, created_at)
VALUES (?, ?, ?, ?, 1, ?)`, "user-a", maliciousText, maliciousText+"@example.test", "unused", now); err != nil {
t.Fatalf("insert user: %v", err)
}
if _, err := s.db.Exec(`INSERT INTO server_devices
(id, name, api_key, user_id, vault_id, client_version, last_seen, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, maliciousDeviceID, maliciousText, "device-key", "user-a", "vault-a", maliciousText, now, now); err != nil {
t.Fatalf("insert device: %v", err)
}
if _, err := s.db.Exec("INSERT INTO server_user_devices (user_id, device_id) VALUES (?, ?)", "user-a", maliciousDeviceID); err != nil {
t.Fatalf("link user device: %v", err)
}
tests := []struct {
name string
path string
cookie *http.Cookie
containsDevID bool
}{
{
name: "user dashboard",
path: "/dashboard",
cookie: &http.Cookie{Name: "user_session", Value: s.userTokens.Create("user-a")},
containsDevID: true,
},
{
name: "admin users",
path: "/admin/users",
cookie: &http.Cookie{Name: "admin_session", Value: s.tokens.Create()},
},
{
name: "admin devices",
path: "/admin/devices",
cookie: &http.Cookie{Name: "admin_session", Value: s.tokens.Create()},
containsDevID: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
req.AddCookie(tt.cookie)
res := httptest.NewRecorder()
s.mux.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", res.Code)
}
body := res.Body.String()
if strings.Contains(body, maliciousText) {
t.Fatalf("page contains unescaped stored text: %s", body)
}
if !strings.Contains(body, html.EscapeString(maliciousText)) {
t.Fatalf("page does not contain escaped stored text: %s", body)
}
if tt.containsDevID && strings.Contains(body, maliciousDeviceID) {
t.Fatalf("page contains unescaped device ID: %s", body)
}
})
}
}
func TestNewServerMigratesLegacyOperationScope(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "legacy.db")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("open legacy db: %v", err)
}
legacySchema := []string{
`CREATE TABLE server_devices (
id TEXT PRIMARY KEY, name TEXT NOT NULL, api_key TEXT NOT NULL UNIQUE,
token_hash TEXT, token_prefix TEXT, token_suffix TEXT, user_id TEXT,
client_version TEXT, last_ip TEXT, last_seen TEXT, revoked_at TEXT,
created_at TEXT NOT NULL
)`,
`CREATE TABLE server_user_devices (
user_id TEXT NOT NULL, device_id TEXT NOT NULL,
PRIMARY KEY (user_id, device_id)
)`,
`CREATE TABLE server_ops (
op_id TEXT PRIMARY KEY, server_sequence INTEGER, device_id TEXT NOT NULL,
entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, op_type TEXT NOT NULL,
payload_json TEXT NOT NULL, idempotency_key TEXT, client_sequence INTEGER DEFAULT 0,
last_seen_server_seq INTEGER DEFAULT 0, created_at TEXT NOT NULL,
pushed_at TEXT NOT NULL
)`,
`CREATE TABLE server_tombstones (
entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, op_id TEXT NOT NULL,
deleted_at TEXT NOT NULL, PRIMARY KEY (entity_type, entity_id)
)`,
`CREATE TABLE server_idempotency_keys (
idempotency_key TEXT PRIMARY KEY, response_json TEXT NOT NULL, created_at TEXT NOT NULL
)`,
}
for _, stmt := range legacySchema {
if _, err := db.Exec(stmt); err != nil {
t.Fatalf("create legacy schema: %v", err)
}
}
now := time.Now().UTC().Format(time.RFC3339)
if _, err := db.Exec(`INSERT INTO server_devices (id, name, api_key, last_seen, created_at)
VALUES (?, ?, ?, ?, ?)`, "legacy-device", "Legacy", "legacy-key", now, now); err != nil {
t.Fatalf("insert legacy device: %v", err)
}
if _, err := db.Exec(`INSERT INTO server_user_devices (user_id, device_id) VALUES (?, ?)`, "legacy-user", "legacy-device"); err != nil {
t.Fatalf("insert legacy device owner: %v", err)
}
if _, err := db.Exec(`INSERT INTO server_ops
(op_id, server_sequence, device_id, entity_type, entity_id, op_type, payload_json, created_at, pushed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, "legacy-op", 1, "legacy-device", "file", "Docs/one.txt", "create", `{}`, now, now); err != nil {
t.Fatalf("insert legacy operation: %v", err)
}
if err := db.Close(); err != nil {
t.Fatalf("close legacy db: %v", err)
}
s, err := NewServer(dbPath, filepath.Join(dir, "data"), &Config{Port: 47732})
if err != nil {
t.Fatalf("NewServer migration: %v", err)
}
assertLegacyOperationScope(t, s)
if err := s.Close(); err != nil {
t.Fatalf("close migrated db: %v", err)
}
s, err = NewServer(dbPath, filepath.Join(dir, "data"), &Config{Port: 47732})
if err != nil {
t.Fatalf("NewServer repeated migration: %v", err)
}
defer s.Close()
assertLegacyOperationScope(t, s)
}
func assertLegacyOperationScope(t *testing.T, s *Server) {
t.Helper()
var userID, vaultID string
if err := s.db.QueryRow("SELECT user_id, vault_id FROM server_ops WHERE op_id=?", "legacy-op").Scan(&userID, &vaultID); err != nil {
t.Fatalf("read migrated operation: %v", err)
}
if userID != "legacy-user" || vaultID != "legacy:legacy-user" {
t.Fatalf("migrated scope = %q/%q, want legacy-user/legacy:legacy-user", userID, vaultID)
}
}
func newSyncHTTPServer(t *testing.T) (*Server, *httptest.Server) {
t.Helper()
dir := t.TempDir()
s, err := NewServer(filepath.Join(dir, "test.db"), filepath.Join(dir, "data"), &Config{Port: 47732})
if err != nil {
t.Fatalf("NewServer: %v", err)
}
s.SetupRoutes()
return s, httptest.NewServer(s.mux)
}
func insertSyncUser(t *testing.T, s *Server, userID string) {
t.Helper()
now := time.Now().UTC().Format(time.RFC3339)
if _, err := s.db.Exec(`INSERT INTO server_users
(id, username, email, password_hash, confirmed, created_at)
VALUES (?, ?, ?, ?, 1, ?)`, userID, userID, userID+"@example.test", "unused", now); err != nil {
t.Fatalf("insert sync user %s: %v", userID, err)
}
}
func insertPairableUser(t *testing.T, s *Server, userID, username, password string) {
t.Helper()
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
t.Fatalf("hash password: %v", err)
}
now := time.Now().UTC().Format(time.RFC3339)
if _, err := s.db.Exec(`INSERT INTO server_users
(id, username, email, password_hash, confirmed, created_at)
VALUES (?, ?, ?, ?, 1, ?)`, userID, username, username+"@example.test", string(passwordHash), now); err != nil {
t.Fatalf("insert pairable user: %v", err)
}
}
func insertSyncDevice(t *testing.T, s *Server, deviceID, userID, token string) {
t.Helper()
now := time.Now().UTC().Format(time.RFC3339)
if _, err := s.db.Exec(`INSERT INTO server_devices
(id, name, api_key, token_hash, user_id, last_seen, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`, deviceID, deviceID, "legacy-"+deviceID, sha256Hex(token), userID, now, now); err != nil {
t.Fatalf("insert sync device %s: %v", deviceID, err)
}
}
func syncPushBody(deviceID, opID, idempotencyKey string) map[string]interface{} {
return map[string]interface{}{
"device_id": deviceID,
"idempotency_key": idempotencyKey,
"ops": []map[string]interface{}{
{
"op_id": opID,
"entity_type": "file",
"entity_id": "Docs/one.txt",
"op_type": "create",
"payload_json": `{"path":"Docs/one.txt","content":"hello"}`,
"created_at": "2026-07-10T00:00:00Z",
},
},
}
}
type pairedSyncDevice struct {
id string
token string
}
func pairSyncDevice(t *testing.T, serverURL, username, password, vaultID string) pairedSyncDevice {
t.Helper()
response := postJSON(t, serverURL+"/api/client/pair", "", map[string]interface{}{
"login": username,
"password": password,
"device_name": "Desktop " + vaultID,
"vault_id": vaultID,
})
return pairedSyncDevice{
id: response["device_id"].(string),
token: response["device_token"].(string),
}
}
func postWebReset(t *testing.T, s *Server, token, password string) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{
"token": {token},
"password": {password},
"confirm": {password},
}
request := httptest.NewRequest(http.MethodPost, "/reset", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response := httptest.NewRecorder()
s.mux.ServeHTTP(response, request)
return response
}
func postJSON(t *testing.T, url, token string, body interface{}) map[string]interface{} {
t.Helper()
status, out := postJSONStatus(t, url, token, body)
if status != http.StatusOK {
t.Fatalf("post %s status = %d", url, status)
}
return out
}
func postJSONStatus(t *testing.T, url, token string, body interface{}) (int, map[string]interface{}) {
t.Helper()
var b bytes.Buffer
if err := json.NewEncoder(&b).Encode(body); err != nil {
t.Fatalf("encode request: %v", err)
}
req, err := http.NewRequest(http.MethodPost, url, &b)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post %s: %v", url, err)
}
defer resp.Body.Close()
var out map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode response: %v", err)
}
return resp.StatusCode, out
}
+8 -1
View File
@@ -2,6 +2,7 @@ package server
import (
"fmt"
"html"
"strings"
)
@@ -311,6 +312,9 @@ input:focus{outline:none;border-color:#6366f1}
.form-row{display:flex;gap:8px;margin-bottom:8px;align-items:center}
.form-row label{font-size:12px;color:#888;min-width:80px;flex-shrink:0}
.form-row input{flex:1}
.form-select{font-family:inherit;font-size:14px;padding:8px 32px 8px 12px;border:1px solid #2a2a3c;background-color:#13131f;color:#e4e4ef;border-radius:6px;flex:1;box-sizing:border-box;appearance:none;background-image:linear-gradient(45deg,transparent 50%%,#8b93aa 50%%),linear-gradient(135deg,#8b93aa 50%%,transparent 50%%);background-position:calc(100%% - 16px) 50%%,calc(100%% - 11px) 50%%;background-size:5px 5px,5px 5px;background-repeat:no-repeat}
.form-select option{background:#13131f;color:#e4e4ef}
.form-select:focus{outline:none;border-color:#6366f1;box-shadow:0 0 0 2px rgba(99,102,241,.24)}
.toolbar{display:flex;gap:8px;margin:16px 0;flex-wrap:wrap}
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100}
.modal{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:24px;width:420px;max-width:90vw;position:relative;max-height:80vh;overflow-y:auto}
@@ -379,7 +383,7 @@ function testSMTP(){
<form action="/admin/api/smtp" method="POST">
<div class="form-row"><label>%[18]s</label><input name="smtp_host" value="%[32]s" placeholder="smtp.example.com"></div>
<div class="form-row"><label>%[19]s</label><input name="smtp_port" value="%[33]s" placeholder="587"></div>
<div class="form-row"><label>%[20]s</label><select name="smtp_security" style="font-family:inherit;font-size:14px;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;flex:1;box-sizing:border-box">
<div class="form-row"><label>%[20]s</label><select name="smtp_security" class="form-select">
<option value="starttls"%[34]s>STARTTLS</option>
<option value="tls"%[35]s>TLS</option>
<option value="none"%[36]s>%[21]s</option>
@@ -552,6 +556,9 @@ button:hover{background:#4f46e5}
}
func errorPageHTML(locale, title, msg, backURL string) string {
title = html.EscapeString(title)
msg = html.EscapeString(msg)
backURL = html.EscapeString(backURL)
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-}"
REPOSITORY="mirivlad/verstak-sync-server"
RELEASE_SCRIPT="${VERSTAK_RELEASE_SCRIPT:-$ROOT/scripts/release.sh}"
RELEASE_DIR="${VERSTAK_RELEASE_DIR:-$ROOT/release}"
GIT_BIN="${GIT_BIN:-git}"
GH_BIN="${GH_BIN:-gh}"
cd "$ROOT"
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "usage: $0 <version>" >&2
echo "example: $0 v0.1.0-alpha.1" >&2
exit 2
fi
if ! command -v "$GH_BIN" >/dev/null; then
echo "gh CLI is required to publish a GitHub Release" >&2
exit 1
fi
if [[ "$("$GIT_BIN" branch --show-current)" != "main" ]]; then
echo "GitHub releases must be published from main" >&2
exit 1
fi
if [[ -n "$("$GIT_BIN" status --porcelain)" ]]; then
echo "working tree must be clean before publishing a release" >&2
exit 1
fi
"$GH_BIN" auth status
"$GIT_BIN" fetch origin main --tags
HEAD="$("$GIT_BIN" rev-parse HEAD)"
if [[ "$HEAD" != "$("$GIT_BIN" rev-parse origin/main)" ]]; then
echo "local main must match origin/main before publishing a release" >&2
exit 1
fi
"$RELEASE_SCRIPT" "$VERSION"
ARCHIVE="$RELEASE_DIR/verstak-sync-server-linux-amd64-$VERSION.tar.gz"
if [[ ! -f "$ARCHIVE" ]]; then
echo "release archive not found: $ARCHIVE" >&2
exit 1
fi
ASSETS=("$ARCHIVE")
if [[ -f "$RELEASE_DIR/SHA256SUMS" ]]; then
ASSETS+=("$RELEASE_DIR/SHA256SUMS")
fi
if "$GIT_BIN" rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
if [[ "$("$GIT_BIN" rev-parse "${VERSION}^{commit}")" != "$HEAD" ]]; then
echo "existing tag $VERSION does not point at HEAD" >&2
exit 1
fi
else
"$GIT_BIN" tag -a "$VERSION" -m "Release $VERSION"
"$GIT_BIN" push origin "refs/tags/$VERSION"
fi
if "$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" >/dev/null 2>&1; then
"$GH_BIN" release upload "$VERSION" "${ASSETS[@]}" --repo "$REPOSITORY" --clobber
else
RELEASE_OPTIONS=(--generate-notes --verify-tag)
if [[ "$VERSION" == *-* ]]; then
RELEASE_OPTIONS+=(--prerelease)
else
RELEASE_OPTIONS+=(--latest)
fi
"$GH_BIN" release create "$VERSION" "${ASSETS[@]}" \
--repo "$REPOSITORY" \
--title "Verstak Sync Server $VERSION" \
"${RELEASE_OPTIONS[@]}"
fi
echo "GitHub release:"
"$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" --json url --jq .url
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-}"
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "usage: $0 <version>" >&2
echo "example: $0 v0.1.0-alpha.1" >&2
exit 2
fi
if [[ "$(go env GOOS)" != "linux" || "$(go env GOARCH)" != "amd64" ]]; then
echo "sync-server release artifacts are currently built for linux/amd64" >&2
exit 1
fi
echo "=== verstak sync server release $VERSION ==="
"$ROOT/scripts/build.sh"
RELEASE_ROOT="$ROOT/release"
STAGING="$RELEASE_ROOT/verstak-sync-server-$VERSION-linux-amd64"
ARCHIVE="$RELEASE_ROOT/verstak-sync-server-linux-amd64-$VERSION.tar.gz"
rm -rf "$STAGING" "$ARCHIVE"
mkdir -p "$STAGING/scripts"
cp "$ROOT/build/bin/verstak-sync-server" "$STAGING/verstak-sync-server"
cp "$ROOT/README.md" "$ROOT/LICENSE" "$ROOT/verstak-server.service" "$STAGING/"
cp "$ROOT/scripts/install.sh" "$STAGING/scripts/install.sh"
tar -C "$RELEASE_ROOT" -czf "$ARCHIVE" "$(basename "$STAGING")"
(cd "$RELEASE_ROOT" && sha256sum "$(basename "$ARCHIVE")" > SHA256SUMS)
echo "release archive: $ARCHIVE"
echo "checksums: $RELEASE_ROOT/SHA256SUMS"
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PUBLISHER="$ROOT/scripts/publish-github-release.sh"
VERSION="v0.0.0-test"
REPOSITORY="mirivlad/verstak-sync-server"
ASSET_NAME="verstak-sync-server-linux-amd64-${VERSION}.tar.gz"
STALE_ASSET="verstak-sync-server-linux-amd64-v0.0.0-stale.tar.gz"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
if [[ ! -x "$PUBLISHER" ]]; then
echo "publisher is missing or not executable: $PUBLISHER" >&2
exit 1
fi
mkdir -p "$WORK/bin" "$WORK/release" "$WORK/state"
LOG="$WORK/log"
export LOG
cat > "$WORK/release.sh" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
printf 'release:%s\n' "$1" >> "$LOG"
printf 'archive\n' > "$VERSTAK_RELEASE_DIR/verstak-sync-server-linux-amd64-$1.tar.gz"
printf 'checksum\n' > "$VERSTAK_RELEASE_DIR/SHA256SUMS"
SCRIPT
chmod +x "$WORK/release.sh"
printf 'stale archive\n' > "$WORK/release/$STALE_ASSET"
cat > "$WORK/bin/git" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$PWD" != "${EXPECTED_ROOT:?}" ]]; then
echo "publisher did not enter repository root: $PWD" >&2
exit 1
fi
case "${1:-}" in
status) exit 0 ;;
branch) echo main ;;
fetch) printf 'fetch\n' >> "$LOG" ;;
rev-parse)
if [[ "${2:-}" == "-q" ]]; then
if [[ -f "$TEST_STATE/tag" ]]; then echo test-commit; exit 0; fi
exit 1
fi
echo test-commit
;;
tag) touch "$TEST_STATE/tag"; printf 'tag:%s\n' "${3:-}" >> "$LOG" ;;
push) printf 'push:%s:%s\n' "${2:-}" "${3:-}" >> "$LOG" ;;
*) echo "unexpected git invocation: $*" >&2; exit 1 ;;
esac
SCRIPT
chmod +x "$WORK/bin/git"
cat > "$WORK/bin/gh" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
case "${1:-}:${2:-}" in
auth:status) printf 'auth\n' >> "$LOG" ;;
release:view)
if [[ -f "$TEST_STATE/release" ]]; then echo https://github.example/release; else exit 1; fi
;;
release:create) touch "$TEST_STATE/release"; printf 'create:%s\n' "$*" >> "$LOG" ;;
release:upload) printf 'upload:%s\n' "$*" >> "$LOG" ;;
*) echo "unexpected gh invocation: $*" >&2; exit 1 ;;
esac
SCRIPT
chmod +x "$WORK/bin/gh"
run_publisher() {
VERSTAK_RELEASE_SCRIPT="$WORK/release.sh" \
VERSTAK_RELEASE_DIR="$WORK/release" \
GIT_BIN="$WORK/bin/git" \
GH_BIN="$WORK/bin/gh" \
EXPECTED_ROOT="$ROOT" \
TEST_STATE="$WORK/state" \
"$PUBLISHER" "$VERSION"
}
run_publisher
grep -Fqx "release:$VERSION" "$LOG"
grep -Fqx "tag:$VERSION" "$LOG"
grep -Fqx "push:origin:refs/tags/$VERSION" "$LOG"
grep -F "release create $VERSION" "$LOG" >/dev/null
grep -F "$ASSET_NAME" "$LOG" >/dev/null
grep -F "SHA256SUMS" "$LOG" >/dev/null
grep -F -- "--prerelease" "$LOG" >/dev/null
if grep -F -- "--latest" "$LOG" >/dev/null; then
echo "alpha release was incorrectly marked latest" >&2
exit 1
fi
if grep -F "$STALE_ASSET" "$LOG" >/dev/null; then
echo "publisher uploaded an archive from a different release version" >&2
exit 1
fi
run_publisher
grep -F "release upload $VERSION" "$LOG" >/dev/null
echo "GitHub release publisher test passed"
+3 -1
View File
@@ -7,7 +7,9 @@ Type=simple
User=verstak
Group=verstak
WorkingDirectory=/opt/verstak-sync-server
ExecStart=/opt/verstak-sync-server/verstak-sync-server --port ${VERSTAK_PORT:-47732} --data /var/lib/verstak-sync-server
Environment=VERSTAK_PORT=47732
EnvironmentFile=-/etc/verstak-server/env
ExecStart=/opt/verstak-sync-server/verstak-sync-server --port ${VERSTAK_PORT} --data /var/lib/verstak-sync-server
Restart=on-failure
RestartSec=5
AmbientCapabilities=CAP_NET_BIND_SERVICE