23 Commits
Author SHA1 Message Date
mirivlad d05ffc8cf3 fix: publish alpha server releases as prereleases 2026-07-15 00:00:01 +08:00
mirivlad 0d0ae92dec fix: hide internal server errors from clients 2026-07-14 22:00:24 +08:00
mirivlad 66bd53f835 fix: style SMTP security selector 2026-07-14 20:38:16 +08:00
mirivlad 4958e6ca44 fix: publish only requested server release asset 2026-07-14 03:42:01 +08:00
mirivlad 8da7f10c6b build: package and publish sync server releases 2026-07-13 05:01:57 +08:00
mirivlad 2ea29cb503 docs: license sync server under AGPL 2026-07-12 23:06:33 +08:00
mirivlad 31f18dade1 fix: escape server-rendered account data 2026-07-10 03:23:01 +08:00
mirivlad 657cc23e7a fix: enforce expiry for web password resets 2026-07-10 03:17:27 +08:00
mirivlad 086bc4ea4a fix: isolate sync operations by user and vault 2026-07-10 03:13:17 +08:00
mirivlad 5c8644d5e1 docs: plan sync tenant isolation 2026-07-10 02:40:21 +08:00
mirivlad 1084c91854 docs: document sync server deployment backups 2026-06-29 03:33:21 +08:00
mirivlad 2e6b451c3d Reject revoked legacy sync API keys 2026-06-27 13:39:00 +08:00
mirivlad fcf0a07fcc Document and test sync API contract 2026-06-27 12:36:31 +08:00
mirivlad c793084fa4 server: add SMTP config, user management web pages, install script
- SMTP configuration in admin panel with test button
- Admin web interface for user creation (/admin/create-user)
- REST API for user creation (/admin/api/users/create)
- Self-registration endpoint (/register)
- Systemd service file and install script
2026-06-20 19:21:25 +08:00
mirivlad c9b35295cb feat: add user creation with manual password in admin UI and API 2026-06-20 12:20:46 +08:00
mirivlad cec2305b15 feat: add admin panel (login, dashboard, users, devices) 2026-06-20 11:21:29 +08:00
mirivlad 8b9c07ae06 fix: wire main.go to actual server package, server now starts 2026-06-20 11:13:41 +08:00
mirivlad 5e2be2b888 fix: clarify sync server build script, produce visible binary 2026-06-20 03:39:01 +08:00
mirivlad dc4507a7ab test: add server unit tests 2026-06-20 02:08:21 +08:00
mirivlad 81756e598f feat: add routes and API handlers
- routes.go: endpoint registration
- handlers_api.go: health, pair, auth test, revoke, me, device register, sync push/pull, blobs
- handlers_auth.go: register, confirm, login, forgot, reset
- middleware.go: requireAuth, requireAdmin, validatePassword
- tokens.go: genDeviceToken
- helpers.go: jsonOK, jsonErr, sha256Hex
- schema.go: add missing tables (email_tokens, revisions, blobs)
- server.go: add SetupRoutes()
2026-06-20 02:05:56 +08:00
mirivlad e0ae36998e feat: add server core (server, config, schema) 2026-06-20 02:01:23 +08:00
mirivlad 69dacb40b0 chore: add .gitignore 2026-06-20 01:57:09 +08:00
mirivlad 984acd2f7e feat: initialize sync server repository 2026-06-20 01:55:51 +08:00
31 changed files with 5428 additions and 17 deletions
+16
View File
@@ -0,0 +1,16 @@
# Binaries
/server
/verstak-sync-server
*.exe
# Data directory
server-data/
# IDE
.idea/
.vscode/
# 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
+213 -2
View File
@@ -1,3 +1,214 @@
# verstak-sync-server
# Verstak Sync Server
Verstak Sync Server — HTTP API, auth/pairing, device registry, vault operation log, blob upload/download, conflict handling
Standalone sync server for Verstak2 platform.
## Overview
This server provides synchronization between devices running Verstak2. It handles:
- Device registration and authentication
- Vault-scoped operation log sync with server sequence numbers and conflict detection
- Blob storage for attachments
- User management with email confirmation
## Quick Start
```bash
# Build (produces binary at build/bin/verstak-sync-server)
./scripts/build.sh
# Run
./build/bin/verstak-sync-server --port 47732 --data ./server-data
# First run with admin user
./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 |
|------|---------|-------------|
| `--port` | 47732 | HTTP port |
| `--data` | ./server-data | Data directory |
| `--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
- 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
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
```bash
# Run tests
go test ./...
# Build for production
CGO_ENABLED=1 go build -o verstak-sync-server ./cmd/server
```
## License
Copyright © 2026 Verstak contributors. Licensed under
[GNU AGPLv3 or later](LICENSE).
BIN
View File
Binary file not shown.
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"path/filepath"
"github.com/verstak/verstak-sync-server/internal/server"
)
func main() {
port := flag.Int("port", 47732, "HTTP port")
dataDir := flag.String("data", "./server-data", "Data directory (db, blobs, config)")
adminUser := flag.String("admin-user", "", "Create admin user (first run)")
adminPass := flag.String("admin-pass", "", "Admin password (first run)")
flag.Parse()
absData, err := filepath.Abs(*dataDir)
if err != nil {
log.Fatalf("data dir: %v", err)
}
if err := os.MkdirAll(absData, 0750); err != nil {
log.Fatalf("create data dir: %v", err)
}
cfg, err := server.LoadConfig(absData)
if err != nil {
log.Fatalf("config: %v", err)
}
if *adminUser != "" && *adminPass != "" {
if err := cfg.SetAdmin(*adminUser, *adminPass); err != nil {
log.Fatalf("set admin: %v", err)
}
fmt.Printf("Admin user %q created.\n", *adminUser)
}
dbPath := filepath.Join(absData, "server.db")
srv, err := server.NewServer(dbPath, absData, cfg)
if err != nil {
log.Fatalf("server: %v", err)
}
defer srv.Close()
srv.SetupRoutes()
addr := fmt.Sprintf(":%d", *port)
log.Printf("Verstak Sync Server starting on %s (data: %s)", addr, absData)
if err := srv.ListenAndServe(addr); err != nil {
log.Fatalf("serve: %v", err)
}
}
@@ -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.
+9
View File
@@ -0,0 +1,9 @@
module github.com/verstak/verstak-sync-server
go 1.25.0
require (
github.com/mattn/go-sqlite3 v1.14.46 // indirect
golang.org/x/crypto v0.53.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+7
View File
@@ -0,0 +1,7 @@
github.com/mattn/go-sqlite3 v1.14.46 h1:ZfaNcYO/CGNMRxkN1vvG9qf+Y+uvXfgT9a6MlEw+HmU=
github.com/mattn/go-sqlite3 v1.14.46/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+84
View File
@@ -0,0 +1,84 @@
package server
import (
"fmt"
"os"
"path/filepath"
"sync"
"golang.org/x/crypto/bcrypt"
"gopkg.in/yaml.v3"
)
type AdminUser struct {
Username string `yaml:"username"`
PasswordHash string `yaml:"password_hash"`
}
type Config struct {
Port int `yaml:"port"`
Admin []AdminUser `yaml:"admin"`
mu sync.Mutex
path string
}
func LoadConfig(dataDir string) (*Config, error) {
path := filepath.Join(dataDir, "config.yml")
cfg := &Config{
Port: 47732,
Admin: nil,
path: path,
}
data, err := os.ReadFile(path)
if err == nil {
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
}
return cfg, nil
}
func (c *Config) Save() error {
c.mu.Lock()
defer c.mu.Unlock()
return c.saveLocked()
}
func (c *Config) SetAdmin(username, password string) error {
c.mu.Lock()
defer c.mu.Unlock()
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
user := AdminUser{Username: username, PasswordHash: string(hash)}
for i, u := range c.Admin {
if u.Username == username {
c.Admin[i] = user
return c.saveLocked()
}
}
c.Admin = append(c.Admin, user)
return c.saveLocked()
}
func (c *Config) CheckAdmin(username, password string) bool {
c.mu.Lock()
defer c.mu.Unlock()
for _, u := range c.Admin {
if u.Username == username {
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil {
return true
}
}
}
return false
}
func (c *Config) saveLocked() error {
data, err := yaml.Marshal(c)
if err != nil {
return err
}
return os.WriteFile(c.path, data, 0640)
}
+604
View File
@@ -0,0 +1,604 @@
package server
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"html"
"log"
"net/http"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Admin Login</title>
<style>body{font-family:sans-serif;background:#1a1a2e;color:#e0e0f0;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
form{background:#16213e;padding:2rem;border-radius:8px;border:1px solid #0f3460;width:300px}
h2{margin:0 0 1rem;color:#e0e0f0}label{display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem}
input{width:100%;background:#0f3460;border:1px solid #1a3a5c;color:#e0e0f0;padding:8px 10px;border-radius:4px;font-size:0.85rem;box-sizing:border-box;margin-bottom:0.75rem}
button{background:#4ecca3;color:#1a1a2e;border:none;padding:0.5rem 1rem;border-radius:4px;cursor:pointer;font-weight:600;width:100%}</style></head>
<body><form method="POST"><h2>Admin Login</h2>
<label>Username</label><input name="username" required>
<label>Password</label><input type="password" name="password" required>
<button type="submit">Login</button></form></body></html>`))
case "POST":
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", 400)
return
}
user := r.FormValue("username")
pass := r.FormValue("password")
if !s.cfg.CheckAdmin(user, pass) {
http.Error(w, "401 Unauthorized", 401)
return
}
tok := s.tokens.Create()
http.SetCookie(w, &http.Cookie{
Name: "admin_session", Value: tok, Path: "/admin",
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 86400,
})
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
default:
http.Error(w, "method not allowed", 405)
}
}
func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
if !s.requireAdminCookie(w, r) {
return
}
var userCount, deviceCount, opsCount int
s.db.QueryRow("SELECT COUNT(*) FROM server_users").Scan(&userCount)
s.db.QueryRow("SELECT COUNT(*) FROM server_devices").Scan(&deviceCount)
s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&opsCount)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Admin Dashboard</title>
<style>body{font-family:sans-serif;background:#1a1a2e;color:#e0e0f0;margin:0;padding:2rem}
h1{color:#4ecca3}table{width:100%;border-collapse:collapse;margin:1rem 0}
th{text-align:left;padding:0.5rem;border-bottom:1px solid #0f3460;color:#a0a0b8}
td{padding:0.5rem;border-bottom:1px solid #0f3460}.stat{display:inline-block;background:#16213e;padding:1rem 1.5rem;border-radius:8px;margin:0.5rem;border:1px solid #0f3460}
.stat-num{font-size:1.5rem;color:#4ecca3;font-weight:600}.stat-label{color:#a0a0b8;font-size:0.85rem}
a{color:#4ecca3}</style></head><body>
<h1>Verstak Sync Server — Admin</h1>
<div class="stat"><div class="stat-num">` + intToStr(userCount) + `</div><div class="stat-label">Users</div></div>
<div class="stat"><div class="stat-num">` + intToStr(deviceCount) + `</div><div class="stat-label">Devices</div></div>
<div class="stat"><div class="stat-num">` + intToStr(opsCount) + `</div><div class="stat-label">Sync Ops</div></div>
<h2><a href="/admin/users">Users</a> | <a href="/admin/devices">Devices</a> | <a href="/api/v1/health">Health</a></h2>
</body></html>`))
}
func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
if !s.requireAdminCookie(w, r) {
return
}
rows, err := s.db.Query("SELECT id, username, email, confirmed, blocked, created_at FROM server_users ORDER BY created_at DESC")
if err != nil {
log.Printf("admin users: query failed: %v", err)
http.Error(w, t(s.locale(), "server.internalError"), http.StatusInternalServerError)
return
}
defer rows.Close()
var users []map[string]interface{}
for rows.Next() {
var id, username, email, createdAt string
var confirmed, blocked int
rows.Scan(&id, &username, &email, &confirmed, &blocked, &createdAt)
users = append(users, map[string]interface{}{
"id": id, "username": username, "email": email,
"confirmed": confirmed, "blocked": blocked, "created_at": createdAt,
})
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Users</title>
<style>body{font-family:sans-serif;background:#1a1a2e;color:#e0e0f0;margin:0;padding:2rem}
h1{color:#4ecca3}table{width:100%;border-collapse:collapse}th{text-align:left;padding:0.5rem;border-bottom:1px solid #0f3460;color:#a0a0b8}
td{padding:0.5rem;border-bottom:1px solid #0f3460}a{color:#4ecca3}</style></head><body>
<h1>Users <a href="/admin/dashboard">← Dashboard</a></h1>
<table><tr><th>Username</th><th>Email</th><th>Confirmed</th><th>Blocked</th><th>Created</th></tr>`))
for _, u := range users {
confirmed := "✅"
if u["confirmed"].(int) == 0 {
confirmed = "❌"
}
blocked := ""
if u["blocked"].(int) != 0 {
blocked = "🚫"
}
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>`))
}
func (s *Server) handleAdminDevices(w http.ResponseWriter, r *http.Request) {
if !s.requireAdminCookie(w, r) {
return
}
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 {
log.Printf("admin devices: query failed: %v", err)
http.Error(w, t(s.locale(), "server.internalError"), http.StatusInternalServerError)
return
}
defer rows.Close()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Devices</title>
<style>body{font-family:sans-serif;background:#1a1a2e;color:#e0e0f0;margin:0;padding:2rem}
h1{color:#4ecca3}table{width:100%;border-collapse:collapse}th{text-align:left;padding:0.5rem;border-bottom:1px solid #0f3460;color:#a0a0b8}
td{padding:0.5rem;border-bottom:1px solid #0f3460}a{color:#4ecca3}</style></head><body>
<h1>Devices <a href="/admin/dashboard">← Dashboard</a></h1>
<table><tr><th>Name</th><th>ID</th><th>Version</th><th>Last Seen</th><th>Revoked</th><th>Created</th></tr>`))
for rows.Next() {
var id, name, clientVer, lastSeen, revokedAt, createdAt string
rows.Scan(&id, &name, &clientVer, &lastSeen, &revokedAt, &createdAt)
if lastSeen == "" {
lastSeen = "never"
}
if revokedAt == "" {
revokedAt = "-"
}
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>`))
}
func (s *Server) requireAdminCookie(w http.ResponseWriter, r *http.Request) bool {
cookie, err := r.Cookie("admin_session")
if err != nil || cookie.Value == "" {
http.Redirect(w, r, "/admin/login", http.StatusFound)
return false
}
if !s.tokens.Check(cookie.Value) {
http.Redirect(w, r, "/admin/login", http.StatusFound)
return false
}
return true
}
func intToStr(n int) string {
b, _ := json.Marshal(n)
return strings.Trim(string(b), "\"")
}
func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) {
if !s.requireAdminCookie(w, r) {
return
}
var opsCount int
s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&opsCount)
jsonOK(w, map[string]int{"ops": opsCount})
}
func (s *Server) handleAdminSMTPTest(w http.ResponseWriter, r *http.Request) {
if !s.requireAdminCookie(w, r) {
return
}
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
Host string `json:"smtp_host"`
Port string `json:"smtp_port"`
User string `json:"smtp_user"`
Pass string `json:"smtp_pass"`
Security string `json:"smtp_security"`
From string `json:"smtp_from"`
To string `json:"test_to"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "bad json")
return
}
host := req.Host
port := req.Port
user := req.User
pass := req.Pass
security := req.Security
from := req.From
to := req.To
if to == "" {
to = from
}
if host == "" || port == "" || from == "" {
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 {
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})
}
func (s *Server) handleAdminAPIDevices(w http.ResponseWriter, r *http.Request) {
if !s.requireAdminCookie(w, r) {
return
}
rows, err := s.db.Query(`
SELECT d.id, d.name, d.client_version, COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at,
COALESCE(u.username,'')
FROM server_devices d
LEFT JOIN server_users u ON u.id = d.user_id
ORDER BY d.created_at DESC`)
if err != nil {
jsonInternalError(w, err)
return
}
defer rows.Close()
type devDTO struct {
ID string `json:"id"`
Name string `json:"name"`
ClientVersion string `json:"client_version"`
LastSeen string `json:"last_seen"`
RevokedAt string `json:"revoked_at"`
CreatedAt string `json:"created_at"`
User string `json:"user"`
}
var out []devDTO
for rows.Next() {
var d devDTO
rows.Scan(&d.ID, &d.Name, &d.ClientVersion, &d.LastSeen, &d.RevokedAt, &d.CreatedAt, &d.User)
out = append(out, d)
}
jsonOK(w, out)
}
func (s *Server) handleAdminAPIKeys(w http.ResponseWriter, r *http.Request) {
if !s.requireAdminCookie(w, r) {
return
}
switch r.Method {
case "GET":
rows, err := s.db.Query("SELECT id, name, api_key FROM server_devices ORDER BY created_at")
if err != nil {
jsonInternalError(w, err)
return
}
defer rows.Close()
var out []map[string]string
for rows.Next() {
var id, name, key string
rows.Scan(&id, &name, &key)
out = append(out, map[string]string{"id": id, "name": name, "api_key": key})
}
jsonOK(w, out)
default:
jsonErr(w, 405, "method not allowed")
}
}
func (s *Server) handleAdminAPISmtp(w http.ResponseWriter, r *http.Request) {
if !s.requireAdminCookie(w, r) {
return
}
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
if err := r.ParseForm(); err != nil {
jsonErr(w, 400, "bad form")
return
}
for _, key := range []string{"smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_security", "smtp_from", "server_url"} {
val := r.FormValue(key)
if val != "" {
s.smtpSet(key, val)
}
}
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
}
func (s *Server) handleAdminAPIKeysDelete(w http.ResponseWriter, r *http.Request) {
if !s.requireAdminCookie(w, r) {
return
}
if r.Method != "DELETE" {
jsonErr(w, 405, "DELETE required")
return
}
id := strings.TrimPrefix(r.URL.Path, "/admin/api/keys/")
_, err := s.db.Exec("DELETE FROM server_devices WHERE id=?", id)
if err != nil {
jsonInternalError(w, err)
return
}
s.db.Exec("DELETE FROM server_user_devices WHERE device_id=?", id)
jsonOK(w, map[string]string{"status": "deleted"})
}
func (s *Server) handleAdminAPIUsers(w http.ResponseWriter, r *http.Request) {
if !s.requireAdminCookie(w, r) {
return
}
filter := r.URL.Query().Get("filter")
sort := r.URL.Query().Get("sort")
order := r.URL.Query().Get("order")
page := 1
perPage := 20
if v := r.URL.Query().Get("page"); v != "" {
fmt.Sscanf(v, "%d", &page)
}
if v := r.URL.Query().Get("per_page"); v != "" {
fmt.Sscanf(v, "%d", &perPage)
}
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 20
}
where := ""
var args []interface{}
if filter != "" {
where = " WHERE u.username LIKE ?"
args = append(args, "%"+filter+"%")
}
validSorts := map[string]string{
"username": "u.username",
"email": "u.email",
"confirmed": "u.confirmed",
"blocked": "u.blocked",
"created_at": "u.created_at",
"last_seen": "u.last_seen",
"devices": "devices",
}
orderClause := "u.created_at DESC"
if col, ok := validSorts[sort]; ok {
if order != "asc" {
order = "desc"
}
orderClause = col + " " + order
}
var total int
countSQL := "SELECT COUNT(*) FROM server_users u" + where
s.db.QueryRow(countSQL, args...).Scan(&total)
offset := (page - 1) * perPage
sql := `SELECT u.id, u.username, u.email, u.confirmed, u.blocked, u.last_seen, u.created_at,
COALESCE((SELECT COUNT(*) FROM server_user_devices ud JOIN server_devices d ON d.id=ud.device_id WHERE ud.user_id=u.id),0) AS devices
FROM server_users u` + where + ` ORDER BY ` + orderClause + ` LIMIT ? OFFSET ?`
args = append(args, perPage, offset)
rows, err := s.db.Query(sql, args...)
if err != nil {
jsonInternalError(w, err)
return
}
defer rows.Close()
type userRow struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Confirmed int `json:"confirmed"`
Blocked int `json:"blocked"`
LastSeen string `json:"last_seen"`
CreatedAt string `json:"created_at"`
Devices int `json:"devices"`
}
var users []userRow
for rows.Next() {
var u userRow
var lastSeen *string
rows.Scan(&u.ID, &u.Username, &u.Email, &u.Confirmed, &u.Blocked, &lastSeen, &u.CreatedAt, &u.Devices)
if lastSeen != nil {
u.LastSeen = *lastSeen
}
users = append(users, u)
}
jsonOK(w, map[string]interface{}{
"users": users,
"total": total,
"page": page,
"per_page": perPage,
})
}
func (s *Server) handleAdminAPIUserActions(w http.ResponseWriter, r *http.Request) {
if !s.requireAdminCookie(w, r) {
return
}
path := strings.TrimPrefix(r.URL.Path, "/admin/api/users/")
if strings.HasSuffix(path, "/block") && r.Method == "POST" {
id := strings.TrimSuffix(path, "/block")
id = strings.TrimSuffix(id, "/")
var blocked int
s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", id).Scan(&blocked)
newVal := 1
if blocked != 0 {
newVal = 0
}
s.db.Exec("UPDATE server_users SET blocked=? WHERE id=?", newVal, id)
jsonOK(w, map[string]interface{}{"status": "ok", "blocked": newVal})
return
}
if strings.HasSuffix(path, "/reset-password") && r.Method == "POST" {
id := strings.TrimSuffix(path, "/reset-password")
id = strings.TrimSuffix(id, "/")
b := make([]byte, 12)
rand.Read(b)
newPass := hex.EncodeToString(b)
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 {
jsonInternalError(w, err)
return
}
jsonOK(w, map[string]interface{}{"status": "ok", "new_password": newPass})
return
}
if strings.HasSuffix(path, "/edit") && r.Method == "POST" {
id := strings.TrimSuffix(path, "/edit")
id = strings.TrimSuffix(id, "/")
var editReq struct {
Username string `json:"username"`
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&editReq); err != nil {
jsonErr(w, 400, "bad json")
return
}
if editReq.Username == "" || editReq.Email == "" {
jsonErr(w, 400, "username and email required")
return
}
_, err := s.db.Exec("UPDATE server_users SET username=?, email=? WHERE id=?", editReq.Username, strings.ToLower(editReq.Email), id)
if err != nil {
jsonInternalError(w, err)
return
}
jsonOK(w, map[string]interface{}{"status": "ok"})
return
}
if r.Method == "DELETE" {
id := strings.TrimSuffix(path, "/")
rows, _ := s.db.Query("SELECT device_id FROM server_user_devices WHERE user_id=?", id)
var deviceIDs []string
for rows.Next() {
var did string
rows.Scan(&did)
deviceIDs = append(deviceIDs, did)
}
rows.Close()
for _, did := range deviceIDs {
s.db.Exec("DELETE FROM server_devices WHERE id=?", did)
}
s.db.Exec("DELETE FROM server_user_devices WHERE user_id=?", id)
s.db.Exec("DELETE FROM server_email_tokens WHERE user_id=?", id)
s.db.Exec("DELETE FROM server_users WHERE id=?", id)
jsonOK(w, map[string]interface{}{"status": "deleted"})
return
}
jsonErr(w, 404, "unknown action")
}
func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
if !s.requireAdminCookie(w, r) {
return
}
locale := s.locale()
switch r.Method {
case "GET":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(adminCreateUserHTML(locale)))
case "POST":
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", 400)
return
}
username := r.FormValue("username")
email := r.FormValue("email")
password := r.FormValue("password")
if username == "" || email == "" || password == "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(400)
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.allFieldsRequired"), "/admin/create-user")))
return
}
if err := validatePassword(password); err != "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(400)
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), string(err), "/admin/create-user")))
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(500)
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), "internal error", "/admin/create-user")))
return
}
now := time.Now().UTC().Format(time.RFC3339)
id := make([]byte, 12)
rand.Read(id)
userID := hex.EncodeToString(id)
_, err = s.db.Exec(
"INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 1, ?)",
userID, username, strings.ToLower(email), string(hash), now,
)
if err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if strings.Contains(err.Error(), "UNIQUE") {
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"), t(locale, "admin.createUserFailed"), "/admin/create-user")))
}
return
}
http.Redirect(w, r, "/admin/users", http.StatusFound)
default:
http.Error(w, "method not allowed", 405)
}
}
func (s *Server) handleAdminAPICreateUser(w http.ResponseWriter, r *http.Request) {
if !s.requireAdminCookie(w, r) {
return
}
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "bad json")
return
}
if req.Username == "" || req.Email == "" || req.Password == "" {
jsonErr(w, 400, "username, email and password required")
return
}
if err := validatePassword(req.Password); err != "" {
jsonErr(w, 400, string(err))
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
jsonErr(w, 500, "internal error")
return
}
now := time.Now().UTC().Format(time.RFC3339)
id := make([]byte, 12)
rand.Read(id)
userID := hex.EncodeToString(id)
_, err = s.db.Exec(
"INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 1, ?)",
userID, req.Username, strings.ToLower(req.Email), string(hash), now,
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
jsonErr(w, 409, "username or email already taken")
} else {
jsonInternalError(w, err)
}
return
}
jsonOK(w, map[string]string{"status": "ok", "user_id": userID})
}
var _ = time.Now
var _ = rand.Read
var _ = hex.EncodeToString
+573
View File
@@ -0,0 +1,573 @@
package server
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte("Verstak Sync Server\n"))
return
}
jsonErr(w, 404, "not found")
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
jsonOK(w, map[string]interface{}{
"status": "ok",
"version": "verstak-server/v1",
"time": time.Now().UTC().Format(time.RFC3339),
})
}
func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
ip := r.RemoteAddr
if idx := strings.LastIndex(ip, ":"); idx >= 0 {
ip = ip[:idx]
}
if !s.pairLimit.allow(ip) {
s.auditLog("rate_limit_exceeded", "", "", ip, "pair rate limit exceeded")
jsonErr(w, 429, "too many attempts")
return
}
var req struct {
Login string `json:"login"`
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")
return
}
if req.Login == "" || req.Password == "" {
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"
}
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=?",
req.Login, strings.ToLower(req.Login)).Scan(&userID, &hash, &confirmed, &blocked)
if err != nil {
s.auditLog("device_auth_failed", "", "", ip, "pair: user not found: "+req.Login)
jsonErr(w, 401, "invalid credentials")
return
}
if blocked != 0 {
s.auditLog("device_auth_failed", userID, "", ip, "pair: user blocked")
jsonErr(w, 403, "account blocked")
return
}
if confirmed == 0 {
s.auditLog("device_auth_failed", userID, "", ip, "pair: email not confirmed")
jsonErr(w, 403, "email not confirmed")
return
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
s.auditLog("device_auth_failed", userID, "", ip, "pair: wrong password")
jsonErr(w, 401, "invalid credentials")
return
}
devID := make([]byte, 12)
rand.Read(devID)
deviceID := "dev_" + hex.EncodeToString(devID)
token, prefix, suffix := genDeviceToken()
tokenHash := sha256Hex(token)
now := time.Now().UTC().Format(time.RFC3339)
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, vault_id, client_version, last_ip, last_seen, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
deviceID, req.DeviceName, hex.EncodeToString(apiKey), tokenHash, prefix, suffix,
userID, req.VaultID, req.ClientVersion, ip, now, now)
if err != nil {
jsonInternalError(w, err)
return
}
s.db.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID)
s.db.Exec("UPDATE server_users SET last_seen=? WHERE id=?", now, userID)
s.pairLimit.reset(ip)
s.auditLog("device_paired", userID, deviceID, ip, "device paired: "+req.DeviceName)
jsonOK(w, map[string]interface{}{
"user_id": userID,
"device_id": deviceID,
"device_token": token,
"server_time": now,
"initial_sync_cursor": 0,
})
}
func (s *Server) handleAuthTest(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "bad json")
return
}
if req.Username == "" || req.Password == "" {
jsonErr(w, 400, "username and password required")
return
}
var hash string
var confirmed, blocked int
err := s.db.QueryRow("SELECT password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
req.Username, strings.ToLower(req.Username)).Scan(&hash, &confirmed, &blocked)
if err != nil {
jsonErr(w, 401, "invalid credentials")
return
}
if blocked != 0 {
jsonErr(w, 403, "account blocked")
return
}
if confirmed == 0 {
jsonErr(w, 403, "email not confirmed")
return
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
jsonErr(w, 401, "invalid credentials")
return
}
jsonOK(w, map[string]string{"status": "ok"})
}
func (s *Server) handleClientRevoke(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if tok == "" {
jsonErr(w, 401, "token required")
return
}
hash := sha256Hex(tok)
var deviceID, userID string
err := s.db.QueryRow("SELECT id, user_id FROM server_devices WHERE token_hash=?", hash).Scan(&deviceID, &userID)
if err != nil {
jsonErr(w, 401, "invalid token")
return
}
now := time.Now().UTC().Format(time.RFC3339)
s.db.Exec("UPDATE server_devices SET revoked_at=? WHERE id=?", now, deviceID)
s.auditLog("device_revoked", userID, deviceID, r.RemoteAddr, "device revoked by user")
jsonOK(w, map[string]string{"status": "revoked"})
}
func (s *Server) handleClientRevokeDevice(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if tok == "" {
jsonErr(w, 401, "token required")
return
}
hash := sha256Hex(tok)
var curUserID string
err := s.db.QueryRow("SELECT user_id FROM server_devices WHERE token_hash=?", hash).Scan(&curUserID)
if err != nil || curUserID == "" {
jsonErr(w, 401, "invalid token")
return
}
var req struct {
DeviceID string `json:"device_id"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
return
}
if req.DeviceID == "" || req.Password == "" {
jsonErr(w, 400, "device_id and password required")
return
}
var pwHash string
err = s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", curUserID).Scan(&pwHash)
if err != nil {
jsonErr(w, 403, "access denied")
return
}
if bcrypt.CompareHashAndPassword([]byte(pwHash), []byte(req.Password)) != nil {
jsonErr(w, 403, "wrong password")
return
}
var devUserID string
err = s.db.QueryRow("SELECT user_id FROM server_devices WHERE id=?", req.DeviceID).Scan(&devUserID)
if err != nil {
jsonErr(w, 404, "device not found")
return
}
if devUserID != curUserID {
jsonErr(w, 403, "device does not belong to you")
return
}
now := time.Now().UTC().Format(time.RFC3339)
s.db.Exec("UPDATE server_devices SET revoked_at=? WHERE id=?", now, req.DeviceID)
s.auditLog("device_revoked", curUserID, req.DeviceID, r.RemoteAddr, "device revoked via API")
jsonOK(w, map[string]string{"status": "revoked"})
}
func (s *Server) handleClientMe(w http.ResponseWriter, r *http.Request) {
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if tok == "" {
jsonErr(w, 401, "token required")
return
}
hash := sha256Hex(tok)
var deviceID, userID, name, clientVer, lastSeen, revokedAt, createdAt string
err := s.db.QueryRow(`SELECT d.id, d.user_id, d.name, COALESCE(d.client_version,''), COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at
FROM server_devices d WHERE d.token_hash=?`, hash).
Scan(&deviceID, &userID, &name, &clientVer, &lastSeen, &revokedAt, &createdAt)
if err != nil {
jsonErr(w, 401, "invalid token")
return
}
var username string
s.db.QueryRow("SELECT username FROM server_users WHERE id=?", userID).Scan(&username)
jsonOK(w, map[string]interface{}{
"device_id": deviceID,
"user_id": userID,
"username": username,
"device_name": name,
"client_version": clientVer,
"last_seen": lastSeen,
"revoked_at": revokedAt,
"created_at": createdAt,
})
}
func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
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")
return
}
if req.Name == "" {
jsonErr(w, 400, "name required")
return
}
if req.Username == "" || req.Password == "" {
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=?",
req.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed, &blocked)
if err != nil {
jsonErr(w, 401, "invalid credentials")
return
}
if blocked != 0 {
jsonErr(w, 403, "account blocked")
return
}
if confirmed == 0 {
jsonErr(w, 403, "email not confirmed")
return
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
jsonErr(w, 401, "invalid credentials")
return
}
b := make([]byte, 20)
rand.Read(b)
apiKey := hex.EncodeToString(b)
deviceID := apiKey[:12]
now := time.Now().UTC().Format(time.RFC3339)
_, err = s.db.Exec(
"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 {
jsonInternalError(w, err)
return
}
s.db.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID)
jsonOK(w, map[string]interface{}{
"device_id": deviceID,
"api_key": apiKey,
})
}
func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
scope, ok := s.requireSyncScope(w, r)
if !ok {
return
}
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
DeviceID string `json:"device_id"`
IdempotencyKey string `json:"idempotency_key"`
Ops []struct {
OpID string `json:"op_id"`
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
OpType string `json:"op_type"`
PayloadJSON string `json:"payload_json"`
ClientSequence int `json:"client_sequence"`
LastSeenServerSeq int `json:"last_seen_server_seq"`
CreatedAt string `json:"created_at"`
} `json:"ops"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
return
}
if req.IdempotencyKey != "" {
var cachedJSON string
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))
return
}
}
now := time.Now().UTC().Format(time.RFC3339)
var accepted []string
var conflicts []map[string]interface{}
for _, op := range req.Ops {
if op.OpID == "" || op.EntityType == "" || op.EntityID == "" || op.OpType == "" {
continue
}
if op.LastSeenServerSeq > 0 {
conflictRows, err := s.db.Query(`
SELECT op_id, device_id, op_type, server_sequence FROM server_ops
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`, scope.UserID, scope.VaultID, op.EntityType, op.EntityID, scope.DeviceID, op.LastSeenServerSeq)
if err == nil {
for conflictRows.Next() {
var cOpID, cDevID, cOpType string
var cSeq int
conflictRows.Scan(&cOpID, &cDevID, &cOpType, &cSeq)
conflicts = append(conflicts, map[string]interface{}{
"op_id": cOpID,
"device_id": cDevID,
"op_type": cOpType,
"server_sequence": cSeq,
"entity_type": op.EntityType,
"entity_id": op.EntityID,
})
}
conflictRows.Close()
}
}
res, err := s.db.Exec(
`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 {
continue
}
n, _ := res.RowsAffected()
if n == 0 {
continue
}
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
(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)
}
resp := map[string]interface{}{
"accepted": accepted,
"count": len(accepted),
"conflicts": conflicts,
}
if req.IdempotencyKey != "" {
if respJSON, err := json.Marshal(resp); err == nil {
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) {
scope, ok := s.requireSyncScope(w, r)
if !ok {
return
}
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
SinceSequence int `json:"since_sequence"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
return
}
var serverSeq int
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 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 {
jsonInternalError(w, err)
return
}
defer rows.Close()
type opDTO struct {
OpID string `json:"op_id"`
ServerSequence int `json:"server_sequence"`
DeviceID string `json:"device_id"`
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
OpType string `json:"op_type"`
PayloadJSON string `json:"payload_json"`
CreatedAt string `json:"created_at"`
}
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 {
continue
}
ops = append(ops, o)
}
jsonOK(w, map[string]interface{}{
"server_sequence": serverSeq,
"ops": ops,
})
}
func (s *Server) handleBlobs(w http.ResponseWriter, r *http.Request) {
_, _, ok := s.requireAuth(w, r)
if !ok {
return
}
switch r.Method {
case "POST":
if err := r.ParseMultipartForm(200 << 20); err != nil {
jsonErr(w, 400, "invalid multipart request")
return
}
file, header, err := r.FormFile("file")
if err != nil {
jsonErr(w, 400, "file field required")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
jsonErr(w, 500, "read error")
return
}
hash := sha256Hex(string(data))
blobDir := filepath.Join(s.blobsDir, hash[:2], hash[2:4])
if err := os.MkdirAll(blobDir, 0750); err != nil {
jsonErr(w, 500, "mkdir error")
return
}
blobPath := filepath.Join(blobDir, hash)
if err := os.WriteFile(blobPath, data, 0640); err != nil {
jsonErr(w, 500, "write error")
return
}
_ = header
now := time.Now().UTC().Format(time.RFC3339)
s.db.Exec("INSERT OR IGNORE INTO server_blobs (sha256, size, created_at) VALUES (?, ?, ?)",
hash, len(data), now)
jsonOK(w, map[string]interface{}{
"sha256": hash,
"size": len(data),
})
case "GET":
shaHex := strings.TrimPrefix(r.URL.Path, "/api/v1/blobs/")
if len(shaHex) != 64 {
jsonErr(w, 400, "invalid SHA-256")
return
}
blobPath := filepath.Join(s.blobsDir, shaHex[:2], shaHex[2:4], shaHex)
if _, err := os.Stat(blobPath); os.IsNotExist(err) {
jsonErr(w, 404, "blob not found")
return
}
data, err := os.ReadFile(blobPath)
if err != nil {
jsonErr(w, 500, "read error")
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment; filename=\""+shaHex+"\"")
w.Write(data)
default:
jsonErr(w, 405, "method not allowed")
}
}
+210
View File
@@ -0,0 +1,210 @@
package server
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"log"
"net/http"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
return
}
if req.Username == "" || req.Email == "" || req.Password == "" {
jsonErr(w, 400, "username, email and password required")
return
}
if err := validatePassword(req.Password); err != "" {
jsonErr(w, 400, err)
return
}
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
jsonErr(w, 400, "invalid email")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
jsonErr(w, 500, "internal error")
return
}
now := time.Now().UTC().Format(time.RFC3339)
id := make([]byte, 12)
rand.Read(id)
userID := hex.EncodeToString(id)
_, err = s.db.Exec(
"INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 0, ?)",
userID, req.Username, strings.ToLower(req.Email), string(hash), now,
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
jsonErr(w, 409, "username or email already taken")
return
}
jsonInternalError(w, err)
return
}
tok := make([]byte, 24)
rand.Read(tok)
tokenStr := hex.EncodeToString(tok)
exp := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'confirm', ?, ?)",
tokenStr, userID, exp, now)
log.Printf("register: confirmation token=%s for user %s", tokenStr, req.Username)
jsonOK(w, map[string]string{"status": "confirmation_sent"})
}
func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
jsonErr(w, 405, "GET required")
return
}
tokenStr := r.URL.Query().Get("token")
if tokenStr == "" {
jsonErr(w, 400, "token required")
return
}
var userID, expiresAt string
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='confirm'",
tokenStr).Scan(&userID, &expiresAt)
if err != nil {
jsonErr(w, 400, "invalid or expired token")
return
}
exp, err := time.Parse(time.RFC3339, expiresAt)
if err != nil || time.Now().After(exp) {
jsonErr(w, 400, "token expired")
return
}
s.db.Exec("UPDATE server_users SET confirmed=1 WHERE id=?", userID)
log.Printf("confirm: user %s confirmed email", userID)
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", tokenStr)
jsonOK(w, map[string]string{"status": "confirmed"})
}
func (s *Server) handleUserLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
return
}
if req.Username == "" || req.Password == "" {
jsonErr(w, 400, "username and password required")
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=?",
req.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed, &blocked)
if err != nil {
jsonErr(w, 401, "invalid credentials")
return
}
if blocked != 0 {
jsonErr(w, 403, "account blocked")
return
}
if confirmed == 0 {
jsonErr(w, 403, "email not confirmed")
return
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
jsonErr(w, 401, "invalid credentials")
return
}
s.db.Exec("UPDATE server_users SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), userID)
tok := s.userTokens.Create(userID)
jsonOK(w, map[string]string{"token": tok, "user_id": userID})
}
func (s *Server) handleForgot(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
return
}
if req.Email == "" {
jsonErr(w, 400, "email required")
return
}
var userID string
err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", strings.ToLower(req.Email)).Scan(&userID)
if err != nil {
jsonOK(w, map[string]string{"status": "if email exists, reset link sent"})
return
}
tok := make([]byte, 24)
rand.Read(tok)
tokenStr := hex.EncodeToString(tok)
exp := time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339)
now := time.Now().UTC().Format(time.RFC3339)
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'reset', ?, ?)",
tokenStr, userID, exp, now)
log.Printf("forgot: reset token=%s for user %s", tokenStr, userID)
jsonOK(w, map[string]string{"status": "if email exists, reset link sent"})
}
func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
var req struct {
Token string `json:"token"`
NewPassword string `json:"new_password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
return
}
if req.Token == "" || req.NewPassword == "" {
jsonErr(w, 400, "token and new_password required")
return
}
if err := validatePassword(req.NewPassword); err != "" {
jsonErr(w, 400, err)
return
}
_, err := s.resetPasswordWithToken(req.Token, req.NewPassword)
if err == errResetTokenInvalid {
jsonErr(w, 400, "invalid or expired token")
return
}
if err == errResetTokenExpired {
jsonErr(w, 400, "token expired")
return
}
if err != nil {
jsonErr(w, 500, "internal error")
return
}
jsonOK(w, map[string]string{"status": "password reset"})
}
+376
View File
@@ -0,0 +1,376 @@
package server
import (
"crypto/rand"
"encoding/hex"
"fmt"
"html"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
func (s *Server) requireUserWeb(w http.ResponseWriter, r *http.Request) (string, bool) {
cookie, err := r.Cookie("user_session")
if err != nil {
http.Redirect(w, r, "/login", http.StatusFound)
return "", false
}
userID, ok := s.userTokens.Check(cookie.Value)
if !ok {
http.Redirect(w, r, "/login", http.StatusFound)
return "", false
}
return userID, true
}
func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) {
locale := s.locale()
switch r.Method {
case "GET":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(userRegisterHTML(locale)))
case "POST":
if err := r.ParseForm(); err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(400)
w.Write([]byte(errorPageHTML(locale, "400 Bad request", "400 Bad request", "/register")))
return
}
username := r.FormValue("username")
email := r.FormValue("email")
password := r.FormValue("password")
if username == "" || email == "" || password == "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(400)
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.allFieldsRequired"), "/register")))
return
}
if err := validatePassword(password); err != "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(400)
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), string(err), "/register")))
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(500)
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), "internal error", "/register")))
return
}
now := time.Now().UTC().Format(time.RFC3339)
id := make([]byte, 12)
rand.Read(id)
userID := hex.EncodeToString(id)
_, err = s.db.Exec(
"INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 0, ?)",
userID, username, strings.ToLower(email), string(hash), now,
)
if err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if strings.Contains(err.Error(), "UNIQUE") {
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"), t(locale, "server.registrationFailed"), "/register")))
}
return
}
tok := make([]byte, 24)
rand.Read(tok)
tokenStr := hex.EncodeToString(tok)
exp := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'confirm', ?, ?)",
tokenStr, userID, exp, now)
host := s.smtpGet("smtp_host")
if host != "" {
srvURL := s.smtpGet("server_url")
var confirmURL string
if srvURL != "" {
confirmURL = fmt.Sprintf("%s/api/v1/auth/confirm?token=%s", srvURL, tokenStr)
} else {
confirmURL = fmt.Sprintf("http://%s/api/v1/auth/confirm?token=%s", r.Host, tokenStr)
}
body := fmt.Sprintf(t(locale, "server.emailConfirmBody"), confirmURL)
if err := s.smtpSend(email, t(locale, "server.emailConfirmSubject"), body); err != nil {
log.Printf("register web: failed to send confirm email: %v", err)
}
} else {
log.Printf("register web: SMTP not configured, confirmation token=%s for user %s", tokenStr, username)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
regMsg := registrationOKHTML(locale)
if host == "" {
regMsg = registrationAutoHTML(locale)
}
w.Write([]byte(regMsg))
default:
jsonErr(w, 405, "method not allowed")
}
}
func (s *Server) handleUserWebForgot(w http.ResponseWriter, r *http.Request) {
locale := s.locale()
switch r.Method {
case "GET":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(forgotPasswordHTML(locale)))
case "POST":
if err := r.ParseForm(); err != nil {
jsonErr(w, 400, "bad form")
return
}
email := strings.ToLower(r.FormValue("email"))
if email == "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.needEmail"), "/forgot")))
return
}
var userID string
err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", email).Scan(&userID)
if err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(forgotSentHTML(locale)))
return
}
tok := make([]byte, 24)
rand.Read(tok)
tokenStr := hex.EncodeToString(tok)
exp := time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339)
now := time.Now().UTC().Format(time.RFC3339)
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'reset', ?, ?)",
tokenStr, userID, exp, now)
host := s.smtpGet("smtp_host")
if host != "" {
srvURL := s.smtpGet("server_url")
resetURL := fmt.Sprintf("/reset?token=%s", tokenStr)
if srvURL != "" {
resetURL = fmt.Sprintf("%s/reset?token=%s", srvURL, tokenStr)
}
body := fmt.Sprintf(t(locale, "server.emailResetBody"), resetURL)
if err := s.smtpSend(email, t(locale, "server.emailResetSubject"), body); err != nil {
log.Printf("forgot web: failed to send reset email: %v", err)
}
} else {
log.Printf("forgot web: SMTP not configured, reset token=%s for email %s", tokenStr, email)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(forgotSentHTML(locale)))
default:
jsonErr(w, 405, "method not allowed")
}
}
func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) {
locale := s.locale()
switch r.Method {
case "GET":
token := r.URL.Query().Get("token")
if token == "" {
http.Redirect(w, r, "/forgot", http.StatusFound)
return
}
var userID, expiresAt string
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='reset'",
token).Scan(&userID, &expiresAt)
if err != nil {
http.Redirect(w, r, "/forgot", http.StatusFound)
return
}
exp, err := time.Parse(time.RFC3339, expiresAt)
if err != nil || time.Now().After(exp) {
http.Redirect(w, r, "/forgot", http.StatusFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
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")
return
}
token := r.FormValue("token")
newPass := r.FormValue("password")
confirm := r.FormValue("confirm")
if token == "" || newPass == "" || confirm == "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.allFieldsRequired"), "/forgot")))
return
}
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="+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="+url.QueryEscape(token))))
return
}
userID, err := s.resetPasswordWithToken(token, newPass)
if err == errResetTokenInvalid || err == errResetTokenExpired {
http.Redirect(w, r, "/forgot", http.StatusFound)
return
}
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)))
default:
jsonErr(w, 405, "method not allowed")
}
}
func (s *Server) handleUserWebLogin(w http.ResponseWriter, r *http.Request) {
locale := s.locale()
switch r.Method {
case "GET":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(userLoginHTML(locale)))
case "POST":
if err := r.ParseForm(); err != nil {
jsonErr(w, 400, "bad form")
return
}
username := r.FormValue("username")
password := r.FormValue("password")
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=?",
username, strings.ToLower(username)).Scan(&userID, &hash, &confirmed, &blocked)
if err != nil || blocked != 0 || confirmed == 0 || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(401)
w.Write([]byte(errorPageHTML(locale, "401 Unauthorized", "401 Unauthorized", "/login")))
return
}
tok := s.userTokens.Create(userID)
http.SetCookie(w, &http.Cookie{
Name: "user_session", Value: tok, Path: "/",
HttpOnly: true, SameSite: http.SameSiteLaxMode,
MaxAge: 86400,
})
http.Redirect(w, r, "/dashboard", http.StatusFound)
default:
jsonErr(w, 405, "method not allowed")
}
}
func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
locale := s.locale()
userID, ok := s.requireUserWeb(w, r)
if !ok {
return
}
var username string
s.db.QueryRow("SELECT username FROM server_users WHERE id=?", userID).Scan(&username)
type dev struct {
ID, Name, LastSeen, CreatedAt, ClientVer, RevokedAt string
}
var devices []dev
rows, err := s.db.Query(`
SELECT d.id, d.name, COALESCE(d.last_seen,''), d.created_at,
COALESCE(d.client_version,''), COALESCE(d.revoked_at,'')
FROM server_devices d
JOIN server_user_devices ud ON ud.device_id = d.id
WHERE ud.user_id = ?
ORDER BY d.created_at DESC`, userID)
if err == nil {
defer rows.Close()
for rows.Next() {
var d dev
rows.Scan(&d.ID, &d.Name, &d.LastSeen, &d.CreatedAt, &d.ClientVer, &d.RevokedAt)
devices = append(devices, d)
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
deviceRows := ""
if len(devices) == 0 {
deviceRows = "<tr><td colspan='5' style='color:#666;text-align:center;padding:24px'>" + t(locale, "userDashboard.noDevices") + "</td></tr>"
} else {
for _, d := range devices {
ls := d.LastSeen
if ls == "" {
ls = "—"
}
created := d.CreatedAt
if len(created) > 10 {
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>`, html.EscapeString(strconv.Quote(d.ID)), t(locale, "userDashboard.revoke"))
if d.RevokedAt != "" {
status = "<span style='color:#ff6b6b'>" + t(locale, "userDashboard.revoked") + "</span>"
revokeBtn = ""
}
deviceRows += fmt.Sprintf(`<tr>
<td>%s</td>
<td>%s</td>
<td>%s</td>
<td>%s</td>
<td>%s %s</td>
</tr>`, html.EscapeString(d.Name), status, html.EscapeString(created), html.EscapeString(ls), html.EscapeString(d.ClientVer), revokeBtn)
}
}
w.Write([]byte(userDashboardHTML(locale, html.EscapeString(username), deviceRows)))
}
func (s *Server) handleUserWebLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "user_session", Value: "", Path: "/",
HttpOnly: true, MaxAge: -1,
})
http.Redirect(w, r, "/login", http.StatusFound)
}
func (s *Server) handleUserDevices(w http.ResponseWriter, r *http.Request) {
userID, ok := s.requireUserWeb(w, r)
if !ok {
return
}
rows, err := s.db.Query(`
SELECT d.id, d.name, COALESCE(d.client_version,''), COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at
FROM server_devices d
JOIN server_user_devices ud ON ud.device_id = d.id
WHERE ud.user_id = ?
ORDER BY d.created_at DESC`, userID)
if err != nil {
jsonInternalError(w, err)
return
}
defer rows.Close()
type devDTO struct {
ID string `json:"id"`
Name string `json:"name"`
ClientVersion string `json:"client_version"`
LastSeen string `json:"last_seen"`
RevokedAt string `json:"revoked_at"`
CreatedAt string `json:"created_at"`
}
var devices []devDTO
for rows.Next() {
var d devDTO
rows.Scan(&d.ID, &d.Name, &d.ClientVersion, &d.LastSeen, &d.RevokedAt, &d.CreatedAt)
devices = append(devices, d)
}
jsonOK(w, devices)
}
+30
View File
@@ -0,0 +1,30 @@
package server
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"log"
"net/http"
)
func jsonOK(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func jsonErr(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
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[:])
}
+278
View File
@@ -0,0 +1,278 @@
package server
func t(locale, key string) string {
if translations, ok := _translations[locale]; ok {
if v, ok := translations[key]; ok {
return v
}
}
if translations, ok := _translations["ru"]; ok {
if v, ok := translations[key]; ok {
return v
}
}
return key
}
var _translations = map[string]map[string]string{
"ru": {
"server.registerTitle": "Регистрация",
"server.register": "Регистрация",
"server.username": "Имя пользователя",
"server.email": "Email",
"server.password": "Пароль",
"server.registerBtn": "Зарегистрироваться",
"server.alreadyHaveAccount": "Уже есть аккаунт?",
"server.loginBtn": "Войти",
"server.loginTitle": "Вход",
"server.usernameOrEmail": "Имя пользователя или email",
"server.forgotPassword": "Забыли пароль?",
"server.adminLink": "Админ",
"server.dashboard": "Панель управления",
"server.allFieldsRequired": "Все поля обязательны",
"server.back": "Назад",
"server.emailConfirmBody": "Подтвердите регистрацию: %s",
"server.emailConfirmSubject": "Verstak — подтверждение email",
"server.registrationSuccess": "Регистрация успешна",
"server.registrationEmailSent": "Письмо с ссылкой подтверждения отправлено.",
"server.registrationCheckEmail": "Проверьте почту и подтвердите аккаунт.",
"server.registrationAutoMessage": "Email не подтверждается. Токен подтверждения выведен в лог сервера.",
"server.resetPasswordTitle": "Сброс пароля",
"server.resetPassword": "Сброс пароля",
"server.resetInstruction": "Введите email для получения ссылки сброса.",
"server.sendLink": "Отправить ссылку",
"server.backToLogin": "Вернуться к входу",
"server.emailSentTitle": "Письмо отправлено",
"server.emailSent": "Письмо отправлено",
"server.emailSentMessage": "Если аккаунт существует, ссылка для сброса пароля отправлена на почту.",
"server.goHome": "На главную",
"server.newPasswordTitle": "Новый пароль",
"server.newPassword": "Новый пароль",
"server.passwordConfirm": "Подтвердите пароль",
"server.save": "Сохранить",
"server.passwordChanged": "Пароль изменён",
"server.passwordChangedMessage": "Пароль успешно изменён. Теперь можно войти.",
"server.emailConfirmed": "Email подтверждён",
"server.emailConfirmedMessage": "Аккаунт активирован. Теперь можно войти.",
"server.needEmail": "Введите email",
"server.passwordsDoNotMatch": "Пароли не совпадают",
"server.newPasswordResult": "Новый пароль для %s:\n",
"server.logout": "Выйти",
"server.error": "Ошибка",
"server.internalError": "Что-то пошло не так. Повторите попытку.",
"server.registrationFailed": "Не удалось зарегистрировать аккаунт. Повторите попытку.",
"userDashboard.devices": "Устройства",
"userDashboard.device": "Устройство",
"userDashboard.status": "Статус",
"userDashboard.connected": "Подключено",
"userDashboard.lastSeen": "Последний раз",
"userDashboard.version": "Версия",
"userDashboard.connectNew": "Подключить новое устройство",
"userDashboard.connectNewHint": "Установите Верстак на новом устройстве и выполните регистрацию.",
"userDashboard.revokeConfirm": "Отозвать устройство?",
"userDashboard.revokePrompt": "Введите пароль для подтверждения:",
"userDashboard.noDevices": "Нет устройств",
"userDashboard.active": "Активно",
"userDashboard.revoked": "Отозвано",
"userDashboard.revoke": "Отозвать",
"admin.login": "Вход администратора",
"admin.username": "Имя пользователя",
"admin.password": "Пароль",
"admin.loginBtn": "Войти",
"admin.dashboard": "Панель управления",
"admin.deviceCount": "Устройств",
"admin.opsCount": "Операций",
"admin.devices": "Устройства",
"admin.noDevices": "Нет устройств",
"admin.device": "Устройство",
"admin.user": "Пользователь",
"admin.version": "Версия",
"admin.status": "Статус",
"admin.lastSeen": "Последний раз",
"admin.active": "Активно",
"admin.revoked": "Отозвано",
"admin.revoke": "Отозвать",
"admin.smtp": "SMTP",
"admin.users": "Пользователи",
"admin.usersHeading": "Пользователи",
"admin.healthCheck": "Проверка здоровья",
"admin.smtpServer": "SMTP сервер",
"admin.smtpPort": "SMTP порт",
"admin.smtpType": "Тип шифрования",
"admin.smtpNoEncryption": "Без шифрования",
"admin.smtpUsername": "Имя пользователя SMTP",
"admin.smtpPassword": "Пароль SMTP",
"admin.smtpFrom": "Отправитель",
"admin.smtpServerURL": "URL сервера",
"admin.smtpSave": "Сохранить",
"admin.smtpTest": "Тест",
"admin.smtpTitle": "Настройки SMTP",
"admin.smtpTesting": "Проверка...",
"admin.smtpPassed": "✓ Тест пройден",
"admin.smtpRequired": "Укажите SMTP-сервер, порт и отправителя.",
"admin.smtpTestFailed": "Не удалось проверить настройки SMTP. Повторите попытку.",
"admin.revokeConfirm": "Вы уверены?",
"common.loading": "Загрузка...",
"common.ok": "OK",
"common.error": "Ошибка",
"admin.filterPlaceholder": "Поиск...",
"admin.email": "Email",
"admin.actions": "Действия",
"admin.confirmTitle": "Подтверждение",
"admin.modalCancel": "Отмена",
"admin.modalConfirm": "Подтвердить",
"admin.editUser": "Редактировать пользователя",
"admin.editBtn": "Сохранить",
"admin.resultTitle": "Результат",
"admin.confirmed": "Подтверждён",
"admin.unconfirmed": "Не подтверждён",
"admin.blocked": "Заблокирован",
"admin.unblock": "Разблокировать",
"admin.block": "Заблокировать",
"admin.resetPassword": "Сбросить пароль",
"admin.noUsers": "Нет пользователей",
"admin.resetPasswordConfirm": "Сбросить пароль",
"admin.resetPasswordMessage": "Новый пароль: ",
"admin.resetBtn": "Сбросить",
"admin.deleteUser": "Удалить",
"admin.deleteUserMessage": "Удалить пользователя %s?",
"admin.deleteBtn": "Удалить",
"admin.unblockUserTitle": "Разблокировать",
"admin.blockUserTitle": "Заблокировать",
"admin.unblockUserMessage": "Разблокировать пользователя?",
"admin.blockUserMessage": "Заблокировать пользователя?",
"admin.createUser": "Создать пользователя",
"admin.createUserBtn": "Создать",
"admin.createUserFailed": "Не удалось создать пользователя. Повторите попытку.",
},
"en": {
"server.registerTitle": "Registration",
"server.register": "Register",
"server.username": "Username",
"server.email": "Email",
"server.password": "Password",
"server.registerBtn": "Register",
"server.alreadyHaveAccount": "Already have an account?",
"server.loginBtn": "Login",
"server.loginTitle": "Login",
"server.usernameOrEmail": "Username or email",
"server.forgotPassword": "Forgot password?",
"server.adminLink": "Admin",
"server.dashboard": "Dashboard",
"server.allFieldsRequired": "All fields are required",
"server.back": "Back",
"server.emailConfirmBody": "Confirm registration: %s",
"server.emailConfirmSubject": "Verstak — email confirmation",
"server.registrationSuccess": "Registration successful",
"server.registrationEmailSent": "Confirmation link has been sent.",
"server.registrationCheckEmail": "Check your email and confirm your account.",
"server.registrationAutoMessage": "Email is not confirmed. Confirmation token logged to server.",
"server.resetPasswordTitle": "Reset Password",
"server.resetPassword": "Reset Password",
"server.resetInstruction": "Enter your email to receive a reset link.",
"server.sendLink": "Send link",
"server.backToLogin": "Back to login",
"server.emailSentTitle": "Email sent",
"server.emailSent": "Email sent",
"server.emailSentMessage": "If the account exists, a reset link has been sent.",
"server.goHome": "Go home",
"server.newPasswordTitle": "New Password",
"server.newPassword": "New Password",
"server.passwordConfirm": "Confirm Password",
"server.save": "Save",
"server.passwordChanged": "Password changed",
"server.passwordChangedMessage": "Password has been changed. You can now login.",
"server.emailConfirmed": "Email confirmed",
"server.emailConfirmedMessage": "Account activated. You can now login.",
"server.needEmail": "Enter email",
"server.passwordsDoNotMatch": "Passwords do not match",
"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",
"userDashboard.connected": "Connected",
"userDashboard.lastSeen": "Last seen",
"userDashboard.version": "Version",
"userDashboard.connectNew": "Connect new device",
"userDashboard.connectNewHint": "Install Verstak on a new device and register it.",
"userDashboard.revokeConfirm": "Revoke device?",
"userDashboard.revokePrompt": "Enter password to confirm:",
"userDashboard.noDevices": "No devices",
"userDashboard.active": "Active",
"userDashboard.revoked": "Revoked",
"userDashboard.revoke": "Revoke",
"admin.login": "Admin Login",
"admin.username": "Username",
"admin.password": "Password",
"admin.loginBtn": "Login",
"admin.dashboard": "Admin Dashboard",
"admin.deviceCount": "Devices",
"admin.opsCount": "Operations",
"admin.devices": "Devices",
"admin.noDevices": "No devices",
"admin.device": "Device",
"admin.user": "User",
"admin.version": "Version",
"admin.status": "Status",
"admin.lastSeen": "Last seen",
"admin.active": "Active",
"admin.revoked": "Revoked",
"admin.revoke": "Revoke",
"admin.smtp": "SMTP",
"admin.users": "Users",
"admin.usersHeading": "Users",
"admin.healthCheck": "Health Check",
"admin.smtpServer": "SMTP Server",
"admin.smtpPort": "SMTP Port",
"admin.smtpType": "Encryption",
"admin.smtpNoEncryption": "None",
"admin.smtpUsername": "SMTP Username",
"admin.smtpPassword": "SMTP Password",
"admin.smtpFrom": "From",
"admin.smtpServerURL": "Server URL",
"admin.smtpSave": "Save",
"admin.smtpTest": "Test",
"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",
"common.error": "Error",
"admin.filterPlaceholder": "Search...",
"admin.email": "Email",
"admin.actions": "Actions",
"admin.confirmTitle": "Confirm",
"admin.modalCancel": "Cancel",
"admin.modalConfirm": "Confirm",
"admin.editUser": "Edit User",
"admin.editBtn": "Save",
"admin.resultTitle": "Result",
"admin.confirmed": "Confirmed",
"admin.unconfirmed": "Unconfirmed",
"admin.blocked": "Blocked",
"admin.unblock": "Unblock",
"admin.block": "Block",
"admin.resetPassword": "Reset Password",
"admin.noUsers": "No users",
"admin.resetPasswordConfirm": "Reset Password",
"admin.resetPasswordMessage": "New password: ",
"admin.resetBtn": "Reset",
"admin.deleteUser": "Delete",
"admin.deleteUserMessage": "Delete user %s?",
"admin.deleteBtn": "Delete",
"admin.unblockUserTitle": "Unblock",
"admin.blockUserTitle": "Block",
"admin.unblockUserMessage": "Unblock user?",
"admin.blockUserMessage": "Block user?",
"admin.createUser": "Create User",
"admin.createUserBtn": "Create",
"admin.createUserFailed": "Could not create the user. Please try again.",
},
}
+112
View File
@@ -0,0 +1,112 @@
package server
import (
"database/sql"
"net/http"
"strings"
"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 == "" {
key = r.URL.Query().Get("api_key")
}
if key == "" {
jsonErr(w, 401, "API key required")
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
}
if revokedAt.Valid && revokedAt.String != "" {
jsonErr(w, 401, "device revoked")
return authenticatedDevice{}, false
}
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=?", device.UserID).Scan(&blocked)
if blocked != 0 {
jsonErr(w, 403, "user blocked")
return authenticatedDevice{}, false
}
}
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 {
cookie, err := r.Cookie("session")
if err != nil || !s.tokens.Check(cookie.Value) {
http.Redirect(w, r, "/admin/login", http.StatusFound)
return false
}
return true
}
type PasswordError string
const (
ErrPasswordTooShort PasswordError = "PASSWORD_TOO_SHORT"
ErrPasswordTooLong PasswordError = "PASSWORD_TOO_LONG"
)
func validatePassword(password string) string {
if len(password) < 8 {
return string(ErrPasswordTooShort)
}
if len(password) > 256 {
return string(ErrPasswordTooLong)
}
return ""
}
+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
}
+41
View File
@@ -0,0 +1,41 @@
package server
func (s *Server) routes() {
s.mux.HandleFunc("/api/v1/health", s.handleHealth)
s.mux.HandleFunc("/api/v1/device/register", s.handleDeviceRegister)
s.mux.HandleFunc("/api/v1/sync/push", s.handleSyncPush)
s.mux.HandleFunc("/api/v1/sync/pull", s.handleSyncPull)
s.mux.HandleFunc("/api/v1/blobs/", s.handleBlobs)
s.mux.HandleFunc("/api/client/pair", s.handleClientPair)
s.mux.HandleFunc("/api/auth/test", s.handleAuthTest)
s.mux.HandleFunc("/api/client/revoke-current", s.handleClientRevoke)
s.mux.HandleFunc("/api/client/me", s.handleClientMe)
s.mux.HandleFunc("/api/client/revoke-device", s.handleClientRevokeDevice)
s.mux.HandleFunc("/api/v1/auth/register", s.handleRegister)
s.mux.HandleFunc("/api/v1/auth/confirm", s.handleConfirm)
s.mux.HandleFunc("/api/v1/auth/login", s.handleUserLogin)
s.mux.HandleFunc("/api/v1/auth/forgot", s.handleForgot)
s.mux.HandleFunc("/api/v1/auth/reset", s.handleReset)
s.mux.HandleFunc("/register", s.handleUserWebRegister)
s.mux.HandleFunc("/login", s.handleUserWebLogin)
s.mux.HandleFunc("/dashboard", s.handleUserDashboard)
s.mux.HandleFunc("/forgot", s.handleUserWebForgot)
s.mux.HandleFunc("/reset", s.handleUserWebReset)
s.mux.HandleFunc("/logout", s.handleUserWebLogout)
s.mux.HandleFunc("/api/v1/user/devices", s.handleUserDevices)
s.mux.HandleFunc("/admin/login", s.handleAdminLogin)
s.mux.HandleFunc("/admin/dashboard", s.handleAdminDashboard)
s.mux.HandleFunc("/admin/users", s.handleAdminUsers)
s.mux.HandleFunc("/admin/create-user", s.handleAdminCreateUser)
s.mux.HandleFunc("/admin/api/users/create", s.handleAdminAPICreateUser)
s.mux.HandleFunc("/admin/devices", s.handleAdminDevices)
s.mux.HandleFunc("/admin/api/stats", s.handleAdminStats)
s.mux.HandleFunc("/admin/api/smtp/test", s.handleAdminSMTPTest)
s.mux.HandleFunc("/admin/api/smtp", s.handleAdminAPISmtp)
s.mux.HandleFunc("/admin/api/devices", s.handleAdminAPIDevices)
s.mux.HandleFunc("/admin/api/keys/", s.handleAdminAPIKeysDelete)
s.mux.HandleFunc("/admin/api/keys", s.handleAdminAPIKeys)
s.mux.HandleFunc("/admin/api/users/", s.handleAdminAPIUserActions)
s.mux.HandleFunc("/admin/api/users", s.handleAdminAPIUsers)
s.mux.HandleFunc("/", s.handleNotFound)
}
+316
View File
@@ -0,0 +1,316 @@
package server
import (
"database/sql"
"fmt"
)
const serverSchema = `
CREATE TABLE IF NOT EXISTS server_users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
confirmed INTEGER NOT NULL DEFAULT 0,
blocked INTEGER NOT NULL DEFAULT 0,
last_seen TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS 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,
vault_id TEXT,
client_version TEXT,
last_ip TEXT,
last_seen TEXT,
revoked_at TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_user_devices (
user_id TEXT NOT NULL,
device_id TEXT NOT NULL,
PRIMARY KEY (user_id, device_id)
);
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,
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 DEFAULT (datetime('now'))
);
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 (user_id, vault_id, entity_type, entity_id)
);
CREATE TABLE IF NOT EXISTS 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)
);
CREATE TABLE IF NOT EXISTS server_email_tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
purpose TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
op_id TEXT NOT NULL,
device_id TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_blobs (
sha256 TEXT PRIMARY KEY,
size INTEGER NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
user_id TEXT,
device_id TEXT,
ip TEXT,
message TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_server_ops_sequence ON server_ops(server_sequence);
CREATE INDEX IF NOT EXISTS idx_server_ops_entity ON server_ops(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_server_ops_idempotency ON server_ops(idempotency_key);
CREATE INDEX IF NOT EXISTS idx_server_devices_api_key ON server_devices(api_key);
CREATE INDEX IF NOT EXISTS idx_server_devices_user ON server_devices(user_id);
CREATE INDEX IF NOT EXISTS idx_server_users_username ON server_users(username);
CREATE INDEX IF NOT EXISTS idx_server_users_email ON server_users(email);
CREATE INDEX IF NOT EXISTS idx_server_audit_log_event ON server_audit_log(event_type);
CREATE INDEX IF NOT EXISTS idx_server_audit_log_created ON server_audit_log(created_at);
CREATE TABLE IF NOT EXISTS server_smtp_config (
key TEXT PRIMARY KEY,
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
}
+179
View File
@@ -0,0 +1,179 @@
package server
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
_ "github.com/mattn/go-sqlite3"
)
type pairRateLimit struct {
mu sync.Mutex
attempts map[string]int
}
func (p *pairRateLimit) allow(ip string) bool {
p.mu.Lock()
defer p.mu.Unlock()
if p.attempts == nil {
p.attempts = make(map[string]int)
}
p.attempts[ip]++
return p.attempts[ip] <= 5
}
func (p *pairRateLimit) reset(ip string) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.attempts, ip)
}
type tokenStore struct {
mu sync.Mutex
tokens map[string]time.Time
}
func newTokenStore() *tokenStore {
return &tokenStore{tokens: make(map[string]time.Time)}
}
func (ts *tokenStore) Create() string {
ts.mu.Lock()
defer ts.mu.Unlock()
b := make([]byte, 16)
rand.Read(b)
tok := hex.EncodeToString(b)
ts.tokens[tok] = time.Now().Add(24 * time.Hour)
return tok
}
func (ts *tokenStore) Check(tok string) bool {
ts.mu.Lock()
defer ts.mu.Unlock()
exp, ok := ts.tokens[tok]
if !ok {
return false
}
if time.Now().After(exp) {
delete(ts.tokens, tok)
return false
}
return true
}
type userTokenStore struct {
mu sync.Mutex
tokens map[string]userTokenEntry
}
type userTokenEntry struct {
UserID string
ExpiresAt time.Time
}
func newUserTokenStore() *userTokenStore {
return &userTokenStore{tokens: make(map[string]userTokenEntry)}
}
func (uts *userTokenStore) Create(userID string) string {
uts.mu.Lock()
defer uts.mu.Unlock()
b := make([]byte, 16)
rand.Read(b)
tok := hex.EncodeToString(b)
uts.tokens[tok] = userTokenEntry{UserID: userID, ExpiresAt: time.Now().Add(24 * time.Hour)}
return tok
}
func (uts *userTokenStore) Check(tok string) (string, bool) {
uts.mu.Lock()
defer uts.mu.Unlock()
entry, ok := uts.tokens[tok]
if !ok {
return "", false
}
if time.Now().After(entry.ExpiresAt) {
delete(uts.tokens, tok)
return "", false
}
return entry.UserID, true
}
type Server struct {
db *sql.DB
cfg *Config
tokens *tokenStore
userTokens *userTokenStore
blobsDir string
mux *http.ServeMux
pairLimit *pairRateLimit
}
func (s *Server) auditLog(eventType, userID, deviceID, ip, msg string) {
s.db.Exec("INSERT INTO server_audit_log (event_type, user_id, device_id, ip, message, created_at) VALUES (?, ?, ?, ?, ?, ?)",
eventType, userID, deviceID, ip, msg, time.Now().UTC().Format(time.RFC3339))
}
func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) {
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?mode=rwc", dbPath))
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
db.SetMaxOpenConns(1)
for _, stmt := range strings.Split(serverSchema, ";") {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
if _, err := db.Exec(stmt); err != nil {
db.Close()
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 {
db.Close()
return nil, err
}
s := &Server{
db: db,
cfg: cfg,
tokens: newTokenStore(),
userTokens: newUserTokenStore(),
blobsDir: blobsDir,
pairLimit: &pairRateLimit{},
}
s.mux = http.NewServeMux()
return s, nil
}
func (s *Server) SetupRoutes() {
s.routes()
}
func (s *Server) locale() string {
return "ru"
}
func (s *Server) Close() error {
return s.db.Close()
}
func (s *Server) ListenAndServe(addr string) error {
return http.ListenAndServe(addr, s.mux)
}
+689
View File
@@ -0,0 +1,689 @@
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) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
dataDir := filepath.Join(dir, "data")
if err := os.MkdirAll(dataDir, 0750); err != nil {
t.Fatal(err)
}
cfg := &Config{Port: 47732}
s, err := NewServer(dbPath, dataDir, cfg)
if err != nil {
t.Fatalf("NewServer: %v", err)
}
defer s.Close()
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
t.Fatal("database file was not created")
}
blobsDir := filepath.Join(dataDir, "blobs")
if _, err := os.Stat(blobsDir); os.IsNotExist(err) {
t.Fatal("blobs directory was not created")
}
}
func TestConfigSetAdmin(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.yml")
cfg := &Config{Port: 47732, path: cfgPath}
if err := cfg.SetAdmin("admin", "secret"); err != nil {
t.Fatalf("SetAdmin: %v", err)
}
if !cfg.CheckAdmin("admin", "secret") {
t.Fatal("CheckAdmin should return true for correct password")
}
if cfg.CheckAdmin("admin", "wrong") {
t.Fatal("CheckAdmin should return false for wrong password")
}
if cfg.CheckAdmin("unknown", "secret") {
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
}
+139
View File
@@ -0,0 +1,139 @@
package server
import (
"crypto/tls"
"fmt"
"log"
"net"
"net/smtp"
"time"
)
func (s *Server) smtpGet(key string) string {
var val string
s.db.QueryRow("SELECT value FROM server_smtp_config WHERE key=?", key).Scan(&val)
return val
}
func (s *Server) smtpSet(key, val string) error {
_, err := s.db.Exec("INSERT OR REPLACE INTO server_smtp_config (key, value) VALUES (?, ?)", key, val)
return err
}
func sel(v, want string) string {
if v == want {
return " selected"
}
return ""
}
func (s *Server) smtpConnect(host, port, user, pass, security string) (*smtp.Client, error) {
addr := net.JoinHostPort(host, port)
switch security {
case "tls":
tlsCfg := &tls.Config{ServerName: host}
conn, err := tls.Dial("tcp", addr, tlsCfg)
if err != nil {
return nil, fmt.Errorf("tls dial: %w", err)
}
cl, err := smtp.NewClient(conn, host)
if err != nil {
conn.Close()
return nil, fmt.Errorf("smtp client: %w", err)
}
return cl, nil
default:
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
if err != nil {
return nil, fmt.Errorf("connect: %w", err)
}
cl, err := smtp.NewClient(conn, host)
if err != nil {
conn.Close()
return nil, fmt.Errorf("smtp client: %w", err)
}
if security != "none" {
if ok, _ := cl.Extension("STARTTLS"); ok {
tlsCfg := &tls.Config{ServerName: host}
if err := cl.StartTLS(tlsCfg); err != nil {
cl.Close()
return nil, fmt.Errorf("starttls: %w", err)
}
}
}
return cl, nil
}
}
func (s *Server) smtpSendMsg(cl *smtp.Client, user, pass, host, from, to string, msg []byte) error {
if user != "" {
auth := smtp.PlainAuth("", user, pass, host)
if err := cl.Auth(auth); err != nil {
return fmt.Errorf("auth: %w", err)
}
}
if err := cl.Mail(from); err != nil {
return fmt.Errorf("mail from: %w", err)
}
if err := cl.Rcpt(to); err != nil {
return fmt.Errorf("rcpt: %w", err)
}
w, err := cl.Data()
if err != nil {
return fmt.Errorf("data: %w", err)
}
if _, err := w.Write(msg); err != nil {
w.Close()
return fmt.Errorf("write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("send: %w", err)
}
return nil
}
func (s *Server) smtpSend(to, subject, body string) error {
host := s.smtpGet("smtp_host")
port := s.smtpGet("smtp_port")
user := s.smtpGet("smtp_user")
pass := s.smtpGet("smtp_pass")
from := s.smtpGet("smtp_from")
security := s.smtpGet("smtp_security")
if host == "" || port == "" || from == "" {
err := fmt.Errorf("SMTP not configured")
log.Printf("smtp: %v (to=%s)", err, to)
return err
}
log.Printf("smtp: sending to %s via %s:%s (security=%s)", to, host, port, security)
msg := []byte("From: " + from + "\r\n" +
"To: " + to + "\r\n" +
"Subject: " + subject + "\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: text/plain; charset=UTF-8\r\n" +
"\r\n" + body + "\r\n")
cl, err := s.smtpConnect(host, port, user, pass, security)
if err != nil {
log.Printf("smtp: connect error: %v", err)
return err
}
defer cl.Close()
if err := s.smtpSendMsg(cl, user, pass, host, from, to, msg); err != nil {
log.Printf("smtp: send error: %v", err)
return err
}
log.Printf("smtp: sent OK to %s", to)
return nil
}
func (s *Server) smtpTest(host, port, user, pass, security, from, to string) error {
if host == "" || port == "" || from == "" {
return fmt.Errorf("SMTP not configured")
}
msg := []byte("From: " + from + "\r\nTo: " + to + "\r\nSubject: Test from Verstak Sync\r\n\r\nThis is a test email from Verstak Sync Server.\r\n")
cl, err := s.smtpConnect(host, port, user, pass, security)
if err != nil {
return err
}
defer cl.Close()
return s.smtpSendMsg(cl, user, pass, host, from, to, msg)
}
+823
View File
@@ -0,0 +1,823 @@
package server
import (
"fmt"
"html"
"strings"
)
func userRegisterHTML(locale string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync — %s</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
h1{font-size:20px;margin:0 0 20px;text-align:center}
p{text-align:center;font-size:12px;color:#666;margin-top:16px}
a{color:#6366f1}
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
button:hover{background:#4f46e5}
.hint{font-size:11px;color:#666;margin-top:-12px;margin-bottom:16px;text-align:center}
</style>
</head><body>
<form method="POST">
<h1>%s</h1>
<label>%s</label>
<input type="text" name="username" autofocus required>
<label>%s</label>
<input type="email" name="email" required>
<label>%s</label>
<input type="password" name="password" required minlength="8" maxlength="256">
<button>%s</button>
<p>%s <a href="/login">%s</a></p>
</form>
</body></html>`,
t(locale, "server.registerTitle"),
t(locale, "server.register"),
t(locale, "server.username"),
t(locale, "server.email"),
t(locale, "server.password"),
t(locale, "server.registerBtn"),
t(locale, "server.alreadyHaveAccount"),
t(locale, "server.loginBtn"),
)
}
func userLoginHTML(locale string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync — %s</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
h1{font-size:20px;margin:0 0 20px;text-align:center}
p{text-align:center;font-size:12px;color:#666;margin-top:16px}
a{color:#6366f1}
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
button:hover{background:#4f46e5}
.links{margin-top:16px;text-align:center;font-size:12px;color:#666;line-height:1.8}
.links a{color:#6366f1;text-decoration:none}
.links a:hover{text-decoration:underline}</style>
</head><body>
<form method="POST">
<h1>Verstak Sync</h1>
<label>%s</label>
<input type="text" name="username" autofocus required>
<label>%s</label>
<input type="password" name="password" required>
<button>%s</button>
<div class="links">
<a href="/forgot">%s</a><br>
<a href="/register">%s</a> · <a href="/admin/login">%s</a>
</div>
</form>
</body></html>`,
t(locale, "server.loginTitle"),
t(locale, "server.usernameOrEmail"),
t(locale, "server.password"),
t(locale, "server.loginBtn"),
t(locale, "server.forgotPassword"),
t(locale, "server.registerBtn"),
t(locale, "server.adminLink"),
)
}
func confirmedHTML(locale string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync — %s</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px;text-align:center}
h1{font-size:20px;margin:0 0 12px;color:#34d399}
p{font-size:13px;color:#b0b0c0;margin:0 0 20px}
a{color:#6366f1;text-decoration:none}
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none}
.btn:hover{background:#4f46e5}</style>
</head><body>
<div class="box">
<h1>%s</h1>
<p>%s</p>
<a href="/login" class="btn">%s</a>
</div>
</body></html>`,
t(locale, "server.emailConfirmed"),
t(locale, "server.emailConfirmed"),
t(locale, "server.emailConfirmedMessage"),
t(locale, "server.loginBtn"),
)
}
func registrationOKHTML(locale string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync — %s</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:360px;text-align:center}
h1{font-size:20px;margin:0 0 12px;color:#34d399}
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
a{color:#6366f1;text-decoration:none}
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none;margin-top:16px}
.btn:hover{background:#4f46e5}</style>
</head><body>
<div class="box">
<h1>%s</h1>
<p>%s</p>
<p>%s</p>
<a href="/login" class="btn">%s</a>
</div>
</body></html>`,
t(locale, "server.registerTitle"),
t(locale, "server.registrationSuccess"),
t(locale, "server.registrationEmailSent"),
t(locale, "server.registrationCheckEmail"),
t(locale, "server.loginBtn"),
)
}
func registrationAutoHTML(locale string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync — %s</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:360px;text-align:center}
h1{font-size:20px;margin:0 0 12px;color:#34d399}
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
a{color:#6366f1;text-decoration:none}
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none;margin-top:16px}
.btn:hover{background:#4f46e5}</style>
</head><body>
<div class="box">
<h1>%s</h1>
<p>%s</p>
<a href="/login" class="btn">%s</a>
</div>
</body></html>`,
t(locale, "server.registerTitle"),
t(locale, "server.registrationSuccess"),
t(locale, "server.registrationAutoMessage"),
t(locale, "server.loginBtn"),
)
}
func forgotPasswordHTML(locale string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>%s</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
h1{font-size:18px;margin:0 0 8px;text-align:center}
p{font-size:12px;color:#888;text-align:center;margin:0 0 20px}
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
button:hover{background:#4f46e5}
.links{text-align:center;font-size:12px;color:#666;margin-top:16px}
.links a{color:#6366f1;text-decoration:none}
.links a:hover{text-decoration:underline}</style>
</head><body>
<form method="POST">
<h1>%s</h1>
<p>%s</p>
<label>%s</label>
<input type="email" name="email" autofocus required>
<button>%s</button>
<div class="links"><a href="/login">%s</a></div>
</form>
</body></html>`,
t(locale, "server.resetPasswordTitle"),
t(locale, "server.resetPassword"),
t(locale, "server.resetInstruction"),
t(locale, "server.email"),
t(locale, "server.sendLink"),
t(locale, "server.backToLogin"),
)
}
func forgotSentHTML(locale string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>%s</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:360px;text-align:center}
h1{font-size:18px;margin:0 0 12px;color:#34d399}
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
a{color:#6366f1;text-decoration:none}
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none;margin-top:16px}
.btn:hover{background:#4f46e5}</style>
</head><body>
<div class="box">
<h1>%s</h1>
<p>%s</p>
<a href="/login" class="btn">%s</a>
</div>
</body></html>`,
t(locale, "server.emailSentTitle"),
t(locale, "server.emailSent"),
t(locale, "server.emailSentMessage"),
t(locale, "server.goHome"),
)
}
func resetPasswordHTML(locale string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>%s</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
h1{font-size:18px;margin:0 0 20px;text-align:center}
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
button:hover{background:#4f46e5}
.hint{font-size:11px;color:#666;text-align:center;margin-top:12px}</style>
</head><body>
<form method="POST">
<h1>%s</h1>
<input type="hidden" name="token" value="{TOKEN}">
<label>%s</label>
<input type="password" name="password" minlength="8" maxlength="256" required autofocus>
<label>%s</label>
<input type="password" name="confirm" minlength="8" maxlength="256" required>
<button style="margin-top:8px">%s</button>
</form>
</body></html>`,
t(locale, "server.newPasswordTitle"),
t(locale, "server.newPassword"),
t(locale, "server.password"),
t(locale, "server.passwordConfirm"),
t(locale, "server.save"),
)
}
func resetDoneHTML(locale string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync — %s</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:360px;text-align:center}
h1{font-size:18px;margin:0 0 12px;color:#34d399}
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none;margin-top:16px}
.btn:hover{background:#4f46e5}</style>
</head><body>
<div class="box">
<h1>%s</h1>
<p>%s</p>
<a href="/login" class="btn">%s</a>
</div>
</body></html>`,
t(locale, "server.passwordChanged"),
t(locale, "server.passwordChanged"),
t(locale, "server.passwordChangedMessage"),
t(locale, "server.loginBtn"),
)
}
func adminDashboardHTML(locale string, deviceCount, opsCount int, smtpHost, smtpPort, smtpUser, smtpFrom, smtpSecurity, srvURL string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>%[1]s</title>
<style>
body{font-family:sans-serif;background:#13131f;color:#e4e4ef;padding:24px;max-width:860px;margin:0 auto}
a{color:#6366f1}
h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}
h2{margin-top:24px;font-size:16px}
.stat{background:#1a1a28;border:1px solid #2a2a3c;padding:12px 16px;border-radius:8px;margin:8px 0}
table{width:100%%;border-collapse:collapse;margin-top:8px}
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c}
th{font-size:12px;color:#888;text-transform:uppercase}
.key-cell{max-width:360px;overflow:hidden;text-overflow:ellipsis;font-family:monospace;font-size:12px;color:#b0b0c0}
.btn{font-family:inherit;font-size:12px;padding:6px 12px;border-radius:6px;border:1px solid #2a2a3c;background:#1a1a28;color:#ccc;cursor:pointer;display:inline-flex;align-items:center;gap:4px}
.btn:hover{background:#222233}
.btn-primary{background:#6366f1;border-color:#6366f1;color:#fff}
.btn-primary:hover{background:#4f46e5}
.btn-danger{color:#ff6b6b;border-color:#4a2222}
.btn-danger:hover{background:#3a2222}
.copy-btn{padding:2px 8px;font-size:11px;margin-left:6px}
input{font-family:inherit;font-size:14px;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;margin:0;box-sizing:border-box}
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}
.modal h2{margin-top:0}
.modal-close{position:absolute;top:10px;right:14px;font-size:20px;cursor:pointer;background:none;border:none;color:#888}
.modal-close:hover{color:#e4e4ef}
pre{background:#13131f;border:1px solid #2a2a3c;border-radius:8px;padding:12px;overflow-x:auto;white-space:pre-wrap}
</style>
</head><body>
<h1>Verstak Sync Server</h1>
<div style="display:flex;gap:20px;flex-wrap:wrap">
<div class="stat" style="margin:0"><strong>%[2]s</strong> <span id="dev-count">%[40]d</span></div>
<div class="stat" style="margin:0"><strong>%[3]s</strong> <span id="op-count">%[41]d</span></div>
</div>
<div class="toolbar">
<button class="btn btn-primary" onclick="openSMTP()">%[15]s</button>
<a href="/admin/users" style="text-decoration:none"><button class="btn" type="button">%[16]s</button></a>
<button class="btn" onclick="openHealth()">%[17]s</button>
</div>
<h2>%[4]s</h2>
<div id="devices"></div>
<script>
fetch('/admin/api/devices').then(r=>r.json()).then(devices=>{
const div=document.getElementById('devices')
if(!devices.length){div.innerHTML='<p>%[5]s</p>';return}
div.innerHTML='<table><tr><th>%[6]s</th><th>%[7]s</th><th>%[8]s</th><th>%[9]s</th><th>%[10]s</th><th></th></tr>'+
devices.map(d=>{
var status=d.revoked_at?'<span style="color:#ff6b6b">%[12]s</span>':'<span style="color:#34d399">%[11]s</span>'
var ls=d.last_seen||'\u2014'
var revBtn=''
if(!d.revoked_at) revBtn='<button class="btn btn-danger" onclick="revokeDevice(\''+d.id+'\')">%[13]s</button>'
return '<tr><td>'+d.name+'</td><td>'+(d.user||'\u2014')+'</td><td>'+(d.client_version||'\u2014')+'</td><td>'+status+'</td><td>'+ls+'</td><td>'+revBtn+'</td></tr>'
}).join('')+'</table>'
document.getElementById('dev-count').textContent=devices.length
})
fetch('/admin/api/stats').then(r=>r.json()).then(stats=>{
document.getElementById('op-count').textContent=stats.ops||'0'
})
function revokeDevice(id){
if(!confirm('%[31]s'))return
fetch('/admin/api/keys/'+id,{method:'DELETE'}).then(()=>location.reload())
}
function openSMTP(){document.getElementById('smtp-modal').style.display='flex';document.getElementById('smtp-test-result').textContent=''}
function closeSMTP(e){if(!e||e.target.id==='smtp-modal')document.getElementById('smtp-modal').style.display='none'}
function openHealth(){var m=document.getElementById('health-modal');m.style.display='flex';document.getElementById('health-result').textContent='%[14]s';fetch('/api/v1/health').then(function(r){return r.text()}).then(function(t){document.getElementById('health-result').textContent=t})}
function closeHealth(e){if(!e||e.target.id==='health-modal')document.getElementById('health-modal').style.display='none'}
function testSMTP(){
var f=document.querySelector('#smtp-modal form')
var fd=new FormData(f)
var obj={};for(var e of fd.entries()){obj[e[0]]=e[1]}
var r=document.getElementById('smtp-test-result')
r.textContent='%[29]s';r.style.color='#888'
fetch('/admin/api/smtp/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(obj)}).then(function(r2){return r2.json()}).then(function(d){
r.textContent=d.ok?'%[30]s':'\u2717 '+d.error
r.style.color=d.ok?'#4ade80':'#ff6b6b'
}).catch(function(e){r.textContent='\u2717 '+e;r.style.color='#ff6b6b'})
}
</script>
<div id="smtp-modal" class="modal-overlay" style="display:none" onclick="closeSMTP(event)">
<div class="modal">
<button class="modal-close" onclick="closeSMTP()">&times;</button>
<h2>%[28]s</h2>
<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" 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>
</select></div>
<div class="form-row"><label>%[22]s</label><input name="smtp_user" value="%[37]s" placeholder="user@example.com"></div>
<div class="form-row"><label>%[23]s</label><input type="password" name="smtp_pass" placeholder="••••••••"></div>
<div class="form-row"><label>%[24]s</label><input name="smtp_from" value="%[38]s" placeholder="noreply@example.com"></div>
<div class="form-row"><label>%[25]s</label><input name="server_url" value="%[39]s" placeholder="https://example.com:47732"></div>
<div style="margin-top:12px;display:flex;gap:8px;align-items:center">
<button class="btn btn-primary">%[26]s</button>
<button class="btn" type="button" onclick="testSMTP()">%[27]s</button>
<span id="smtp-test-result" style="font-size:12px"></span>
</div>
</form>
</div>
</div>
<div id="health-modal" class="modal-overlay" style="display:none" onclick="closeHealth(event)">
<div class="modal">
<button class="modal-close" onclick="closeHealth()">&times;</button>
<h2>%[17]s</h2>
<pre id="health-result">%[14]s</pre>
</div>
</div>
</body></html>`,
t(locale, "admin.dashboard"),
t(locale, "admin.deviceCount"),
t(locale, "admin.opsCount"),
t(locale, "admin.devices"),
t(locale, "admin.noDevices"),
t(locale, "admin.device"),
t(locale, "admin.user"),
t(locale, "admin.version"),
t(locale, "admin.status"),
t(locale, "admin.lastSeen"),
t(locale, "admin.active"),
t(locale, "admin.revoked"),
t(locale, "admin.revoke"),
t(locale, "common.loading"),
t(locale, "admin.smtp"),
t(locale, "admin.users"),
t(locale, "admin.healthCheck"),
t(locale, "admin.smtpServer"),
t(locale, "admin.smtpPort"),
t(locale, "admin.smtpType"),
t(locale, "admin.smtpNoEncryption"),
t(locale, "admin.smtpUsername"),
t(locale, "admin.smtpPassword"),
t(locale, "admin.smtpFrom"),
t(locale, "admin.smtpServerURL"),
t(locale, "admin.smtpSave"),
t(locale, "admin.smtpTest"),
t(locale, "admin.smtpTitle"),
t(locale, "admin.smtpTesting"),
t(locale, "admin.smtpPassed"),
t(locale, "admin.revokeConfirm"),
smtpHost,
smtpPort,
sel(smtpSecurity, "starttls"),
sel(smtpSecurity, "tls"),
sel(smtpSecurity, "none"),
smtpUser,
smtpFrom,
srvURL,
deviceCount,
opsCount,
)
}
func userDashboardHTML(locale, username, deviceRows string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync — %[1]s</title>
<style>
body{font-family:sans-serif;background:#13131f;color:#e4e4ef;padding:24px;max-width:800px;margin:0 auto}
h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}
h2{margin-top:24px;font-size:16px}
table{width:100%%;border-collapse:collapse;margin-top:8px}
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c}
th{font-size:12px;color:#888;text-transform:uppercase}
.btn{font-family:inherit;font-size:12px;padding:6px 12px;border-radius:6px;border:1px solid #2a2a3c;background:#1a1a28;color:#ccc;cursor:pointer;display:inline-flex;align-items:center;gap:4px}
.btn:hover{background:#222233}
.btn-primary{background:#6366f1;border-color:#6366f1;color:#fff}
.btn-primary:hover{background:#4f46e5}
.btn-danger{color:#ff6b6b;border-color:#4a2222}
.btn-danger:hover{background:#3a2222}
.btn-sm{padding:2px 8px;font-size:11px}
.top{display:flex;justify-content:space-between;align-items:center}
a{color:#6366f1}
</style>
</head><body>
<div class="top">
<h1>Verstak Sync</h1>
<span>%[1]s · <a href="/logout">%[2]s</a></span>
</div>
<h2>%[3]s</h2>
<table><tr><th>%[4]s</th><th>%[5]s</th><th>%[6]s</th><th>%[7]s</th><th>%[8]s</th></tr>%[9]s</table>
<div style="margin-top:24px;padding:16px;background:#1a1a28;border:1px solid #2a2a3c;border-radius:8px">
<h2 style="margin-top:0">%[10]s</h2>
<p style="font-size:13px;color:#888">%[11]s</p>
</div>
<script>
function revokeDevice(id){
if(!confirm('%[12]s'))return
var pw=prompt('%[13]s')
if(!pw)return
fetch('/api/client/revoke-device',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({device_id:id,password:pw})}).then(function(r){return r.json()}).then(function(d){
if(d.status==='revoked'){location.reload()}else{alert(d.error||'error')}
})
}
</script>
</body></html>`,
username,
t(locale, "server.logout"),
t(locale, "userDashboard.devices"),
t(locale, "userDashboard.device"),
t(locale, "userDashboard.status"),
t(locale, "userDashboard.connected"),
t(locale, "userDashboard.lastSeen"),
t(locale, "userDashboard.version"),
deviceRows,
t(locale, "userDashboard.connectNew"),
t(locale, "userDashboard.connectNewHint"),
t(locale, "userDashboard.revokeConfirm"),
t(locale, "userDashboard.revokePrompt"),
)
}
func adminCreateUserHTML(locale string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>%[1]s</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
h1{font-size:20px;margin:0 0 20px;text-align:center}
p{text-align:center;font-size:12px;color:#666;margin-top:16px}
a{color:#6366f1}
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
button:hover{background:#4f46e5}
.hint{font-size:11px;color:#666;margin-top:-12px;margin-bottom:16px;text-align:center}
</style>
</head><body>
<form method="POST">
<h1>%[2]s</h1>
<label>%[3]s</label>
<input type="text" name="username" autofocus required>
<label>%[4]s</label>
<input type="email" name="email" required>
<label>%[5]s</label>
<input type="password" name="password" required minlength="8" maxlength="256">
<button>%[6]s</button>
<p><a href="/admin/users">%[7]s</a></p>
</form>
</body></html>`,
t(locale, "admin.createUser"),
t(locale, "admin.createUser"),
t(locale, "server.username"),
t(locale, "server.email"),
t(locale, "server.password"),
t(locale, "admin.createUserBtn"),
t(locale, "server.dashboard"),
)
}
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">
<title>Verstak Sync — %s</title>
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;text-align:center;max-width:360px}
h1{font-size:18px;margin:0 0 12px;color:#ff6b6b}
p{font-size:13px;color:#b0b0c0;margin:0 0 16px}
a{color:#6366f1;text-decoration:none}
a:hover{text-decoration:underline}</style>
</head><body>
<div class="box">
<h1>%s</h1>
<p>%s</p>
<a href="%s">%s</a>
</div>
</body></html>`, title, title, msg, backURL, t(locale, "server.back"))
}
func adminUsersHTML(locale string) string {
newPassResult := t(locale, "server.newPasswordResult")
newPassParts := strings.SplitN(newPassResult, "%s", 2)
newPassPrefix := newPassParts[0]
newPassSuffix := ""
if len(newPassParts) > 1 {
newPassSuffix = strings.ReplaceAll(newPassParts[1], "\n", "\\n")
}
deleteMsg := t(locale, "admin.deleteUserMessage")
deleteMsgParts := strings.SplitN(deleteMsg, "%s", 2)
delMsgPrefix := deleteMsgParts[0]
delMsgSuffix := ""
if len(deleteMsgParts) > 1 {
delMsgSuffix = deleteMsgParts[1]
}
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>%[1]s</title>
<style>
body{font-family:sans-serif;background:#13131f;color:#e4e4ef;padding:24px;max-width:960px;margin:0 auto}
a{color:#6366f1}
h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}
table{width:100%%;border-collapse:collapse;margin-top:12px}
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c}
th{font-size:12px;color:#888;text-transform:uppercase;cursor:pointer;user-select:none}
th:hover{color:#b0b0c0}
th.sorted{color:#6366f1}
.btn{font-family:inherit;font-size:12px;padding:6px 12px;border-radius:6px;border:1px solid #2a2a3c;background:#1a1a28;color:#ccc;cursor:pointer;display:inline-flex;align-items:center;gap:4px}
.btn:hover{background:#222233}
.btn-primary{background:#6366f1;border-color:#6366f1;color:#fff}
.btn-primary:hover{background:#4f46e5}
.btn-danger{color:#ff6b6b;border-color:#4a2222}
.btn-danger:hover{background:#3a2222}
.btn-sm{padding:2px 8px;font-size:11px}
input{font-family:inherit;font-size:14px;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;box-sizing:border-box}
input:focus{outline:none;border-color:#6366f1}
.toolbar{display:flex;gap:8px;margin:12px 0;flex-wrap:wrap;align-items:center}
.pagination{display:flex;gap:8px;margin-top:12px;align-items:center;justify-content:center}
.pagination span{padding:4px 8px;font-size:12px;color:#888}
.badge{padding:2px 8px;border-radius:4px;font-size:11px}
.badge-green{background:#064e3b;color:#34d399}
.badge-red{background:#4a2222;color:#ff6b6b}
.badge-yellow{background:#4a3e00;color:#fbbf24}
.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:400px;max-width:90vw;position:relative}
.modal h2{margin-top:0;font-size:16px}
.modal-close{position:absolute;top:10px;right:14px;font-size:20px;cursor:pointer;background:none;border:none;color:#888}
.modal-close:hover{color:#e4e4ef}
.form-row{display:flex;gap:8px;margin-bottom:12px;align-items:center}
.form-row label{font-size:12px;color:#888;min-width:80px;flex-shrink:0}
.form-row input{flex:1}
</style>
</head><body>
<h1>%[2]s</h1>
<p><a href="/admin/dashboard">%[3]s</a></p>
<div class="toolbar">
<input id="filter-input" placeholder="%[4]s" style="width:200px" onkeyup="loadUsers()">
<a href="/admin/create-user" style="text-decoration:none"><button class="btn btn-primary" type="button">%[39]s</button></a>
</div>
<table>
<thead><tr>
<th onclick="sortBy('username')">%[5]s <span id="s-username"></span></th>
<th onclick="sortBy('email')">%[6]s <span id="s-email"></span></th>
<th onclick="sortBy('confirmed')">%[7]s <span id="s-confirmed"></span></th>
<th onclick="sortBy('devices')">%[8]s <span id="s-devices"></span></th>
<th onclick="sortBy('last_seen')">%[9]s <span id="s-last_seen"></span></th>
<th>%[10]s</th>
</tr></thead>
<tbody id="users-tbody"></tbody>
</table>
<div class="pagination" id="pagination"></div>
<div id="confirm-modal" class="modal-overlay" style="display:none">
<div class="modal">
<button class="modal-close" onclick="closeConfirm()">&times;</button>
<h2 id="confirm-title">%[11]s</h2>
<p id="confirm-text"></p>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
<button class="btn" onclick="closeConfirm()">%[12]s</button>
<button class="btn btn-danger" id="confirm-btn" onclick="confirmAction()">%[13]s</button>
</div>
</div>
</div>
<div id="edit-modal" class="modal-overlay" style="display:none">
<div class="modal">
<button class="modal-close" onclick="closeEdit()">&times;</button>
<h2>%[14]s</h2>
<div class="form-row"><label>%[15]s</label><input id="edit-username"></div>
<div class="form-row"><label>%[16]s</label><input id="edit-email" type="email"></div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
<button class="btn" onclick="closeEdit()">%[17]s</button>
<button class="btn btn-primary" onclick="saveEdit()">%[18]s</button>
</div>
</div>
</div>
<div id="result-modal" class="modal-overlay" style="display:none">
<div class="modal" style="width:320px">
<button class="modal-close" onclick="closeResult()">&times;</button>
<h2 id="result-title">%[19]s</h2>
<p id="result-text" style="white-space:pre-wrap"></p>
<button class="btn btn-primary" onclick="closeResult()" style="margin-top:8px">%[20]s</button>
</div>
</div>
<script>
var currentPage=1,currentSort='',currentOrder='',editUserId='',pendingAction=''
function loadUsers(){
var f=document.getElementById('filter-input').value
var u='/admin/api/users?page='+currentPage+'&per_page=20&filter='+encodeURIComponent(f)
if(currentSort){u+='&sort='+currentSort+'&order='+currentOrder}
fetch(u).then(function(r){return r.json()}).then(function(d){
var tbody=document.getElementById('users-tbody')
tbody.innerHTML=''
d.users.forEach(function(u){
var status=u.confirmed?'<span class="badge badge-green">%[21]s</span>':'<span class="badge badge-yellow">%[22]s</span>'
if(u.blocked){status='<span class="badge badge-red">%[23]s</span>'}
var lastSeen=u.last_seen?new Date(u.last_seen).toLocaleString():'-'
var blockText=u.blocked?'%[24]s':'%[25]s'
var tr=document.createElement('tr')
tr.innerHTML='<td>'+esc(u.username)+'</td><td>'+esc(u.email)+'</td><td>'+status+'</td><td>'+u.devices+'</td><td>'+lastSeen+'</td>'+
'<td><button class="btn btn-sm" onclick="editUser(\''+u.id+'\',\''+escJS(u.username)+'\',\''+escJS(u.email)+'\')">✎</button> '+
'<button class="btn btn-sm" onclick="askBlock(\''+u.id+'\','+u.blocked+')">'+blockText+'</button> '+
'<button class="btn btn-sm" onclick="askReset(\''+u.id+'\')">%[26]s</button> '+
'<button class="btn btn-sm btn-danger" onclick="askDelete(\''+u.id+'\',\''+escJS(u.username)+'\')">✕</button></td>'
tbody.appendChild(tr)
})
if(!d.users.length){tbody.innerHTML='<tr><td colspan="6" style="text-align:center;color:#666">%[27]s</td></tr>'}
var totalPages=Math.ceil(d.total/d.per_page)
var pag=document.getElementById('pagination')
pag.innerHTML=''
if(totalPages>1){
var prev=document.createElement('button')
prev.className='btn btn-sm';prev.textContent='←';prev.onclick=function(){if(currentPage>1){currentPage--;loadUsers()}}
pag.appendChild(prev)
var s=document.createElement('span')
s.textContent=d.page+' / '+totalPages
pag.appendChild(s)
var next=document.createElement('button')
next.className='btn btn-sm';next.textContent='→';next.onclick=function(){if(currentPage<totalPages){currentPage++;loadUsers()}}
pag.appendChild(next)
}
})
}
function sortBy(col){
if(currentSort===col){currentOrder=currentOrder==='asc'?'desc':'asc'}
else{currentSort=col;currentOrder='asc'}
document.querySelectorAll('th').forEach(function(th){th.classList.remove('sorted')})
var el=document.getElementById('s-'+col)
if(el){el.parentElement.classList.add('sorted');el.textContent=currentOrder==='asc'?' ▲':' ▼'}
loadUsers()
}
function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
function escJS(s){return s.replace(/'/g,"\\'").replace(/"/g,'&quot;')}
function editUser(id,username,email){
editUserId=id;document.getElementById('edit-username').value=username;document.getElementById('edit-email').value=email;document.getElementById('edit-modal').style.display='flex'}
function closeEdit(){document.getElementById('edit-modal').style.display='none'}
function saveEdit(){
var un=document.getElementById('edit-username').value,em=document.getElementById('edit-email').value
if(!un||!em)return
fetch('/admin/api/users/'+editUserId+'/edit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:un,email:em})}).then(function(r){return r.json()}).then(function(d){closeEdit();if(d.status==='ok')loadUsers()})
}
function askBlock(id,blocked){
pendingAction=function(){fetch('/admin/api/users/'+id+'/block',{method:'POST'}).then(function(r){return r.json()}).then(function(d){loadUsers()})}
document.getElementById('confirm-title').textContent=blocked?'%[35]s':'%[36]s'
document.getElementById('confirm-text').textContent=blocked?'%[37]s':'%[38]s'
document.getElementById('confirm-btn').textContent=blocked?'%[24]s':'%[25]s'
document.getElementById('confirm-modal').style.display='flex'}
function askReset(id){
pendingAction=function(){
fetch('/admin/api/users/'+id+'/reset-password',{method:'POST'}).then(function(r){return r.json()}).then(function(d){
document.getElementById('confirm-modal').style.display='none'
document.getElementById('result-title').textContent='%[28]s'
document.getElementById('result-text').textContent='%[29]s' + d.new_password + '%[30]s'
document.getElementById('result-modal').style.display='flex'})}
document.getElementById('confirm-title').textContent='%[31]s'
document.getElementById('confirm-text').textContent='%[32]s'
document.getElementById('confirm-btn').textContent='%[33]s'
document.getElementById('confirm-modal').style.display='flex'}
function askDelete(id,username){
pendingAction=function(){fetch('/admin/api/users/'+id,{method:'DELETE'}).then(function(r){return r.json()}).then(function(d){loadUsers()})}
document.getElementById('confirm-title').textContent='%[34]s'
document.getElementById('confirm-text').textContent='%[35]s' + username + '%[36]s'
document.getElementById('confirm-btn').textContent='%[37]s'
document.getElementById('confirm-modal').style.display='flex'}
function closeConfirm(){document.getElementById('confirm-modal').style.display='none';pendingAction=''}
function confirmAction(){if(pendingAction){pendingAction();pendingAction=''}}
function closeResult(){document.getElementById('result-modal').style.display='none'}
loadUsers()
</script>
</body></html>`,
t(locale, "admin.users"),
t(locale, "admin.usersHeading"),
t(locale, "server.dashboard"),
t(locale, "admin.filterPlaceholder"),
t(locale, "admin.username"),
t(locale, "admin.email"),
t(locale, "admin.status"),
t(locale, "admin.devices"),
t(locale, "admin.lastSeen"),
t(locale, "admin.actions"),
t(locale, "admin.confirmTitle"),
t(locale, "admin.modalCancel"),
t(locale, "admin.modalConfirm"),
t(locale, "admin.editUser"),
t(locale, "admin.username"),
t(locale, "admin.email"),
t(locale, "admin.modalCancel"),
t(locale, "admin.editBtn"),
t(locale, "admin.resultTitle"),
t(locale, "common.ok"),
t(locale, "admin.confirmed"),
t(locale, "admin.unconfirmed"),
t(locale, "admin.blocked"),
t(locale, "admin.unblock"),
t(locale, "admin.block"),
t(locale, "admin.resetPassword"),
t(locale, "admin.noUsers"),
t(locale, "server.newPassword"),
newPassPrefix,
newPassSuffix,
t(locale, "admin.resetPasswordConfirm"),
t(locale, "admin.resetPasswordMessage"),
t(locale, "admin.resetBtn"),
t(locale, "admin.deleteUser"),
delMsgPrefix,
delMsgSuffix,
t(locale, "admin.deleteBtn"),
t(locale, "admin.unblockUserTitle"),
t(locale, "admin.blockUserTitle"),
t(locale, "admin.unblockUserMessage"),
t(locale, "admin.blockUserMessage"),
t(locale, "admin.createUser"),
)
}
+15
View File
@@ -0,0 +1,15 @@
package server
import (
"crypto/rand"
"encoding/hex"
)
func genDeviceToken() (token, prefix, suffix string) {
b := make([]byte, 32)
rand.Read(b)
token = hex.EncodeToString(b)
prefix = token[:8]
suffix = token[len(token)-4:]
return
}
+31 -15
View File
@@ -1,21 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
OUTPUT_DIR="$REPO_ROOT/build/bin"
BINARY="$OUTPUT_DIR/verstak-sync-server"
echo "=== verstak-sync-server build ==="
if [ -f "$ROOT/go.mod" ]; then
echo "[go build]"
(cd "$ROOT" && go build ./...)
echo " ✅ go build"
if go test -list . ./... &>/dev/null 2>&1; then
(cd "$ROOT" && go test -count=1 ./... 2>&1 || true)
else
echo " ℹ️ no tests"
fi
else
echo " ℹ️ repository empty — no build target yet"
echo " 📝 This repo will hold the Verstak sync server (CRDT-based)"
echo "=== Verstak Sync Server Build ==="
# Check dependencies
if ! command -v go &>/dev/null; then
echo "❌ go not found"
exit 1
fi
echo "✅ go $(go version | awk '{print $3}')"
# Clean
rm -rf "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"
# Build
echo "→ Building server binary..."
cd "$REPO_ROOT"
go build -o "$BINARY" ./cmd/server
echo "✅ Binary built: $BINARY"
ls -lh "$BINARY"
# Tests
echo "→ Running tests..."
go test ./...
echo "✅ Tests passed"
echo ""
echo "✅ build passed (no-op)"
echo "=== Build Complete ==="
echo "Binary: $BINARY"
echo "Run: $BINARY --help"
+113
View File
@@ -0,0 +1,113 @@
#!/bin/sh
#
# install.sh — установка Verstak Sync Server
#
# Использование:
# sudo ./install.sh --port 47732 --user verstak --admin-user admin --admin-pass secret
#
# Флаги:
# --port Порт сервера (по умолчанию: 47732)
# --user Системный пользователь (по умолчанию: verstak)
# --admin-user Логин администратора (обязательный)
# --admin-pass Пароль администратора (обязательный)
# --bin Путь к бинарнику (по умолчанию: ./verstak-sync-server)
#
set -e
# Defaults
PORT="${VERSTAK_PORT:-47732}"
USER="verstak"
ADMIN_USER=""
ADMIN_PASS=""
BIN="./verstak-sync-server"
# Parse flags
while [ $# -gt 0 ]; do
case "$1" in
--port) PORT="$2"; shift 2 ;;
--user) USER="$2"; shift 2 ;;
--admin-user) ADMIN_USER="$2"; shift 2 ;;
--admin-pass) ADMIN_PASS="$2"; shift 2 ;;
--bin) BIN="$2"; shift 2 ;;
*) echo "Unknown: $1"; exit 1 ;;
esac
done
if [ -z "$ADMIN_USER" ] || [ -z "$ADMIN_PASS" ]; then
echo "Usage: $0 --admin-user USER --admin-pass PASS [--port PORT] [--user USER]"
exit 1
fi
if [ "$(id -u)" -ne 0 ]; then
echo "This script must be run as root (sudo)."
exit 1
fi
echo "=== Verstak Sync Server Installation ==="
echo "Port: $PORT"
echo "User: $USER"
echo "Admin: $ADMIN_USER"
echo "Binary: $BIN"
echo ""
# 1. Create system user if not exists.
if ! id -u "$USER" >/dev/null 2>&1; then
echo "Creating user: $USER"
useradd --system --no-create-home --shell /usr/sbin/nologin "$USER"
fi
# 2. Install binary.
INSTALL_DIR="/opt/verstak-sync-server"
if [ ! -f "$BIN" ]; then
echo "Binary not found: $BIN. Build it first: go build -o $BIN ./cmd/server/"
exit 1
fi
echo "Installing binary to $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
cp "$BIN" "$INSTALL_DIR/verstak-sync-server"
chmod 755 "$INSTALL_DIR/verstak-sync-server"
# 3. Create data directory.
DATA_DIR="/var/lib/verstak-sync-server"
echo "Creating $DATA_DIR"
mkdir -p "$DATA_DIR"
chown "$USER:$USER" "$DATA_DIR"
chmod 750 "$DATA_DIR"
# 4. Set up admin user (first run).
echo "Setting up admin user"
"$INSTALL_DIR/verstak-sync-server" \
--port "$PORT" \
--data "$DATA_DIR" \
--admin-user "$ADMIN_USER" \
--admin-pass "$ADMIN_PASS" &
SERVER_PID=$!
sleep 2
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
# 5. Install systemd unit.
echo "Installing systemd unit"
SERVICE_FILE="/etc/systemd/system/verstak-server.service"
cp "$(dirname "$0")/../verstak-server.service" "$SERVICE_FILE"
chmod 644 "$SERVICE_FILE"
# Set port in environment file.
mkdir -p /etc/verstak-server
echo "VERSTAK_PORT=$PORT" > /etc/verstak-server/env
# 6. Enable and start.
echo "Enabling and starting service"
systemctl daemon-reload
systemctl enable verstak-server
systemctl start verstak-server
echo ""
echo "=== Installation complete ==="
echo "Service: verstak-server"
echo "Port: $PORT"
echo "Admin: http://localhost:$PORT/admin/login"
echo ""
echo "Check status: systemctl status verstak-server"
echo "View logs: journalctl -u verstak-server -f"
+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"
+24
View File
@@ -0,0 +1,24 @@
[Unit]
Description=Verstak Sync Server
After=network.target
[Service]
Type=simple
User=verstak
Group=verstak
WorkingDirectory=/opt/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
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
StateDirectory=verstak-sync-server
RuntimeDirectory=verstak-sync-server
[Install]
WantedBy=multi-user.target