Compare commits

...

16 Commits

Author SHA1 Message Date
mirivlad 3bbeb42d37 Документация: обновлён AGENTS.md, release notes v0.1.0-alpha.4 2026-07-18 18:54:42 +08:00
mirivlad f35ee2aaa2 Документация: обновлён AGENTS.md (API), удалены superpowers-планы, добавлен README.ru.md с шапкой 2026-07-18 18:06:49 +08:00
mirivlad d1e4068636 fix(web): keep generated passwords out of page source 2026-07-17 07:04:46 +08:00
mirivlad e8605f15a2 feat(web): complete localized sync server console 2026-07-17 07:02:11 +08:00
mirivlad 0014910638 test(config): keep web settings inside data dir 2026-07-17 06:05:24 +08:00
mirivlad b3f8fb69a0 test(web): cover confirmation template escaping 2026-07-17 06:04:38 +08:00
mirivlad 513675c852 docs(web): document console diagnostics and smoke 2026-07-17 06:03:49 +08:00
mirivlad 50d0d7df38 test(web): add chromium smoke script 2026-07-17 06:03:06 +08:00
mirivlad 6ca174b2d9 feat(web): add admin settings and vault detail 2026-07-17 06:01:40 +08:00
mirivlad 5ca030e5f9 feat(web): add bounded admin data views 2026-07-17 05:58:16 +08:00
mirivlad f82d3b0224 refactor(web): remove legacy sync server templates 2026-07-17 05:55:20 +08:00
mirivlad 4b78f30a61 feat(web): add embedded localized sync console 2026-07-17 05:44:27 +08:00
mirivlad b7f730cba9 Harden sync server transport and credentials 2026-07-17 05:09:28 +08:00
mirivlad 487ede8e4f Document scoped ordered sync contract 2026-07-17 04:11:04 +08:00
mirivlad e68d057ec7 docs: add historical release notes 2026-07-15 07:50:17 +08:00
mirivlad 4e4eaac66f docs: require human release notes 2026-07-15 07:42:02 +08:00
76 changed files with 6628 additions and 2041 deletions

2
.gitignore vendored
View File

@ -2,6 +2,7 @@
/server
/verstak-sync-server
*.exe
/build/
# Data directory
server-data/
@ -14,3 +15,4 @@ server-data/
.DS_Store
Thumbs.db
release/
pass

View File

@ -11,16 +11,49 @@
- Плагины явно указывают, какие данные можно синхронизировать.
- Sync — не источник правды, а дополнение.
## API
## API (текущий)
Desktop sync client:
```
POST /api/v1/pair — создание пары устройство-сервер
POST /api/v1/auth — аутентификация
GET /api/v1/devices — список устройств
POST /api/v1/sync — синхронизация операций
GET /api/v1/blob/:hash — скачать blob
PUT /api/v1/blob/:hash — загрузить blob
GET /api/v1/operations — получить операции с последнего sync point
POST /api/client/pair — pair device with username/password
POST /api/auth/test — validate username/password
GET /api/client/me — current authenticated device details
POST /api/client/revoke-current — revoke current device token
POST /api/client/revoke-device — revoke another device
POST /api/v1/sync/push — push local operations
POST /api/v1/sync/pull — pull operations since server sequence
POST /api/v1/blobs/ — store a blob, return SHA-256
GET /api/v1/blobs/{sha256} — download a stored blob
```
User API:
```
POST /api/v1/auth/register — register a user
GET /api/v1/auth/confirm — confirm email (GET shows form, POST confirms)
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 user devices
```
Operational:
```
GET /api/v1/health — server health and storage status
GET /livez — liveness probe
GET /readyz — readiness probe
```
Embedded web console:
```
GET / — public page
GET /login, /register, /forgot, /reset, /logout
GET /dashboard — user device management
GET /admin/login — admin console
GET /admin/... — admin sections (users, devices, vaults, storage, audit, SMTP, diagnostics)
```
## Структура
@ -31,14 +64,20 @@ verstak-sync-server/
cmd/
server/
internal/
api/
auth/
device/
vault/
blob/
conflict/
migrations/
...
server/
server.go
routes.go
handlers_api.go
handlers_auth.go
handlers_admin.go
schema.go
web/
...
go.mod
README.md
```
## Подробнее
См. [README.md](README.md) для полной документации по развёртыванию,
конфигурации, backup/restore и архитектуре.

213
README.md
View File

@ -1,3 +1,17 @@
<div align="center">
# Verstak Sync Server
### Optional self-hosted synchronization relay for Verstak vaults.
**English** · [Русский](README.ru.md)
[![Release](https://img.shields.io/github/v/release/mirivlad/verstak-sync-server?include_prereleases\&label=release)](https://github.com/mirivlad/verstak-sync-server/releases)
![Status](https://img.shields.io/badge/status-alpha-orange)
[![License](https://img.shields.io/github/license/mirivlad/verstak-sync-server)](LICENSE)
</div>
# Verstak Sync Server
Standalone sync server for Verstak2 platform.
@ -7,8 +21,8 @@ Standalone sync server for Verstak2 platform.
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
- Vault-scoped, ordered operation-log relay with server sequence numbers
- Scoped content-addressed Blob transport for binary and large file content
- User management with email confirmation
## Quick Start
@ -18,10 +32,12 @@ This server provides synchronization between devices running Verstak2. It handle
./scripts/build.sh
# Run
./build/bin/verstak-sync-server --port 47732 --data ./server-data
./build/bin/verstak-sync-server --data ./server-data
# First run with admin user
./build/bin/verstak-sync-server --admin-user admin --admin-pass secret
printf '%s\n' 'choose-a-long-password' > /tmp/verstak-admin-password
chmod 600 /tmp/verstak-admin-password
./build/bin/verstak-sync-server --admin-user admin --admin-pass-file /tmp/verstak-admin-password
```
## Release packages
@ -51,16 +67,50 @@ annotated tag when necessary, then creates or updates the GitHub Release.
| Flag | Default | Description |
|------|---------|-------------|
| `--port` | 47732 | HTTP port |
| `--listen` | `127.0.0.1:47732` | HTTP address; an administrator must explicitly expose another interface |
| `--port` | — | Deprecated compatibility shortcut; always binds loopback |
| `--data` | ./server-data | Data directory |
| `--admin-user` | | Create admin user (first run) |
| `--admin-pass` | | Admin password (first run) |
| `--admin-pass-file` | | Read the initial admin password from a protected file |
| `--admin-pass-stdin` | | Read the initial admin password from stdin |
The server has explicit header/read/write/idle timeouts and a 16 KiB header
limit. It handles SIGINT/SIGTERM with a 20-second graceful shutdown and closes
SQLite afterwards. Release builds publish `version` and `build_commit` through
the health response; neither logs nor health contain credentials.
`config.yml` can set `listen`, `public_url`, `trusted_proxies`, and limits:
```yaml
listen: 127.0.0.1:47732
public_url: https://sync.example.test
trusted_proxies: [127.0.0.1, ::1]
limits:
max_json_body: 2097152
max_push_operations: 100
max_payload_json: 262144
max_pull_page: 100
max_blob_bytes: 268435456
max_vault_blob_bytes: 4294967296
max_user_blob_bytes: 8589934592
retention:
idempotency_hours: 24
audit_days: 90
temp_upload_hours: 24
web:
# Server default; visitors may choose System, Русский, or English in a cookie.
default_locale: en
# Set false for invite/admin-only installations.
allow_registration: true
# Product name rendered in the embedded web console.
server_name: Verstak Sync Server
```
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`;
- listen-address environment file: `/etc/verstak-server/env`;
- service: `verstak-server`.
Install from a built binary:
@ -69,9 +119,9 @@ Install from a built binary:
./scripts/build.sh
sudo ./scripts/install.sh \
--bin ./build/bin/verstak-sync-server \
--port 47732 \
--listen 127.0.0.1:47732 \
--admin-user admin \
--admin-pass 'change-this-password'
--admin-pass-file /root/verstak-admin-password
```
The install script creates a locked-down system user, initializes the data
@ -95,7 +145,7 @@ 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
echo 'VERSTAK_LISTEN=127.0.0.1:47733' | sudo tee /etc/verstak-server/env
sudo systemctl restart verstak-server
```
@ -108,8 +158,9 @@ sudo install -m 755 ./build/bin/verstak-sync-server /opt/verstak-sync-server/ver
sudo systemctl start verstak-server
```
Keep `--data` stable across upgrades. The data directory is the server's source
of truth.
Keep `--data` stable across upgrades. The data directory is the durable relay
log for connected devices; each Desktop vault remains the source of truth for
its local-first files and workspace state.
## Backup And Restore
@ -138,7 +189,7 @@ sudo tar --xattrs --acls -xzf verstak-sync-backup-YYYYMMDD-HHMMSS.tar.gz -C /var
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
curl http://127.0.0.1:47732/api/v1/health
```
After restore, connected desktop clients keep their existing device tokens.
@ -175,7 +226,7 @@ Desktop sync client:
User API:
- `POST /api/v1/auth/register` - Register a user
- `GET /api/v1/auth/confirm?token=...` - Confirm email
- `GET /api/v1/auth/confirm?token=...` - Display a confirmation form; `POST` performs confirmation
- `POST /api/v1/auth/login` - User login
- `POST /api/v1/auth/forgot` - Request password reset
- `POST /api/v1/auth/reset` - Reset password
@ -187,12 +238,141 @@ Operational endpoints:
- `/admin/...` - Admin web UI and admin JSON endpoints
- `/register`, `/login`, `/dashboard`, `/forgot`, `/reset`, `/logout` - User web UI
## Embedded web console
The server embeds its public, account, and administrator interface in the Go
binary. It has no CDN, npm build, external font, or remote analytics
dependency. `/` is a localized public page; `/login`, `/register`, `/forgot`,
and `/reset` use post/redirect/get flows. `/dashboard` lets a signed-in user
review and search only their own devices, see confirmation state and connection
times, and revoke one only after entering their password. The reusable layout,
templates, CSS, JavaScript, and local SVG live below `internal/server/web/` and
are embedded with `go:embed`; there is no separate frontend build.
`/admin/login` opens the administrator console. Its sidebar provides overview,
users, devices, vaults, storage, audit, SMTP settings, and diagnostics. Lists
use bounded server-side search, filters, whitelisted sort order, and pagination.
Administrators can create, edit, confirm, block, reset, and delete users; revoke
devices; and permanently remove only a previously revoked device. A browser
password reset generates a random password and exposes it once through a
CSRF-protected `no-store` POST after the result page loads; it is never placed
in the URL, initial HTML source, audit log, cookies, or database plaintext.
Destructive
browser actions use the shared local confirmation dialog. Blocking, credential
changes, device actions, cleanup, and SMTP changes require the current
administrator password again. Admin HTML is deliberately a normal server
rendered control plane; the existing `/admin/api/...` endpoints remain for
automation.
The locale resolver uses the `verstak_locale` HttpOnly/Lax cookie first. Its
values are `ru`, `en`, or `system`; `system` uses `Accept-Language`, then
`web.default_locale`, then English. The choice survives login and logout. A
separate short-lived HttpOnly form token protects the language chooser and all
anonymous browser POST forms; it is distinct from the server-side session CSRF
token. Registration is controlled by `web.allow_registration`; when disabled
the public registration page does not expose account creation.
The overview reports operational counts and readiness warnings. Vault details
show only metadata (devices, sequence, operation count, activity, and blob
usage), never file contents or operation `payload_json`. Diagnostics download
is sanitized: it excludes paths, tokens, passwords, hashes, and payloads.
General web settings are stored in the existing `config.yml`; public URL and
registration policy can be changed there through the console, while transport
limits remain read-only. SMTP passwords are never returned to a browser form.
All browser mutations use POST and validate a server-side session plus CSRF
token; anonymous forms use the separate public-form token described above.
The server returns security headers including a restrictive CSP,
`frame-ancestors 'none'`, `nosniff`, and a same-origin referrer policy. The
console must still be deployed behind the HTTPS reverse proxy described below:
secure cookies are enabled when HTTPS is detected through a trusted proxy.
Run the local interactive browser smoke (Chromium plus Node's built-in
`undici`; no npm install) with:
```bash
./scripts/smoke-web.sh
```
It exercises language switching, admin login/navigation, temporary-user
creation, block/unblock confirmation, device pairing/revocation, filtering,
logout, and desktop/mobile screenshots. It starts an isolated temporary server
and removes its data and screenshots on exit; it is not a reverse-proxy or
production-deployment test.
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.
Operations are returned in increasing `server_sequence`; clients must stop at
the first operation they cannot apply and retry that sequence later. The server
does not merge files, resolve conflicts, or create replacement names.
The desktop pairing payload may supply an existing `vault_id` to add a new
empty local vault to that remote scope. The server treats that value only as a
scope selector: reconciliation, conflict detection, snapshots, and durable
workspace identity remain Desktop-core responsibilities. Small text can remain
inline; binary and large files are uploaded first and their operations carry a
`blob` `{sha256,size}` reference. Blob bytes are physically deduplicated but a
`user_id`/`vault_id` reference is mandatory. Knowing another scope's SHA-256
never grants download access. Revoked devices and blocked users lose sync and
blob access immediately.
`POST /api/v1/sync/pull` accepts `since_sequence` and optional `page_limit`.
The response has ordered `ops`, `page_last_sequence`, `server_sequence`, and
`has_more`. Clients must persist a cursor only after applying each operation.
Push is capped by the configured JSON/body/field limits and returns stable
JSON `{error,code}` errors (including `request_too_large`, `rate_limited`, and
`quota_exceeded`); desktop and UI localize codes rather than server text.
New device tokens, sessions, and email/reset tokens are stored only as SHA-256
hashes. The plaintext device token is returned once by pairing. Older plaintext
API keys are marked `legacy_api_key=1` during migration and are never created
again; rotate/re-pair them during normal deployment. Admin key endpoints show
only a prefix/suffix hint. Sessions are database-backed, expire after 24 hours,
rotate on login, and use HttpOnly/Lax cookies plus a SameSite/CSRF companion
cookie. Mutating browser endpoints require a matching CSRF token and do not use
GET for destructive work.
### Reverse proxy and TLS
TLS terminates at nginx or Caddy; the server has no built-in TLS. It ignores
`Forwarded`, `X-Forwarded-For`, and `X-Forwarded-Proto` unless the TCP peer is
listed in `trusted_proxies`. For a local nginx/Caddy proxy use
`trusted_proxies: [127.0.0.1, ::1]` and set `public_url` to the HTTPS URL.
```nginx
location / {
proxy_pass http://127.0.0.1:47732;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
Never bind `0.0.0.0` merely to make a proxy work. If an external listener is
intentional, restrict it with a firewall and configure the real proxy CIDR.
### Operations, retention, and privacy
`GET /api/v1/health`, `/livez`, and `/readyz` report status, version, build,
uptime, database reachability, blob writability, schema version, and server
time without paths or secrets. The internal stats service exposes user/device,
vault, operation, database/blob size, and last-sync counters for a future
admin panel.
Retention cleans expired sessions/email tokens, bounded idempotency records,
old audit entries, stale upload temp files, and in-memory rate buckets. It does
**not** delete sync operations or referenced blobs: without a materialized
checkpoint and verified recovery protocol, pruning would prevent a new device
from restoring a vault. That checkpoint/operation-retention design is a future
milestone.
The server is optional: Desktop remains local-first. The relay can see metadata
and file bytes needed to serve operations/blobs; files are not end-to-end
encrypted in this milestone. Secrets, plugin settings, Todo, Journal,
Activity, and Browser Inbox are not synchronized here.
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
@ -204,6 +384,9 @@ new clients.
# Run tests
go test ./...
# Run real headless Chromium smoke screenshots in a temporary directory
./scripts/smoke-web.sh
# Build for production
CGO_ENABLED=1 go build -o verstak-sync-server ./cmd/server
```

158
README.ru.md Normal file
View File

@ -0,0 +1,158 @@
<div align="center">
# Verstak Sync Server
### Собственный сервер синхронизации для vault-хранилищ Верстака.
[English](README.md) · **Русский**
[![Релиз](https://img.shields.io/github/v/release/mirivlad/verstak-sync-server?include_prereleases\&label=release)](https://github.com/mirivlad/verstak-sync-server/releases)
![Статус](https://img.shields.io/badge/status-alpha-orange)
[![Лицензия](https://img.shields.io/github/license/mirivlad/verstak-sync-server)](LICENSE)
</div>
> **Alpha-версия.** Сервер синхронизации дополняет локальный рабочий процесс.
> Desktop-приложение остаётся основным источником данных.
## Обзор
Сервер синхронизации обеспечивает обмен данными между устройствами с Verstak Desktop:
- Регистрация устройств и аутентификация
- Упорядоченный журнал операций с серверными номерами последовательности
- Передача бинарных и больших файлов через Blob API
- Управление пользователями с подтверждением email
- Встроенная веб-консоль для администратора и пользователей
## Быстрый старт
```bash
# Сборка
./scripts/build.sh
# Запуск
./build/bin/verstak-sync-server --data ./server-data
# Первый запуск с администратором
printf '%s\n' 'выберите-длинный-пароль' > /tmp/verstak-admin-password
chmod 600 /tmp/verstak-admin-password
./build/bin/verstak-sync-server --admin-user admin --admin-pass-file /tmp/verstak-admin-password
```
## Установка
Соберите бинарник и запустите установочный скрипт:
```bash
./scripts/build.sh
sudo ./scripts/install.sh \
--bin ./build/bin/verstak-sync-server \
--listen 127.0.0.1:47732 \
--admin-user admin \
--admin-pass-file /root/verstak-admin-password
```
Скрипт создаёт системного пользователя, инициализирует каталог данных и
устанавливает systemd-сервис `verstak-server`.
Основные операции:
```bash
sudo systemctl status verstak-server
sudo journalctl -u verstak-server -f
curl http://127.0.0.1:47732/api/v1/health
```
## Развёртывание
В production сервер должен работать за HTTPS. Сам сервер слушает plain HTTP;
TLS терминируется в обратном прокси (nginx, Caddy).
Пример nginx:
```nginx
location / {
proxy_pass http://127.0.0.1:47732;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
Всегда привязывайте сервер к loopback (`127.0.0.1`), если явно не нужен внешний
доступ.
## Резервное копирование и восстановление
Создание резервной копии (сервис должен быть остановлен):
```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
```
Восстановление:
```bash
sudo systemctl stop verstak-server
sudo tar --xattrs --acls -xzf verstak-sync-backup-ГГГГММДД-ЧЧММСС.tar.gz -C /var/lib
sudo chown -R verstak:verstak /var/lib/verstak-sync-server
sudo systemctl start verstak-server
```
## API
Desktop sync client:
| Метод | Назначение |
|-------|------------|
| `POST /api/client/pair` | Сопряжение устройства |
| `POST /api/auth/test` | Проверка учётных данных |
| `GET /api/client/me` | Информация о текущем устройстве |
| `POST /api/v1/sync/push` | Отправка локальных операций |
| `POST /api/v1/sync/pull` | Получение операций с сервера |
| `POST /api/v1/blobs/` | Загрузка бинарного файла |
| `GET /api/v1/blobs/{sha256}` | Скачивание бинарного файла |
User API:
| Метод | Назначение |
|-------|------------|
| `POST /api/v1/auth/register` | Регистрация |
| `POST /api/v1/auth/login` | Вход |
| `GET /api/v1/user/devices` | Список устройств |
Операционные:
| Метод | Назначение |
|-------|------------|
| `GET /api/v1/health` | Состояние сервера |
Административная веб-консоль доступна по `/admin/login`.
## Встроенная веб-консоль
Сервер включает встроенную веб-консоль без внешних зависимостей:
- Локализованные публичные страницы (System / English / Русский)
- Личный кабинет пользователя: устройства, подтверждение email
- Административная панель: пользователи, устройства, vaults, хранилище,
аудит, SMTP, диагностика
- Защита сессиями, CSRF-токенами и security-заголовками
## Архитектура
Сервер не является источником истины для данных. Desktop остаётся local-first.
Сервер передаёт операции и blob-файлы между устройствами, но не сливает
содержимое файлов, не разрешает конфликты и не создаёт замещающих имён.
Файлы не имеют сквозного шифрования в текущем milestone. Секреты, настройки
плагинов, задачи, журнал, активность и браузерный inbox не синхронизируются.
## Лицензия
Copyright © 2026 Verstak contributors. Распространяется на условиях
[GNU AGPLv3 или новее](LICENSE).

Binary file not shown.

View File

@ -1,21 +1,36 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"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)")
listen := flag.String("listen", "", "HTTP listen address (default 127.0.0.1:47732)")
port := flag.Int("port", 0, "Deprecated compatibility override for the loopback port")
adminUser := flag.String("admin-user", "", "Create admin user (first run)")
adminPass := flag.String("admin-pass", "", "Admin password (first run)")
adminPassFile := flag.String("admin-pass-file", "", "Read initial admin password from a 0600 file")
adminPassStdin := flag.Bool("admin-pass-stdin", false, "Read initial admin password from stdin")
showVersion := flag.Bool("version", false, "Print build version and exit")
flag.Parse()
if *showVersion {
fmt.Printf("verstak-sync-server %s (%s)\n", server.Version, server.BuildCommit)
return
}
absData, err := filepath.Abs(*dataDir)
if err != nil {
@ -31,11 +46,37 @@ func main() {
log.Fatalf("config: %v", err)
}
if *adminUser != "" && *adminPass != "" {
if err := cfg.SetAdmin(*adminUser, *adminPass); err != nil {
if envListen := strings.TrimSpace(os.Getenv("VERSTAK_LISTEN")); envListen != "" {
cfg.Listen = envListen
}
if *listen != "" {
cfg.Listen = *listen
}
if *port != 0 && *listen == "" {
cfg.Listen = fmt.Sprintf("127.0.0.1:%d", *port)
}
if publicURL := strings.TrimSpace(os.Getenv("VERSTAK_PUBLIC_URL")); publicURL != "" {
cfg.PublicURL = publicURL
}
if trusted := strings.TrimSpace(os.Getenv("VERSTAK_TRUSTED_PROXIES")); trusted != "" {
cfg.TrustedProxies = strings.Split(trusted, ",")
}
if err := cfg.Normalize(); err != nil {
log.Fatalf("config: %v", err)
}
adminPass, err := initialAdminPassword(*adminPassFile, *adminPassStdin)
if err != nil {
log.Fatalf("admin password: %v", err)
}
if (*adminUser == "") != (adminPass == "") {
log.Fatal("admin-user and one admin password source must be supplied together")
}
if *adminUser != "" {
if err := cfg.SetAdmin(*adminUser, adminPass); err != nil {
log.Fatalf("set admin: %v", err)
}
fmt.Printf("Admin user %q created.\n", *adminUser)
log.Printf("initial admin user %q configured", *adminUser)
}
dbPath := filepath.Join(absData, "server.db")
@ -43,13 +84,64 @@ func main() {
if err != nil {
log.Fatalf("server: %v", err)
}
defer srv.Close()
srv.SetupRoutes()
if err := srv.CleanupRetention(time.Now().UTC()); err != nil {
_ = srv.Close()
log.Fatalf("retention cleanup: %v", err)
}
addr := cfg.ListenAddress()
listener, err := net.Listen("tcp", addr)
if err != nil {
_ = srv.Close()
log.Fatalf("listen %s: %v", addr, err)
}
httpServer := srv.HTTPServer(addr)
serveDone := make(chan error, 1)
go func() { serveDone <- httpServer.Serve(listener) }()
log.Printf("Verstak Sync Server %s (%s) listening on %s", server.Version, server.BuildCommit, addr)
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)
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
select {
case sig := <-signals:
log.Printf("received %s; shutting down", sig)
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
err = httpServer.Shutdown(shutdownCtx)
cancel()
if err != nil {
log.Printf("graceful shutdown: %v", err)
}
case err = <-serveDone:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
_ = srv.Close()
log.Fatalf("serve: %v", err)
}
}
if err := srv.Close(); err != nil {
log.Printf("close database: %v", err)
}
}
func initialAdminPassword(path string, useStdin bool) (string, error) {
if path != "" && useStdin {
return "", fmt.Errorf("choose either --admin-pass-file or --admin-pass-stdin")
}
if path == "" && !useStdin {
return "", nil
}
var data []byte
var err error
if path != "" {
data, err = os.ReadFile(path)
} else {
data, err = os.ReadFile("/dev/stdin")
}
if err != nil {
return "", err
}
password := strings.TrimSpace(string(data))
if password == "" {
return "", fmt.Errorf("password source is empty")
}
return password, nil
}

View File

@ -1,99 +0,0 @@
# 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.

View File

@ -1,92 +0,0 @@
# 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.

277
internal/server/blobs.go Normal file
View File

@ -0,0 +1,277 @@
package server
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
func validSHA256(value string) bool {
if len(value) != sha256.Size*2 || strings.ToLower(value) != value {
return false
}
_, err := hex.DecodeString(value)
return err == nil
}
func blobPath(root, hash string) string {
return filepath.Join(root, hash[:2], hash[2:4], hash)
}
func (s *Server) handleBlobUpload(w http.ResponseWriter, r *http.Request, scope authenticatedDevice) {
// Reserve a little multipart framing headroom while the file itself is
// measured independently. The stream is never materialized in memory.
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.Limits.MaxBlobBytes+(1<<20))
reader, err := r.MultipartReader()
if err != nil {
jsonErrCode(w, http.StatusBadRequest, "invalid_multipart", "invalid multipart request")
return
}
part, err := reader.NextPart()
if errors.Is(err, io.EOF) {
jsonErrCode(w, http.StatusBadRequest, "invalid_multipart", "file field is required")
return
}
if err != nil {
jsonErrCode(w, http.StatusBadRequest, "invalid_multipart", "invalid multipart request")
return
}
defer part.Close()
if part.FormName() != "file" || part.FileName() == "" {
jsonErrCode(w, http.StatusBadRequest, "invalid_multipart", "exactly one file field is required")
return
}
tmp, err := os.CreateTemp(s.blobsDir, ".upload-*")
if err != nil {
jsonInternalError(w, err)
return
}
tmpName := tmp.Name()
cleanupTmp := true
defer func() {
if cleanupTmp {
_ = os.Remove(tmpName)
}
}()
if err := tmp.Chmod(0640); err != nil {
_ = tmp.Close()
jsonInternalError(w, err)
return
}
hash := sha256.New()
written, err := io.Copy(io.MultiWriter(tmp, hash), io.LimitReader(part, s.cfg.Limits.MaxBlobBytes+1))
if err != nil {
_ = tmp.Close()
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
jsonErrCode(w, http.StatusRequestEntityTooLarge, "blob_too_large", "blob is too large")
} else {
jsonInternalError(w, err)
}
return
}
if written > s.cfg.Limits.MaxBlobBytes {
_ = tmp.Close()
jsonErrCode(w, http.StatusRequestEntityTooLarge, "blob_too_large", "blob is too large")
return
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
jsonInternalError(w, err)
return
}
if err := tmp.Close(); err != nil {
jsonInternalError(w, err)
return
}
// Do not silently accept a second part: this endpoint has one unambiguous
// file contract and no form fields that could hide extra request data.
if extra, err := reader.NextPart(); err != io.EOF {
if extra != nil {
_ = extra.Close()
}
jsonErrCode(w, http.StatusBadRequest, "invalid_multipart", "exactly one file field is required")
return
}
sha := hex.EncodeToString(hash.Sum(nil))
if err := s.attachBlob(scope.UserID, scope.VaultID, sha, written, tmpName); err != nil {
if errors.Is(err, errBlobTooLarge) {
jsonErrCode(w, http.StatusRequestEntityTooLarge, "quota_exceeded", "blob quota exceeded")
return
}
jsonInternalError(w, err)
return
}
jsonOK(w, map[string]interface{}{"sha256": sha, "size": written})
}
var errBlobTooLarge = errors.New("blob quota exceeded")
// attachBlob checks logical quotas before the rename, then atomically makes
// the content reachable and commits the scope reference. A DB error removes a
// newly-created physical file so rejected requests leave no blob behind.
func (s *Server) attachBlob(userID, vaultID, sha string, size int64, tmpName string) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var existingSize int64
err = tx.QueryRow(`SELECT size FROM server_blob_refs WHERE user_id=? AND vault_id=? AND sha256=?`, userID, vaultID, sha).Scan(&existingSize)
if err == nil {
if existingSize != size {
return fmt.Errorf("existing blob reference has a different size")
}
if _, err := tx.Exec(`UPDATE server_blob_refs SET last_accessed=? WHERE user_id=? AND vault_id=? AND sha256=?`, time.Now().UTC().Format(time.RFC3339), userID, vaultID, sha); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
var vaultUsed, userUsed int64
if err := tx.QueryRow(`SELECT COALESCE(SUM(size), 0) FROM server_blob_refs WHERE user_id=? AND vault_id=?`, userID, vaultID).Scan(&vaultUsed); err != nil {
return err
}
if err := tx.QueryRow(`SELECT COALESCE(SUM(size), 0) FROM server_blob_refs WHERE user_id=?`, userID).Scan(&userUsed); err != nil {
return err
}
if vaultUsed+size > s.cfg.Limits.MaxVaultBlobBytes || userUsed+size > s.cfg.Limits.MaxUserBlobBytes {
return errBlobTooLarge
}
destination := blobPath(s.blobsDir, sha)
if err := os.MkdirAll(filepath.Dir(destination), 0750); err != nil {
return err
}
createdPhysical := false
if _, err := os.Stat(destination); errors.Is(err, os.ErrNotExist) {
if err := os.Rename(tmpName, destination); err != nil {
return err
}
createdPhysical = true
} else if err != nil {
return err
}
now := time.Now().UTC().Format(time.RFC3339)
if _, err := tx.Exec(`INSERT OR IGNORE INTO server_blobs (sha256, size, created_at) VALUES (?, ?, ?)`, sha, size, now); err != nil {
if createdPhysical {
_ = os.Remove(destination)
}
return err
}
if _, err := tx.Exec(`INSERT INTO server_blob_refs (user_id, vault_id, sha256, size, created_at, last_accessed) VALUES (?, ?, ?, ?, ?, ?)`, userID, vaultID, sha, size, now, now); err != nil {
if createdPhysical {
_ = os.Remove(destination)
}
return err
}
if err := tx.Commit(); err != nil {
if createdPhysical {
_ = os.Remove(destination)
}
return err
}
return nil
}
func (s *Server) handleBlobDownload(w http.ResponseWriter, r *http.Request, scope authenticatedDevice, sha string) {
if !validSHA256(sha) {
jsonErrCode(w, http.StatusBadRequest, "invalid_blob_hash", "invalid SHA-256")
return
}
var size int64
err := s.db.QueryRow(`SELECT size FROM server_blob_refs WHERE user_id=? AND vault_id=? AND sha256=?`, scope.UserID, scope.VaultID, sha).Scan(&size)
if errors.Is(err, sql.ErrNoRows) {
jsonErrCode(w, http.StatusNotFound, "blob_not_found", "blob not found")
return
}
if err != nil {
jsonInternalError(w, err)
return
}
file, err := os.Open(blobPath(s.blobsDir, sha))
if errors.Is(err, os.ErrNotExist) {
jsonErrCode(w, http.StatusNotFound, "blob_not_found", "blob not found")
return
}
if err != nil {
jsonInternalError(w, err)
return
}
defer file.Close()
info, err := file.Stat()
if err != nil {
jsonInternalError(w, err)
return
}
if !info.Mode().IsRegular() || info.Size() != size {
jsonErrCode(w, http.StatusInternalServerError, "blob_unavailable", "blob is unavailable")
return
}
if _, err := s.db.Exec(`UPDATE server_blob_refs SET last_accessed=? WHERE user_id=? AND vault_id=? AND sha256=?`, time.Now().UTC().Format(time.RFC3339), scope.UserID, scope.VaultID, sha); err != nil {
jsonInternalError(w, err)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
w.Header().Set("Content-Disposition", "attachment; filename=\""+sha+"\"")
// ServeContent streams from the file and provides safe Range support.
http.ServeContent(w, r, sha, info.ModTime(), file)
}
// validateScopedBlobReferences ensures an accepted operation can later be
// restored by every authorized device in this scope. Legacy base64 binary
// payloads are deliberately rejected: binary content belongs in Blob storage,
// never in the operation log.
func validateScopedBlobReferences(tx *sql.Tx, scope authenticatedDevice, ops []syncPushOperation) (code, message string, err error) {
for _, op := range ops {
if op.EntityType != "file" || op.PayloadJSON == "" {
continue
}
var payload struct {
DataBase64 *string `json:"dataBase64"`
Blob *struct {
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
} `json:"blob"`
}
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
return "invalid_payload", "operation payload_json must be valid JSON", nil
}
if payload.DataBase64 != nil {
return "inline_binary_forbidden", "binary file payload must reference a blob", nil
}
if payload.Blob == nil {
continue
}
if !validSHA256(payload.Blob.SHA256) || payload.Blob.Size < 0 {
return "invalid_blob_reference", "invalid blob reference", nil
}
var storedSize int64
err := tx.QueryRow(`SELECT size FROM server_blob_refs WHERE user_id=? AND vault_id=? AND sha256=?`,
scope.UserID, scope.VaultID, payload.Blob.SHA256).Scan(&storedSize)
if errors.Is(err, sql.ErrNoRows) {
return "blob_not_owned", "blob must be uploaded before its operation", nil
}
if err != nil {
return "", "", err
}
if storedSize != payload.Blob.Size {
return "invalid_blob_reference", "blob size does not match uploaded content", nil
}
}
return "", "", nil
}

View File

@ -2,8 +2,10 @@ package server
import (
"fmt"
"net/netip"
"os"
"path/filepath"
"strings"
"sync"
"golang.org/x/crypto/bcrypt"
@ -16,28 +18,194 @@ type AdminUser struct {
}
type Config struct {
Port int `yaml:"port"`
Admin []AdminUser `yaml:"admin"`
mu sync.Mutex
path string
// Port remains readable for older config.yml files. New deployments use
// Listen so an administrator must deliberately expose a non-loopback
// listener.
Port int `yaml:"port,omitempty"`
Listen string `yaml:"listen,omitempty"`
TrustedProxies []string `yaml:"trusted_proxies,omitempty"`
PublicURL string `yaml:"public_url,omitempty"`
DevelopmentTokenLogging bool `yaml:"development_token_logging,omitempty"`
Web WebConfig `yaml:"web,omitempty"`
Limits Limits `yaml:"limits,omitempty"`
Retention Retention `yaml:"retention,omitempty"`
Admin []AdminUser `yaml:"admin"`
mu sync.Mutex
path string
trustedProxyPrefixes []netip.Prefix
}
// WebConfig intentionally contains only presentation policy. It never
// duplicates transport limits or security configuration owned by the server.
type WebConfig struct {
DefaultLocale string `yaml:"default_locale,omitempty"`
AllowRegistration bool `yaml:"allow_registration,omitempty"`
ServerName string `yaml:"server_name,omitempty"`
}
// Retention controls data that has no role in reconstructing a vault. Sync
// operations and referenced blobs are deliberately absent: pruning either
// requires a checkpoint protocol or risks making a new device unrecoverable.
type Retention struct {
IdempotencyHours int `yaml:"idempotency_hours"`
AuditDays int `yaml:"audit_days"`
TempUploadHours int `yaml:"temp_upload_hours"`
}
// Limits protect the process and operation log independently of a client
// supplied value. They are deliberately conservative defaults for a
// self-hosted service and can be adjusted in config.yml.
type Limits struct {
MaxJSONBody int64 `yaml:"max_json_body"`
MaxPushOperations int `yaml:"max_push_operations"`
MaxPayloadJSON int `yaml:"max_payload_json"`
MaxPullPage int `yaml:"max_pull_page"`
MaxBlobBytes int64 `yaml:"max_blob_bytes"`
MaxVaultBlobBytes int64 `yaml:"max_vault_blob_bytes"`
MaxUserBlobBytes int64 `yaml:"max_user_blob_bytes"`
}
func defaultLimits() Limits {
return Limits{
MaxJSONBody: 2 << 20,
MaxPushOperations: 100,
MaxPayloadJSON: 256 << 10,
MaxPullPage: 100,
MaxBlobBytes: 256 << 20,
MaxVaultBlobBytes: 4 << 30,
MaxUserBlobBytes: 8 << 30,
}
}
// DefaultConfig is the safe baseline used by a new server and tests.
func DefaultConfig() *Config {
return &Config{
Port: 47732,
Listen: "127.0.0.1:47732",
Web: WebConfig{DefaultLocale: "en", AllowRegistration: true, ServerName: "Verstak Sync Server"},
Limits: defaultLimits(),
Retention: Retention{IdempotencyHours: 24, AuditDays: 90, TempUploadHours: 24},
}
}
func LoadConfig(dataDir string) (*Config, error) {
path := filepath.Join(dataDir, "config.yml")
cfg := &Config{
Port: 47732,
Admin: nil,
path: path,
}
cfg := DefaultConfig()
cfg.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)
}
}
if err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("read config: %w", err)
}
if err := cfg.normalize(); err != nil {
return nil, err
}
return cfg, nil
}
// ListenAddress returns a loopback address unless an administrator explicitly
// configured another address. A legacy port-only config is also loopback.
func (c *Config) ListenAddress() string {
if c == nil {
return "127.0.0.1:47732"
}
if listen := strings.TrimSpace(c.Listen); listen != "" {
return listen
}
port := c.Port
if port == 0 {
port = 47732
}
return fmt.Sprintf("127.0.0.1:%d", port)
}
func (c *Config) normalize() error {
if c.Port == 0 {
c.Port = 47732
}
if strings.TrimSpace(c.Listen) == "" {
c.Listen = fmt.Sprintf("127.0.0.1:%d", c.Port)
}
defaults := defaultLimits()
if c.Limits.MaxJSONBody <= 0 {
c.Limits.MaxJSONBody = defaults.MaxJSONBody
}
if c.Limits.MaxPushOperations <= 0 {
c.Limits.MaxPushOperations = defaults.MaxPushOperations
}
if c.Limits.MaxPayloadJSON <= 0 {
c.Limits.MaxPayloadJSON = defaults.MaxPayloadJSON
}
if c.Limits.MaxPullPage <= 0 {
c.Limits.MaxPullPage = defaults.MaxPullPage
}
if c.Limits.MaxBlobBytes <= 0 {
c.Limits.MaxBlobBytes = defaults.MaxBlobBytes
}
if c.Limits.MaxVaultBlobBytes <= 0 {
c.Limits.MaxVaultBlobBytes = defaults.MaxVaultBlobBytes
}
if c.Limits.MaxUserBlobBytes <= 0 {
c.Limits.MaxUserBlobBytes = defaults.MaxUserBlobBytes
}
if c.Retention.IdempotencyHours <= 0 {
c.Retention.IdempotencyHours = 24
}
if c.Retention.AuditDays <= 0 {
c.Retention.AuditDays = 90
}
if c.Retention.TempUploadHours <= 0 {
c.Retention.TempUploadHours = 24
}
if !isSupportedWebLocale(c.Web.DefaultLocale) {
c.Web.DefaultLocale = "en"
}
if strings.TrimSpace(c.Web.ServerName) == "" {
c.Web.ServerName = "Verstak Sync Server"
}
prefixes := make([]netip.Prefix, 0, len(c.TrustedProxies))
for _, raw := range c.TrustedProxies {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
prefix, err := netip.ParsePrefix(raw)
if err != nil {
addr, addrErr := netip.ParseAddr(raw)
if addrErr != nil {
return fmt.Errorf("trusted proxy %q is neither an IP address nor CIDR: %w", raw, err)
}
bits := 32
if addr.Is6() {
bits = 128
}
prefix = netip.PrefixFrom(addr, bits)
}
prefixes = append(prefixes, prefix.Masked())
}
c.trustedProxyPrefixes = prefixes
return nil
}
// Normalize applies safe defaults and validates proxy configuration after
// command-line and environment overrides.
func (c *Config) Normalize() error {
return c.normalize()
}
func (c *Config) isTrustedProxy(addr netip.Addr) bool {
for _, prefix := range c.trustedProxyPrefixes {
if prefix.Contains(addr) {
return true
}
}
return false
}
func (c *Config) Save() error {
c.mu.Lock()
defer c.mu.Unlock()
@ -80,5 +248,29 @@ func (c *Config) saveLocked() error {
if err != nil {
return err
}
return os.WriteFile(c.path, data, 0640)
if err := os.MkdirAll(filepath.Dir(c.path), 0750); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(c.path), ".config-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if err := tmp.Chmod(0640); err != nil {
_ = tmp.Close()
return err
}
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, c.path)
}

76
internal/server/csrf.go Normal file
View File

@ -0,0 +1,76 @@
package server
import (
"crypto/subtle"
"net/http"
"net/url"
"strings"
)
func (s *Server) requireAdminMutation(w http.ResponseWriter, r *http.Request) bool {
session, ok := s.requireSession(w, r, sessionScopeAdmin)
if !ok {
return false
}
return s.verifyCSRF(w, r, session)
}
func (s *Server) requireUserMutation(w http.ResponseWriter, r *http.Request) bool {
session, ok := s.requireSession(w, r, sessionScopeUser)
if !ok {
return false
}
return s.verifyCSRF(w, r, session)
}
func (s *Server) verifyCSRF(w http.ResponseWriter, r *http.Request, session webSession) bool {
cookie, err := r.Cookie("csrf_token")
if err != nil || cookie.Value == "" {
jsonErrCode(w, http.StatusForbidden, "csrf_invalid", "CSRF token is required")
return false
}
candidate := r.Header.Get("X-CSRF-Token")
if candidate == "" {
// Form parsing is only needed for regular HTML form posts. JSON callers
// must send the header, avoiding any interference with their decoder.
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") || strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
if err := r.ParseForm(); err != nil {
jsonErrCode(w, http.StatusBadRequest, "invalid_request", "invalid form")
return false
}
candidate = r.FormValue("csrf_token")
}
}
if candidate == "" || subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(candidate)) != 1 || subtle.ConstantTimeCompare([]byte(sha256Hex(candidate)), []byte(session.CSRFHash)) != 1 {
jsonErrCode(w, http.StatusForbidden, "csrf_invalid", "CSRF token is invalid")
return false
}
if !s.sameOrigin(r) {
jsonErrCode(w, http.StatusForbidden, "csrf_invalid", "request origin is not allowed")
return false
}
return true
}
func (s *Server) sameOrigin(r *http.Request) bool {
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin == "" {
// Older same-origin form submissions can omit Origin. The CSRF token is
// still mandatory, so accepting this remains protected.
return true
}
originURL, err := url.Parse(origin)
if err != nil || originURL.Host == "" {
return false
}
expected := strings.TrimSpace(s.cfg.PublicURL)
if expected != "" {
expectedURL, err := url.Parse(expected)
return err == nil && strings.EqualFold(originURL.Scheme, expectedURL.Scheme) && strings.EqualFold(originURL.Host, expectedURL.Host)
}
scheme := "http"
if s.requestIsHTTPS(r) {
scheme = "https"
}
return strings.EqualFold(originURL.Scheme, scheme) && strings.EqualFold(originURL.Host, r.Host)
}

View File

@ -0,0 +1,41 @@
package server
import (
"fmt"
)
func validatePairRequest(login, deviceName, clientVersion, vaultID string) error {
for _, field := range []struct {
name string
value string
max int
}{
{"login", login, maxLoginLength},
{"device name", deviceName, maxDeviceNameLength},
{"client version", clientVersion, maxClientVersionLength},
{"vault id", vaultID, maxVaultIDLength},
} {
if err := validateStringLength(field.name, field.value, field.max); err != nil {
return err
}
}
if vaultID == "" {
return fmt.Errorf("vault_id required")
}
return nil
}
func (s *Server) revokeDevice(deviceID, when string) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec("UPDATE server_devices SET revoked_at=? WHERE id=?", when, deviceID); err != nil {
return err
}
if _, err := tx.Exec("DELETE FROM server_sessions WHERE subject_id=? AND scope='device'", deviceID); err != nil {
return err
}
return tx.Commit()
}

View File

@ -0,0 +1,26 @@
package server
import (
"database/sql"
"time"
)
type sqlExecutor interface {
Exec(query string, args ...interface{}) (sql.Result, error)
}
func issueEmailToken(exec sqlExecutor, userID, purpose string, lifetime time.Duration) (string, error) {
token, err := randomSecret(24)
if err != nil {
return "", err
}
now := time.Now().UTC()
_, err = exec.Exec(`INSERT INTO server_email_tokens (token_hash, user_id, purpose, expires_at, created_at)
VALUES (?, ?, ?, ?, ?)`, sha256Hex(token), userID, purpose, now.Add(lifetime).Format(time.RFC3339), now.Format(time.RFC3339))
if err != nil {
return "", err
}
return token, nil
}
func emailTokenHash(token string) string { return sha256Hex(token) }

View File

@ -5,7 +5,6 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"html"
"log"
"net/http"
"strings"
@ -17,180 +16,63 @@ import (
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>`))
s.renderPage(w, r, "admin_login", webPage{Title: "admin.loginTitle", Admin: true})
case "POST":
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", 400)
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/admin/login")
return
}
if !s.requirePublicWebMutation(w, r, "/admin/login") {
return
}
user := r.FormValue("username")
pass := r.FormValue("password")
if !s.cfg.CheckAdmin(user, pass) {
http.Error(w, "401 Unauthorized", 401)
if !s.allowWebRate(w, r, "login", user, "/admin/login") {
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)
if !s.cfg.CheckAdmin(user, pass) {
s.renderPageStatus(w, r, "admin_login", webPage{Title: "admin.loginTitle", Admin: true, Flash: "error.invalidCredentials"}, http.StatusUnauthorized)
return
}
tok, csrf, err := s.createSession(sessionScopeAdmin, user)
if err != nil {
jsonInternalError(w, err)
return
}
s.setSessionCookies(w, r, sessionScopeAdmin, tok, csrf)
http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther)
default:
http.Error(w, "method not allowed", 405)
methodNotAllowed(w, http.MethodGet, http.MethodPost)
}
}
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), "\"")
_, ok := s.requireSession(w, r, sessionScopeAdmin)
return ok
}
func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
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})
stats, err := s.Stats(r.Context())
if err != nil {
jsonInternalError(w, err)
return
}
jsonOK(w, stats)
}
func (s *Server) handleAdminSMTPTest(w http.ResponseWriter, r *http.Request) {
if !s.requireAdminCookie(w, r) {
if r.Method != "POST" {
methodNotAllowed(w, "POST")
return
}
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
if !s.requireAdminMutation(w, r) {
return
}
var req struct {
@ -229,6 +111,10 @@ func (s *Server) handleAdminSMTPTest(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleAdminAPIDevices(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
if !s.requireAdminCookie(w, r) {
return
}
@ -267,7 +153,7 @@ func (s *Server) handleAdminAPIKeys(w http.ResponseWriter, r *http.Request) {
}
switch r.Method {
case "GET":
rows, err := s.db.Query("SELECT id, name, api_key FROM server_devices ORDER BY created_at")
rows, err := s.db.Query("SELECT id, name, COALESCE(token_prefix,''), COALESCE(token_suffix,'') FROM server_devices ORDER BY created_at")
if err != nil {
jsonInternalError(w, err)
return
@ -275,9 +161,12 @@ func (s *Server) handleAdminAPIKeys(w http.ResponseWriter, r *http.Request) {
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})
var id, name, prefix, suffix string
if err := rows.Scan(&id, &name, &prefix, &suffix); err != nil {
jsonInternalError(w, err)
return
}
out = append(out, map[string]string{"id": id, "name": name, "token_hint": prefix + "…" + suffix})
}
jsonOK(w, out)
default:
@ -286,11 +175,11 @@ func (s *Server) handleAdminAPIKeys(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleAdminAPISmtp(w http.ResponseWriter, r *http.Request) {
if !s.requireAdminCookie(w, r) {
if r.Method != "POST" {
methodNotAllowed(w, "POST")
return
}
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
if !s.requireAdminMutation(w, r) {
return
}
if err := r.ParseForm(); err != nil {
@ -307,24 +196,40 @@ func (s *Server) handleAdminAPISmtp(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleAdminAPIKeysDelete(w http.ResponseWriter, r *http.Request) {
if !s.requireAdminCookie(w, r) {
if r.Method != "DELETE" {
methodNotAllowed(w, "DELETE")
return
}
if r.Method != "DELETE" {
jsonErr(w, 405, "DELETE required")
if !s.requireAdminMutation(w, r) {
return
}
id := strings.TrimPrefix(r.URL.Path, "/admin/api/keys/")
_, err := s.db.Exec("DELETE FROM server_devices WHERE id=?", id)
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
s.db.Exec("DELETE FROM server_user_devices WHERE device_id=?", id)
defer tx.Rollback()
if _, err := tx.Exec("DELETE FROM server_user_devices WHERE device_id=?", id); err != nil {
jsonInternalError(w, err)
return
}
if _, err := tx.Exec("DELETE FROM server_devices WHERE id=?", id); err != nil {
jsonInternalError(w, err)
return
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
jsonOK(w, map[string]string{"status": "deleted"})
}
func (s *Server) handleAdminAPIUsers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
if !s.requireAdminCookie(w, r) {
return
}
@ -410,7 +315,11 @@ func (s *Server) handleAdminAPIUsers(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleAdminAPIUserActions(w http.ResponseWriter, r *http.Request) {
if !s.requireAdminCookie(w, r) {
if r.Method != "POST" && r.Method != "DELETE" {
methodNotAllowed(w, "POST", "DELETE")
return
}
if !s.requireAdminMutation(w, r) {
return
}
path := strings.TrimPrefix(r.URL.Path, "/admin/api/users/")
@ -419,23 +328,52 @@ func (s *Server) handleAdminAPIUserActions(w http.ResponseWriter, r *http.Reques
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)
if err := s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", id).Scan(&blocked); err != nil {
jsonErr(w, http.StatusNotFound, "user not found")
return
}
newVal := 1
if blocked != 0 {
newVal = 0
}
s.db.Exec("UPDATE server_users SET blocked=? WHERE id=?", newVal, id)
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
defer tx.Rollback()
if _, err := tx.Exec("UPDATE server_users SET blocked=? WHERE id=?", newVal, id); err != nil {
jsonInternalError(w, err)
return
}
if newVal != 0 {
if _, err := tx.Exec("DELETE FROM server_sessions WHERE scope='user' AND subject_id=?", id); err != nil {
jsonInternalError(w, err)
return
}
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
jsonOK(w, map[string]interface{}{"status": "ok", "blocked": newVal})
return
}
if strings.HasSuffix(path, "/reset-password") && r.Method == "POST" {
if !s.allowRate(w, r, "admin-reset", "") {
return
}
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)
hash, err := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost)
if err != nil {
jsonInternalError(w, err)
return
}
_, err = s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), id)
if err != nil {
jsonInternalError(w, err)
return
@ -450,8 +388,7 @@ func (s *Server) handleAdminAPIUserActions(w http.ResponseWriter, r *http.Reques
Username string `json:"username"`
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&editReq); err != nil {
jsonErr(w, 400, "bad json")
if !decodeJSONBody(w, r, &editReq, s.cfg.Limits.MaxJSONBody) {
return
}
if editReq.Username == "" || editReq.Email == "" {
@ -468,94 +405,46 @@ func (s *Server) handleAdminAPIUserActions(w http.ResponseWriter, r *http.Reques
}
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)
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
rows.Close()
for _, did := range deviceIDs {
s.db.Exec("DELETE FROM server_devices WHERE id=?", did)
defer tx.Rollback()
for _, statement := range []string{
"DELETE FROM server_sessions WHERE subject_id=? AND scope='user'",
"DELETE FROM server_email_tokens WHERE user_id=?",
"DELETE FROM server_blob_refs WHERE user_id=?",
"DELETE FROM server_idempotency_keys WHERE user_id=?",
"DELETE FROM server_tombstones WHERE user_id=?",
"DELETE FROM server_revisions WHERE op_id IN (SELECT op_id FROM server_ops WHERE user_id=?)",
"DELETE FROM server_ops WHERE user_id=?",
"DELETE FROM server_user_devices WHERE user_id=?",
"DELETE FROM server_devices WHERE user_id=?",
"DELETE FROM server_audit_log WHERE user_id=?",
"DELETE FROM server_users WHERE id=?",
} {
if _, err := tx.Exec(statement, id); err != nil {
jsonInternalError(w, err)
return
}
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
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) {
if r.Method != "POST" {
methodNotAllowed(w, "POST")
return
}
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
if !s.requireAdminMutation(w, r) {
return
}
var req struct {

View File

@ -2,12 +2,11 @@ package server
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"io"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"time"
@ -15,20 +14,32 @@ import (
)
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"))
if !strings.HasPrefix(r.URL.Path, "/api/") {
s.renderPageStatus(w, r, "error", webPage{Title: "error.notFound", Heading: "error.notFound", Message: "error.notFound", BackURL: "/"}, http.StatusNotFound)
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),
})
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
health := s.healthStatus(r.Context())
if health.Status != "ok" {
jsonOKStatus(w, http.StatusServiceUnavailable, health)
return
}
jsonOK(w, health)
}
func (s *Server) handleLiveness(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
jsonOK(w, map[string]interface{}{"status": "ok", "server_time": time.Now().UTC().Format(time.RFC3339)})
}
func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
@ -36,15 +47,7 @@ func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
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
}
ip := s.clientIP(r)
var req struct {
Login string `json:"login"`
Password string `json:"password"`
@ -52,14 +55,16 @@ func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
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")
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
return
}
if req.Login == "" || req.Password == "" {
jsonErr(w, 400, "login and password required")
return
}
if !s.allowRate(w, r, "pair", req.Login) {
return
}
req.VaultID = strings.TrimSpace(req.VaultID)
if req.VaultID == "" {
jsonErr(w, 400, "vault_id required")
@ -72,12 +77,16 @@ func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
if req.DeviceName == "" {
req.DeviceName = "unknown"
}
if err := validatePairRequest(req.Login, req.DeviceName, req.ClientVersion, req.VaultID); err != nil {
jsonErrCode(w, http.StatusBadRequest, "invalid_request", err.Error())
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.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)
s.auditLog("device_auth_failed", "", "", ip, "pair: user not found")
jsonErr(w, 401, "invalid credentials")
return
}
@ -102,20 +111,33 @@ func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
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,
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
defer tx.Rollback()
_, err = tx.Exec(`INSERT INTO server_devices
(id, name, api_key, token_hash, token_prefix, token_suffix, legacy_api_key, user_id, vault_id, client_version, last_ip, last_seen, created_at)
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)`,
deviceID, req.DeviceName, "disabled:"+deviceID, 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)
if _, err := tx.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID); err != nil {
jsonInternalError(w, err)
return
}
if _, err := tx.Exec("UPDATE server_users SET last_seen=? WHERE id=?", now, userID); err != nil {
jsonInternalError(w, err)
return
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
s.auditLog("device_paired", userID, deviceID, ip, "device paired: "+req.DeviceName)
jsonOK(w, map[string]interface{}{
"user_id": userID,
@ -135,14 +157,16 @@ func (s *Server) handleAuthTest(w http.ResponseWriter, r *http.Request) {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "bad json")
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
return
}
if req.Username == "" || req.Password == "" {
jsonErr(w, 400, "username and password required")
return
}
if !s.allowRate(w, r, "auth-test", req.Username) {
return
}
var hash string
var confirmed, blocked int
err := s.db.QueryRow("SELECT password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
@ -171,21 +195,16 @@ func (s *Server) handleClientRevoke(w http.ResponseWriter, r *http.Request) {
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")
device, ok := s.authenticateDevice(w, r)
if !ok {
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")
if err := s.revokeDevice(device.DeviceID, now); err != nil {
jsonInternalError(w, err)
return
}
s.auditLog("device_revoked", device.UserID, device.DeviceID, s.clientIP(r), "device revoked by user")
jsonOK(w, map[string]string{"status": "revoked"})
}
@ -194,32 +213,27 @@ func (s *Server) handleClientRevokeDevice(w http.ResponseWriter, r *http.Request
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")
device, ok := s.authenticateDevice(w, r)
if !ok || device.UserID == "" {
return
}
curUserID := device.UserID
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")
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
return
}
if req.DeviceID == "" || req.Password == "" {
jsonErr(w, 400, "device_id and password required")
return
}
if !s.allowRate(w, r, "auth-test", curUserID) {
return
}
var pwHash string
err = s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", curUserID).Scan(&pwHash)
err := s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", curUserID).Scan(&pwHash)
if err != nil {
jsonErr(w, 403, "access denied")
return
@ -239,21 +253,26 @@ func (s *Server) handleClientRevokeDevice(w http.ResponseWriter, r *http.Request
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")
if err := s.revokeDevice(req.DeviceID, now); err != nil {
jsonInternalError(w, err)
return
}
s.auditLog("device_revoked", curUserID, req.DeviceID, s.clientIP(r), "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")
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
device, ok := s.authenticateDevice(w, r)
if !ok {
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).
FROM server_devices d WHERE d.id=?`, device.DeviceID).
Scan(&deviceID, &userID, &name, &clientVer, &lastSeen, &revokedAt, &createdAt)
if err != nil {
jsonErr(w, 401, "invalid token")
@ -284,8 +303,7 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
Password string `json:"password"`
VaultID string `json:"vault_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
return
}
if req.Name == "" {
@ -296,6 +314,13 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 401, "username and password required")
return
}
if !s.allowRate(w, r, "device-register", req.Username) {
return
}
if err := validatePairRequest(req.Username, req.Name, "", req.VaultID); err != nil {
jsonErrCode(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
req.VaultID = strings.TrimSpace(req.VaultID)
if req.VaultID == "" {
jsonErr(w, 400, "vault_id required")
@ -325,61 +350,86 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 401, "invalid credentials")
return
}
b := make([]byte, 20)
b := make([]byte, 12)
rand.Read(b)
apiKey := hex.EncodeToString(b)
deviceID := apiKey[:12]
deviceID := "dev_" + hex.EncodeToString(b)
token, prefix, suffix := genDeviceToken()
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,
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
defer tx.Rollback()
_, err = tx.Exec(
"INSERT INTO server_devices (id, name, api_key, token_hash, token_prefix, token_suffix, legacy_api_key, user_id, vault_id, last_seen, created_at) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)",
deviceID, req.Name, "disabled:"+deviceID, sha256Hex(token), prefix, suffix, 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)
if _, err := tx.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID); err != nil {
jsonInternalError(w, err)
return
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
jsonOK(w, map[string]interface{}{
"device_id": deviceID,
"api_key": apiKey,
"device_id": deviceID,
"device_token": token,
})
}
func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
methodNotAllowed(w, "POST")
return
}
scope, ok := s.requireSyncScope(w, r)
if !ok {
return
}
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
var req syncPushRequest
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
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 code, message := s.validateSyncPush(req); code != "" {
status := http.StatusBadRequest
if code == "too_many_operations" || code == "payload_too_large" {
status = http.StatusRequestEntityTooLarge
}
jsonErrCode(w, status, code, message)
return
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
defer tx.Rollback()
if code, message, err := validateScopedBlobReferences(tx, scope, req.Ops); err != nil {
jsonInternalError(w, err)
return
} else if code != "" {
jsonErrCode(w, http.StatusBadRequest, code, message)
return
}
if req.IdempotencyKey != "" {
var cachedJSON string
err := s.db.QueryRow(`SELECT response_json FROM server_idempotency_keys
err := tx.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))
_, _ = w.Write([]byte(cachedJSON))
return
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
jsonInternalError(w, err)
return
}
}
@ -387,55 +437,82 @@ func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
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(`
conflictRows, err := tx.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,
})
if err != nil {
jsonInternalError(w, err)
return
}
for conflictRows.Next() {
var cOpID, cDevID, cOpType string
var cSeq int
if err := conflictRows.Scan(&cOpID, &cDevID, &cOpType, &cSeq); err != nil {
_ = conflictRows.Close()
jsonInternalError(w, err)
return
}
conflictRows.Close()
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,
})
}
if err := conflictRows.Err(); err != nil {
_ = conflictRows.Close()
jsonInternalError(w, err)
return
}
if err := conflictRows.Close(); err != nil {
jsonInternalError(w, err)
return
}
}
res, err := s.db.Exec(
res, err := tx.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
jsonInternalError(w, err)
return
}
n, err := res.RowsAffected()
if err != nil {
jsonInternalError(w, err)
return
}
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)
seqRes, err := tx.Exec("INSERT INTO server_revisions (op_id, device_id) VALUES (?, ?)", op.OpID, scope.DeviceID)
if err != nil {
continue
jsonInternalError(w, err)
return
}
seq, err := seqRes.LastInsertId()
if err != nil {
jsonInternalError(w, err)
return
}
if _, err := tx.Exec("UPDATE server_ops SET server_sequence=? WHERE op_id=?", seq, op.OpID); err != nil {
jsonInternalError(w, err)
return
}
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
if _, err := tx.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)
scope.UserID, scope.VaultID, op.EntityType, op.EntityID, op.OpID, now); err != nil {
jsonInternalError(w, err)
return
}
}
accepted = append(accepted, op.OpID)
}
@ -445,39 +522,54 @@ func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
"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)
respJSON, err := json.Marshal(resp)
if err != nil {
jsonInternalError(w, err)
return
}
if _, err := tx.Exec(`INSERT INTO server_idempotency_keys
(user_id, vault_id, idempotency_key, response_json, created_at) VALUES (?, ?, ?, ?, ?)`,
scope.UserID, scope.VaultID, req.IdempotencyKey, string(respJSON), now); err != nil {
jsonInternalError(w, err)
return
}
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
jsonOK(w, resp)
}
func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
methodNotAllowed(w, "POST")
return
}
scope, ok := s.requireSyncScope(w, r)
if !ok {
return
}
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
var req syncPullRequest
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
return
}
var req struct {
SinceSequence int `json:"since_sequence"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
if req.SinceSequence < 0 || req.PageLimit < 0 {
jsonErrCode(w, http.StatusBadRequest, "invalid_request", "sequence and page_limit must be non-negative")
return
}
pageLimit := s.pullPageLimit(req.PageLimit)
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)
if err := s.db.QueryRow(`SELECT COALESCE(MAX(server_sequence), 0) FROM server_ops
WHERE user_id=? AND vault_id=?`, scope.UserID, scope.VaultID).Scan(&serverSeq); err != nil {
jsonInternalError(w, err)
return
}
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)
ORDER BY server_sequence LIMIT ?`, scope.UserID, scope.VaultID, req.SinceSequence, pageLimit+1)
if err != nil {
jsonInternalError(w, err)
return
@ -493,81 +585,47 @@ func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
PayloadJSON string `json:"payload_json"`
CreatedAt string `json:"created_at"`
}
ops := []opDTO{}
ops := make([]opDTO, 0, pageLimit)
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
jsonInternalError(w, err)
return
}
ops = append(ops, o)
}
if err := rows.Err(); err != nil {
jsonInternalError(w, err)
return
}
hasMore := len(ops) > pageLimit
if hasMore {
ops = ops[:pageLimit]
}
pageLastSequence := req.SinceSequence
if len(ops) > 0 {
pageLastSequence = ops[len(ops)-1].ServerSequence
}
jsonOK(w, map[string]interface{}{
"server_sequence": serverSeq,
"ops": ops,
"server_sequence": serverSeq,
"page_last_sequence": pageLastSequence,
"has_more": hasMore,
"ops": ops,
})
}
func (s *Server) handleBlobs(w http.ResponseWriter, r *http.Request) {
_, _, ok := s.requireAuth(w, r)
scope, ok := s.requireSyncScope(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),
})
s.handleBlobUpload(w, r, scope)
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)
s.handleBlobDownload(w, r, scope, shaHex)
default:
jsonErr(w, 405, "method not allowed")
methodNotAllowed(w, "GET", "POST")
}
}

View File

@ -3,7 +3,6 @@ package server
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"log"
"net/http"
"strings"
@ -13,8 +12,8 @@ import (
)
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
if r.Method != http.MethodPost {
methodNotAllowed(w, http.MethodPost)
return
}
var req struct {
@ -22,14 +21,16 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
return
}
if req.Username == "" || req.Email == "" || req.Password == "" {
jsonErr(w, 400, "username, email and password required")
return
}
if !s.allowRate(w, r, "register", req.Email) {
return
}
if err := validatePassword(req.Password); err != "" {
jsonErr(w, 400, err)
return
@ -47,7 +48,13 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
id := make([]byte, 12)
rand.Read(id)
userID := hex.EncodeToString(id)
_, err = s.db.Exec(
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
defer tx.Rollback()
_, err = tx.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,
)
@ -59,61 +66,123 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
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)
if _, err := issueEmailToken(tx, userID, "confirm", 48*time.Hour); err != nil {
jsonInternalError(w, err)
return
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
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")
if r.Method == http.MethodGet {
tokenStr := r.URL.Query().Get("token")
if tokenStr == "" {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/login")
return
}
w.Header().Set("Cache-Control", "no-store")
s.renderPage(w, r, "confirm", webPage{Title: "confirm.title", Token: tokenStr})
return
}
if r.Method != http.MethodPost {
methodNotAllowed(w, http.MethodGet, http.MethodPost)
return
}
tokenStr := ""
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
var req struct {
Token string `json:"token"`
}
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
return
}
tokenStr = req.Token
} else if err := r.ParseForm(); err == nil {
if !s.requirePublicWebMutation(w, r, "/login") {
return
}
tokenStr = r.FormValue("token")
} else {
jsonErrCode(w, http.StatusBadRequest, "invalid_request", "invalid form")
return
}
tokenStr := r.URL.Query().Get("token")
if tokenStr == "" {
jsonErr(w, 400, "token required")
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
jsonErr(w, 400, "token required")
} else {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/login")
}
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)
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token_hash=? AND purpose='confirm'",
emailTokenHash(tokenStr)).Scan(&userID, &expiresAt)
if err != nil {
jsonErr(w, 400, "invalid or expired token")
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
jsonErr(w, 400, "invalid or expired token")
} else {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/login")
}
return
}
exp, err := time.Parse(time.RFC3339, expiresAt)
if err != nil || time.Now().After(exp) {
jsonErr(w, 400, "token expired")
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
jsonErr(w, 400, "token expired")
} else {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/login")
}
return
}
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
defer tx.Rollback()
if _, err := tx.Exec("UPDATE server_users SET confirmed=1 WHERE id=?", userID); err != nil {
jsonInternalError(w, err)
return
}
if _, err := tx.Exec("DELETE FROM server_email_tokens WHERE token_hash=?", emailTokenHash(tokenStr)); err != nil {
jsonInternalError(w, err)
return
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
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"})
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
jsonOK(w, map[string]string{"status": "confirmed"})
} else {
http.Redirect(w, r, "/confirm/result", http.StatusSeeOther)
}
}
func (s *Server) handleUserLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
if r.Method != http.MethodPost {
methodNotAllowed(w, http.MethodPost)
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")
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
return
}
if req.Username == "" || req.Password == "" {
jsonErr(w, 400, "username and password required")
return
}
if !s.allowRate(w, r, "login", req.Username) {
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=?",
@ -134,61 +203,68 @@ func (s *Server) handleUserLogin(w http.ResponseWriter, r *http.Request) {
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)
if _, err := s.db.Exec("UPDATE server_users SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), userID); err != nil {
jsonInternalError(w, err)
return
}
tok, _, err := s.createSession(sessionScopeUser, userID)
if err != nil {
jsonInternalError(w, err)
return
}
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")
if r.Method != http.MethodPost {
methodNotAllowed(w, http.MethodPost)
return
}
var req struct {
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
return
}
if req.Email == "" {
jsonErr(w, 400, "email required")
return
}
if !s.allowRate(w, r, "forgot", req.Email) {
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)
if _, err := issueEmailToken(s.db, userID, "reset", time.Hour); err != nil {
jsonInternalError(w, err)
return
}
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")
if r.Method != http.MethodPost {
methodNotAllowed(w, http.MethodPost)
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")
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
return
}
if req.Token == "" || req.NewPassword == "" {
jsonErr(w, 400, "token and new_password required")
return
}
if !s.allowRate(w, r, "reset", "") {
return
}
if err := validatePassword(req.NewPassword); err != "" {
jsonErr(w, 400, err)
return

View File

@ -4,11 +4,8 @@ import (
"crypto/rand"
"encoding/hex"
"fmt"
"html"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@ -16,361 +13,417 @@ import (
)
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)
session, ok := s.requireSession(w, r, sessionScopeUser)
if !ok {
http.Redirect(w, r, "/login", http.StatusFound)
return "", false
}
return userID, true
return session.SubjectID, true
}
func (s *Server) renderWebError(w http.ResponseWriter, r *http.Request, status int, message, back string) {
s.renderPageStatus(w, r, "error", webPage{Title: "error.label", Heading: "error.badRequest", Message: message, BackURL: back}, status)
}
func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) {
locale := s.locale()
if !s.cfg.Web.AllowRegistration {
s.renderWebError(w, r, http.StatusNotFound, "error.registrationDisabled", "/login")
return
}
switch r.Method {
case "GET":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(userRegisterHTML(locale)))
case "POST":
case http.MethodGet:
s.renderPage(w, r, "register", webPage{Title: "auth.registerTitle"})
case http.MethodPost:
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")))
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/register")
return
}
username := r.FormValue("username")
email := r.FormValue("email")
password := r.FormValue("password")
if !s.requirePublicWebMutation(w, r, "/register") {
return
}
username, email, password := strings.TrimSpace(r.FormValue("username")), strings.TrimSpace(r.FormValue("email")), 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")))
s.renderPage(w, r, "register", webPage{Title: "auth.registerTitle", Flash: "error.allFieldsRequired"})
return
}
if !s.allowWebRate(w, r, "register", email, "/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")))
s.renderPage(w, r, "register", webPage{Title: "auth.registerTitle", Flash: "error.passwordInvalid"})
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")))
log.Printf("web register: password hashing: %v", err)
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/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")))
}
if _, err := rand.Read(id); err != nil {
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/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)
userID := hex.EncodeToString(id)
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 (?, ?, ?, ?, 0, ?)", userID, username, strings.ToLower(email), string(hash), now); err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
s.renderPage(w, r, "register", webPage{Title: "auth.registerTitle", Flash: "error.accountTaken"})
return
}
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)
log.Printf("web register: create user: %v", err)
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/register")
return
}
token, err := issueEmailToken(s.db, userID, "confirm", 48*time.Hour)
if err != nil {
log.Printf("web register: issue confirmation token: %v", err)
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/register")
return
}
if host := s.smtpGet("smtp_host"); host != "" {
base := s.smtpGet("server_url")
if base == "" {
base = "http://" + r.Host
}
} else {
log.Printf("register web: SMTP not configured, confirmation token=%s for user %s", tokenStr, username)
confirmURL := fmt.Sprintf("%s/api/v1/auth/confirm?token=%s", strings.TrimRight(base, "/"), token)
if err := s.smtpSend(email, t(s.webLocale(r), "server.emailConfirmSubject"), fmt.Sprintf(t(s.webLocale(r), "server.emailConfirmBody"), confirmURL)); err != nil {
log.Printf("web register: confirmation mail: %v", err)
}
} else if s.cfg.DevelopmentTokenLogging {
log.Printf("development confirmation token for user %s: %s", username, token)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
regMsg := registrationOKHTML(locale)
if host == "" {
regMsg = registrationAutoHTML(locale)
}
w.Write([]byte(regMsg))
http.Redirect(w, r, "/register/result", http.StatusSeeOther)
default:
jsonErr(w, 405, "method not allowed")
methodNotAllowed(w, http.MethodGet, http.MethodPost)
}
}
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":
case http.MethodGet:
s.renderPage(w, r, "forgot", webPage{Title: "auth.forgotTitle"})
case http.MethodPost:
if err := r.ParseForm(); err != nil {
jsonErr(w, 400, "bad form")
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/forgot")
return
}
email := strings.ToLower(r.FormValue("email"))
if !s.requirePublicWebMutation(w, r, "/forgot") {
return
}
email := strings.ToLower(strings.TrimSpace(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")))
s.renderPage(w, r, "forgot", webPage{Title: "auth.forgotTitle", Flash: "error.emailRequired"})
return
}
if !s.allowWebRate(w, r, "forgot", email, "/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)
if err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", email).Scan(&userID); err == nil {
token, issueErr := issueEmailToken(s.db, userID, "reset", time.Hour)
if issueErr != nil {
log.Printf("web forgot: issue reset token: %v", issueErr)
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/forgot")
return
}
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)
if s.smtpGet("smtp_host") != "" {
base := s.smtpGet("server_url")
if base == "" {
base = "http://" + r.Host
}
resetURL := fmt.Sprintf("%s/reset?token=%s", strings.TrimRight(base, "/"), token)
if err := s.smtpSend(email, t(s.webLocale(r), "server.emailResetSubject"), fmt.Sprintf(t(s.webLocale(r), "server.emailResetBody"), resetURL)); err != nil {
log.Printf("web forgot: reset mail: %v", err)
}
} else if s.cfg.DevelopmentTokenLogging {
log.Printf("development reset token requested for %s: %s", email, token)
}
} 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)))
http.Redirect(w, r, "/forgot/sent", http.StatusSeeOther)
default:
jsonErr(w, 405, "method not allowed")
methodNotAllowed(w, http.MethodGet, http.MethodPost)
}
}
func (s *Server) validResetToken(token string) bool {
var expiresAt string
if err := s.db.QueryRow("SELECT expires_at FROM server_email_tokens WHERE token_hash=? AND purpose='reset'", emailTokenHash(token)).Scan(&expiresAt); err != nil {
return false
}
expires, err := time.Parse(time.RFC3339, expiresAt)
return err == nil && time.Now().Before(expires)
}
func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) {
locale := s.locale()
w.Header().Set("Cache-Control", "no-store")
switch r.Method {
case "GET":
case http.MethodGet:
token := r.URL.Query().Get("token")
if token == "" {
if token == "" || !s.validResetToken(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":
s.renderPage(w, r, "reset", webPage{Title: "auth.resetTitle", Token: token})
case http.MethodPost:
if err := r.ParseForm(); err != nil {
jsonErr(w, 400, "bad form")
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/forgot")
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")))
if !s.requirePublicWebMutation(w, r, "/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))))
token, password, confirm := r.FormValue("token"), r.FormValue("password"), r.FormValue("confirm")
if token == "" || password == "" || confirm == "" {
s.renderPage(w, r, "reset", webPage{Title: "auth.resetTitle", Token: token, Flash: "error.allFieldsRequired"})
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))))
if !s.allowWebRate(w, r, "reset", "", "/forgot") {
return
}
userID, err := s.resetPasswordWithToken(token, newPass)
if err := validatePassword(password); err != "" {
s.renderPage(w, r, "reset", webPage{Title: "auth.resetTitle", Token: token, Flash: "error.passwordInvalid"})
return
}
if password != confirm {
s.renderPage(w, r, "reset", webPage{Title: "auth.resetTitle", Token: token, Flash: "error.passwordMismatch"})
return
}
userID, err := s.resetPasswordWithToken(token, password)
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")))
log.Printf("web reset: %v", err)
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/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)))
http.Redirect(w, r, "/reset/done", http.StatusSeeOther)
default:
jsonErr(w, 405, "method not allowed")
methodNotAllowed(w, http.MethodGet, http.MethodPost)
}
}
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":
case http.MethodGet:
s.renderPage(w, r, "login", webPage{Title: "auth.loginTitle"})
case http.MethodPost:
if err := r.ParseForm(); err != nil {
jsonErr(w, 400, "bad form")
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/login")
return
}
if !s.requirePublicWebMutation(w, r, "/login") {
return
}
login, password := strings.TrimSpace(r.FormValue("username")), r.FormValue("password")
if !s.allowWebRate(w, r, "login", login, "/login") {
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)
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?", login, strings.ToLower(login)).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")))
s.renderPageStatus(w, r, "login", webPage{Title: "auth.loginTitle", Flash: "error.invalidCredentials"}, http.StatusUnauthorized)
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)
token, csrf, err := s.createSession(sessionScopeUser, userID)
if err != nil {
log.Printf("web login: session: %v", err)
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/login")
return
}
s.setSessionCookies(w, r, sessionScopeUser, token, csrf)
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
default:
jsonErr(w, 405, "method not allowed")
methodNotAllowed(w, http.MethodGet, http.MethodPost)
}
}
func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
locale := s.locale()
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
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 username, email string
var confirmed int
if err := s.db.QueryRow("SELECT username, email, confirmed FROM server_users WHERE id=?", userID).Scan(&username, &email, &confirmed); err != nil {
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/")
return
}
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)
query := strings.TrimSpace(r.URL.Query().Get("q"))
where := " WHERE ud.user_id=?"
args := []interface{}{userID}
if query != "" {
like := "%" + query + "%"
where += " AND (d.name LIKE ? OR d.vault_id LIKE ? OR d.client_version LIKE ?)"
args = append(args, like, like, like)
}
rows, err := s.db.Query(`SELECT d.id, d.name, COALESCE(d.vault_id,''), 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+` ORDER BY d.created_at DESC`, args...)
if err != nil {
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/")
return
}
defer rows.Close()
var devices []webDevice
for rows.Next() {
var d webDevice
var revoked string
if err := rows.Scan(&d.ID, &d.Name, &d.Vault, &d.ClientVersion, &d.LastSeen, &revoked, &d.CreatedAt); err != nil {
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/")
return
}
}
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)
d.Revoked = revoked != ""
if d.LastSeen == "" {
d.LastSeen = "—"
}
devices = append(devices, d)
}
w.Write([]byte(userDashboardHTML(locale, html.EscapeString(username), deviceRows)))
if err := rows.Err(); err != nil {
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/")
return
}
flash := r.URL.Query().Get("flash")
flashError := false
if flash == "" {
flash = r.URL.Query().Get("error")
flashError = flash != ""
}
if flash != "error.invalidCredentials" && flash != "user.deviceRevoked" {
flash = ""
}
s.renderPage(w, r, "dashboard", webPage{Title: "user.account", UserName: username, Email: email, UserConfirmed: confirmed != 0, Devices: devices, Flash: flash, FlashError: flashError, List: webList{Query: query}})
}
func (s *Server) handleUserWebLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "user_session", Value: "", Path: "/",
HttpOnly: true, MaxAge: -1,
})
if r.Method != http.MethodPost {
methodNotAllowed(w, http.MethodPost)
return
}
if !s.requireUserMutation(w, r) {
return
}
if cookie, err := r.Cookie("user_session"); err == nil {
if err := s.deleteSession(cookie.Value); err != nil {
jsonInternalError(w, err)
return
}
}
s.clearSessionCookies(w, r, sessionScopeUser)
http.Redirect(w, r, "/login", http.StatusFound)
}
func (s *Server) handleUserDevices(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
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)
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
var devices []map[string]string
for rows.Next() {
var d devDTO
rows.Scan(&d.ID, &d.Name, &d.ClientVersion, &d.LastSeen, &d.RevokedAt, &d.CreatedAt)
devices = append(devices, d)
var id, name, version, lastSeen, revoked, created string
if err := rows.Scan(&id, &name, &version, &lastSeen, &revoked, &created); err != nil {
jsonInternalError(w, err)
return
}
devices = append(devices, map[string]string{"id": id, "name": name, "client_version": version, "last_seen": lastSeen, "revoked_at": revoked, "created_at": created})
}
if err := rows.Err(); err != nil {
jsonInternalError(w, err)
return
}
jsonOK(w, devices)
}
// handleUserWebDeviceAction accepts the dashboard's regular form as well as
// the existing JSON API. Both paths are session and CSRF protected.
func (s *Server) handleUserWebDeviceAction(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w, http.MethodPost)
return
}
session, ok := s.requireSession(w, r, sessionScopeUser)
if !ok || !s.verifyCSRF(w, r, session) {
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/v1/user/devices/")
if !strings.HasSuffix(path, "/revoke") {
jsonErr(w, http.StatusNotFound, "not found")
return
}
deviceID := strings.TrimSuffix(strings.TrimSuffix(path, "/revoke"), "/")
password := ""
formRequest := strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded")
if formRequest {
password = r.FormValue("password")
} else {
var req struct {
Password string `json:"password"`
}
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
return
}
password = req.Password
}
if password == "" {
if formRequest {
http.Redirect(w, r, "/dashboard?error=error.invalidCredentials", http.StatusSeeOther)
} else {
jsonErrCode(w, http.StatusBadRequest, "invalid_request", "password required")
}
return
}
if formRequest {
if !s.allowWebRate(w, r, "auth-test", session.SubjectID, "/dashboard") {
return
}
} else if !s.allowRate(w, r, "auth-test", session.SubjectID) {
return
}
var hash string
if err := s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", session.SubjectID).Scan(&hash); err != nil {
jsonInternalError(w, err)
return
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
if formRequest {
http.Redirect(w, r, "/dashboard?error=error.invalidCredentials", http.StatusSeeOther)
} else {
jsonErr(w, http.StatusForbidden, "wrong password")
}
return
}
var owner string
if err := s.db.QueryRow("SELECT user_id FROM server_devices WHERE id=?", deviceID).Scan(&owner); err != nil {
jsonErr(w, http.StatusNotFound, "device not found")
return
}
if owner != session.SubjectID {
jsonErr(w, http.StatusForbidden, "device does not belong to you")
return
}
if err := s.revokeDevice(deviceID, time.Now().UTC().Format(time.RFC3339)); err != nil {
jsonInternalError(w, err)
return
}
s.auditLog("device_revoked", session.SubjectID, deviceID, s.clientIP(r), "device revoked from web dashboard")
if formRequest {
http.Redirect(w, r, "/dashboard?flash=user.deviceRevoked", http.StatusSeeOther)
return
}
jsonOK(w, map[string]string{"status": "revoked"})
}

View File

@ -0,0 +1,645 @@
package server
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"mime/multipart"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
func newTestServer(t *testing.T) (*Server, error) {
t.Helper()
return newServerForTest(t, DefaultConfig())
}
func newServerForTest(t *testing.T, cfg *Config) (*Server, error) {
t.Helper()
dir := t.TempDir()
return NewServer(filepath.Join(dir, "server.db"), filepath.Join(dir, "data"), cfg)
}
func serveJSON(t *testing.T, s *Server, method, path, token string, body interface{}) (int, map[string]interface{}) {
t.Helper()
data, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(method, path, bytes.NewReader(data))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
result := make(map[string]interface{})
if len(res.Body.Bytes()) > 0 {
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
t.Fatalf("decode response: %v (%s)", err, res.Body.String())
}
}
return res.Code, result
}
func insertScopedSyncDevice(t *testing.T, s *Server, deviceID, userID, vaultID, 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, vault_id, last_seen, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, deviceID, deviceID, "legacy-"+deviceID, sha256Hex(token), userID, vaultID, now, now); err != nil {
t.Fatalf("insert scoped device: %v", err)
}
}
func uploadBlob(t *testing.T, s *Server, token string, data []byte) (int, map[string]interface{}) {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "blob.bin")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(data); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/blobs/", &body)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", writer.FormDataContentType())
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
result := map[string]interface{}{}
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
t.Fatalf("decode blob response: %v (%s)", err, res.Body.String())
}
return res.Code, result
}
func TestDefaultListenAddressIsLoopback(t *testing.T) {
cfg := DefaultConfig()
if got, want := cfg.ListenAddress(), "127.0.0.1:47732"; got != want {
t.Fatalf("default listen address = %q, want %q", got, want)
}
}
func TestHTTPServerUsesExplicitTimeouts(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
httpServer := s.HTTPServer("127.0.0.1:0")
if httpServer.ReadHeaderTimeout <= 0 || httpServer.ReadTimeout <= 0 || httpServer.WriteTimeout <= 0 || httpServer.IdleTimeout <= 0 {
t.Fatalf("HTTP timeouts must all be set: %#v", httpServer)
}
if httpServer.MaxHeaderBytes <= 0 {
t.Fatalf("MaxHeaderBytes must be set: %#v", httpServer)
}
}
func TestHTTPServerGracefulShutdown(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
server := s.HTTPServer(listener.Addr().String())
server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })
done := make(chan error, 1)
go func() { done <- server.Serve(listener) }()
response, err := http.Get("http://" + listener.Addr().String())
if err != nil {
t.Fatal(err)
}
_ = response.Body.Close()
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
t.Fatalf("shutdown: %v", err)
}
if err := <-done; err != nil && err != http.ErrServerClosed {
t.Fatalf("serve returned %v", err)
}
}
func TestClientIPIgnoresUntrustedForwardedHeaders(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
r := httptest.NewRequest(http.MethodPost, "/api/client/pair", nil)
r.RemoteAddr = "198.51.100.20:4040"
r.Header.Set("X-Forwarded-For", "203.0.113.8")
if got, want := s.clientIP(r), "198.51.100.20"; got != want {
t.Fatalf("client IP = %q, want %q", got, want)
}
}
func TestClientIPUsesTrustedProxyHeadersOnlyForTrustedPeer(t *testing.T) {
cfg := DefaultConfig()
cfg.TrustedProxies = []string{"127.0.0.1/32"}
s, err := newServerForTest(t, cfg)
if err != nil {
t.Fatal(err)
}
defer s.Close()
r := httptest.NewRequest(http.MethodPost, "/api/client/pair", nil)
r.RemoteAddr = "127.0.0.1:4040"
r.Header.Set("X-Forwarded-For", "203.0.113.8, 127.0.0.1")
if got, want := s.clientIP(r), "203.0.113.8"; got != want {
t.Fatalf("client IP = %q, want %q", got, want)
}
}
func TestRateLimiterRecoversAfterWindow(t *testing.T) {
now := time.Now().UTC()
limiter := newRateLimiter(func() time.Time { return now })
policy := RatePolicy{Limit: 2, Window: time.Minute}
if allowed, _ := limiter.Allow("198.51.100.1", policy); !allowed {
t.Fatal("first attempt unexpectedly limited")
}
if allowed, _ := limiter.Allow("198.51.100.1", policy); !allowed {
t.Fatal("second attempt unexpectedly limited")
}
if allowed, retryAfter := limiter.Allow("198.51.100.1", policy); allowed || retryAfter <= 0 {
t.Fatalf("third attempt = allowed:%t retry:%s, want limited with retry", allowed, retryAfter)
}
now = now.Add(time.Minute + time.Second)
if allowed, _ := limiter.Allow("198.51.100.1", policy); !allowed {
t.Fatal("attempt after window remained limited")
}
}
func TestRateLimiterMemoryIsBounded(t *testing.T) {
limiter := newRateLimiter(nil)
policy := RatePolicy{Limit: 1, Window: time.Hour}
for i := 0; i < maxRateLimitBuckets+100; i++ {
if allowed, _ := limiter.Allow(strconvItoa(i), policy); !allowed {
t.Fatalf("new bucket %d unexpectedly limited", i)
}
}
if got := len(limiter.buckets); got > maxRateLimitBuckets {
t.Fatalf("rate buckets = %d, want <= %d", got, maxRateLimitBuckets)
}
}
func TestSyncPushRejectsOversizedJSONBody(t *testing.T) {
cfg := DefaultConfig()
cfg.Limits.MaxJSONBody = 64
s, err := newServerForTest(t, cfg)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
insertSyncUser(t, s, "user-a")
insertSyncDevice(t, s, "device-a", "user-a", "token-a")
overSizedJSON := append([]byte(`{"ops":[],"padding":"`), bytes.Repeat([]byte("x"), 65)...)
overSizedJSON = append(overSizedJSON, []byte(`"}`)...)
req := httptest.NewRequest(http.MethodPost, "/api/v1/sync/push", bytes.NewReader(overSizedJSON))
req.Header.Set("Authorization", "Bearer token-a")
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want %d: %s", res.Code, http.StatusRequestEntityTooLarge, res.Body.String())
}
var body map[string]string
if err := json.Unmarshal(res.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["code"] != "request_too_large" {
t.Fatalf("error body = %#v, want stable request_too_large code", body)
}
}
func TestSyncPushRejectsOperationCountAboveLimit(t *testing.T) {
cfg := DefaultConfig()
cfg.Limits.MaxPushOperations = 1
s, err := newServerForTest(t, cfg)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
insertSyncUser(t, s, "user-a")
insertSyncDevice(t, s, "device-a", "user-a", "token-a")
body := syncPushBody("device-a", "op-1", "")
body["ops"] = append(body["ops"].([]map[string]interface{}), map[string]interface{}{
"op_id": "op-2", "entity_type": "file", "entity_id": "Docs/two.txt", "op_type": "create",
"payload_json": `{"path":"Docs/two.txt","content":"two"}`, "created_at": "2026-07-10T00:00:00Z",
})
status, response := serveJSON(t, s, http.MethodPost, "/api/v1/sync/push", "token-a", body)
if status != http.StatusRequestEntityTooLarge || response["code"] != "too_many_operations" {
t.Fatalf("status=%d body=%#v, want 413 too_many_operations", status, response)
}
}
func TestSyncPushRejectsTrailingJSONAndOversizedPayload(t *testing.T) {
cfg := DefaultConfig()
cfg.Limits.MaxPayloadJSON = 32
s, err := newServerForTest(t, cfg)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
insertSyncUser(t, s, "user-a")
insertSyncDevice(t, s, "device-a", "user-a", "token-a")
data, err := json.Marshal(syncPushBody("device-a", "op-trailing", ""))
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/sync/push", bytes.NewReader(append(data, []byte(` {}`)...)))
req.Header.Set("Authorization", "Bearer token-a")
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusBadRequest || !bytes.Contains(res.Body.Bytes(), []byte(`"trailing_json"`)) {
t.Fatalf("trailing JSON status=%d body=%s", res.Code, res.Body.String())
}
body := syncPushBody("device-a", "op-large-payload", "")
body["ops"].([]map[string]interface{})[0]["payload_json"] = `{"path":"Docs/large.txt","content":"this is intentionally longer than the configured payload bound"}`
status, response := serveJSON(t, s, http.MethodPost, "/api/v1/sync/push", "token-a", body)
if status != http.StatusRequestEntityTooLarge || response["code"] != "payload_too_large" {
t.Fatalf("payload limit status=%d body=%#v", status, response)
}
}
func TestSyncPullPaginationHasNoGapsOrRepeats(t *testing.T) {
cfg := DefaultConfig()
cfg.Limits.MaxPullPage = 2
s, err := newServerForTest(t, cfg)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
insertSyncUser(t, s, "user-a")
insertSyncDevice(t, s, "device-a", "user-a", "token-a")
for _, opID := range []string{"op-1", "op-2", "op-3", "op-4", "op-5"} {
if status, response := serveJSON(t, s, http.MethodPost, "/api/v1/sync/push", "token-a", syncPushBody("device-a", opID, "")); status != http.StatusOK {
t.Fatalf("push %s status=%d body=%#v", opID, status, response)
}
}
cursor := 0
var sequences []int
for page := 0; page < 3; page++ {
status, response := serveJSON(t, s, http.MethodPost, "/api/v1/sync/pull", "token-a", map[string]int{"since_sequence": cursor, "page_limit": 2})
if status != http.StatusOK {
t.Fatalf("pull page %d status=%d body=%#v", page, status, response)
}
for _, raw := range response["ops"].([]interface{}) {
sequences = append(sequences, int(raw.(map[string]interface{})["server_sequence"].(float64)))
}
cursor = int(response["page_last_sequence"].(float64))
if !response["has_more"].(bool) {
break
}
}
if got, want := len(sequences), 5; got != want {
t.Fatalf("sequences=%v, want five ordered values", sequences)
}
for i, sequence := range sequences {
if sequence != i+1 {
t.Fatalf("sequences=%v, want [1 2 3 4 5]", sequences)
}
}
}
func TestBlobOwnershipPreventsCrossVaultDownload(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
insertSyncUser(t, s, "user-a")
insertSyncUser(t, s, "user-b")
insertScopedSyncDevice(t, s, "device-a", "user-a", "vault-a", "token-a")
insertScopedSyncDevice(t, s, "device-b", "user-b", "vault-b", "token-b")
status, uploaded := uploadBlob(t, s, "token-a", []byte("private blob"))
if status != http.StatusOK {
t.Fatalf("upload status=%d body=%#v", status, uploaded)
}
sha := uploaded["sha256"].(string)
request := httptest.NewRequest(http.MethodGet, "/api/v1/blobs/"+sha, nil)
request.Header.Set("Authorization", "Bearer token-b")
response := httptest.NewRecorder()
s.Handler().ServeHTTP(response, request)
if response.Code != http.StatusNotFound {
t.Fatalf("cross-vault download status=%d, want 404: %s", response.Code, response.Body.String())
}
}
func TestBlobLimitAndQuotaRejectWithoutResidualFile(t *testing.T) {
cfg := DefaultConfig()
cfg.Limits.MaxBlobBytes = 8
cfg.Limits.MaxVaultBlobBytes = 8
s, err := newServerForTest(t, cfg)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
insertSyncUser(t, s, "user-a")
insertScopedSyncDevice(t, s, "device-a", "user-a", "vault-a", "token-a")
tooLarge := []byte("012345678")
status, body := uploadBlob(t, s, "token-a", tooLarge)
if status != http.StatusRequestEntityTooLarge || body["code"] != "blob_too_large" {
t.Fatalf("file limit status=%d body=%#v", status, body)
}
sum := sha256.Sum256(tooLarge)
path := blobPath(s.blobsDir, hex.EncodeToString(sum[:]))
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("rejected oversized blob left physical file: %v", err)
}
status, body = uploadBlob(t, s, "token-a", []byte("12345678"))
if status != http.StatusOK {
t.Fatalf("first quota upload status=%d body=%#v", status, body)
}
quotaCandidate := []byte("abcdefgh")
status, body = uploadBlob(t, s, "token-a", quotaCandidate)
if status != http.StatusRequestEntityTooLarge || body["code"] != "quota_exceeded" {
t.Fatalf("quota status=%d body=%#v", status, body)
}
sum = sha256.Sum256(quotaCandidate)
if _, err := os.Stat(blobPath(s.blobsDir, hex.EncodeToString(sum[:]))); !os.IsNotExist(err) {
t.Fatalf("quota-rejected blob left physical file: %v", err)
}
}
func TestBlobUploadIsIdempotentWithinScope(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
insertSyncUser(t, s, "user-a")
insertScopedSyncDevice(t, s, "device-a", "user-a", "vault-a", "token-a")
data := []byte("same content")
_, first := uploadBlob(t, s, "token-a", data)
_, second := uploadBlob(t, s, "token-a", data)
if first["sha256"] != second["sha256"] || first["size"] != second["size"] {
t.Fatalf("idempotent uploads differ: first=%#v second=%#v", first, second)
}
var refs int
if err := s.db.QueryRow("SELECT COUNT(*) FROM server_blob_refs WHERE user_id=? AND vault_id=?", "user-a", "vault-a").Scan(&refs); err != nil {
t.Fatal(err)
}
if refs != 1 {
t.Fatalf("blob refs = %d, want one", refs)
}
}
func TestRevokedDeviceCannotUseBlobEndpoints(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
insertSyncUser(t, s, "user-a")
insertScopedSyncDevice(t, s, "device-a", "user-a", "vault-a", "token-a")
status, uploaded := uploadBlob(t, s, "token-a", []byte("before revoke"))
if status != http.StatusOK {
t.Fatalf("upload status=%d body=%#v", status, uploaded)
}
if err := s.revokeDevice("device-a", time.Now().UTC().Format(time.RFC3339)); err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/blobs/"+uploaded["sha256"].(string), nil)
request.Header.Set("Authorization", "Bearer token-a")
response := httptest.NewRecorder()
s.Handler().ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("revoked blob download status=%d, want 401", response.Code)
}
}
func TestAdminKeysNeverReturnPlaintextCredential(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
insertSyncUser(t, s, "user-a")
insertScopedSyncDevice(t, s, "device-a", "user-a", "vault-a", "secret-device-token")
token, _, err := s.createSession(sessionScopeAdmin, "admin")
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/admin/api/keys", nil)
request.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
response := httptest.NewRecorder()
s.Handler().ServeHTTP(response, request)
if response.Code != http.StatusOK || bytes.Contains(response.Body.Bytes(), []byte("secret-device-token")) || bytes.Contains(response.Body.Bytes(), []byte("api_key")) {
t.Fatalf("admin keys leaked a credential: status=%d body=%s", response.Code, response.Body.String())
}
}
func TestHealthReportsDegradedDatabase(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
s.SetupRoutes()
if err := s.db.Close(); err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/readyz", nil)
response := httptest.NewRecorder()
s.Handler().ServeHTTP(response, request)
if response.Code != http.StatusServiceUnavailable || !bytes.Contains(response.Body.Bytes(), []byte(`"database_reachable":false`)) {
t.Fatalf("readiness after db close: status=%d body=%s", response.Code, response.Body.String())
}
}
func TestRetentionDoesNotPruneOperations(t *testing.T) {
cfg := DefaultConfig()
cfg.Retention.IdempotencyHours = 1
cfg.Retention.AuditDays = 1
s, err := newServerForTest(t, cfg)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
insertSyncUser(t, s, "user-a")
insertSyncDevice(t, s, "device-a", "user-a", "token-a")
if status, body := serveJSON(t, s, http.MethodPost, "/api/v1/sync/push", "token-a", syncPushBody("device-a", "op-retained", "")); status != http.StatusOK {
t.Fatalf("push status=%d body=%#v", status, body)
}
if err := s.CleanupRetention(time.Now().UTC().Add(48 * time.Hour)); err != nil {
t.Fatal(err)
}
var ops int
if err := s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&ops); err != nil {
t.Fatal(err)
}
if ops != 1 {
t.Fatalf("retention removed sync operations: %d", ops)
}
}
func TestWebSessionSurvivesServerRestartAndLogoutInvalidatesIt(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "server.db")
dataDir := filepath.Join(dir, "data")
s, err := NewServer(dbPath, dataDir, DefaultConfig())
if err != nil {
t.Fatal(err)
}
token, csrf, err := s.createSession(sessionScopeUser, "user-a")
if err != nil {
t.Fatal(err)
}
if err := s.Close(); err != nil {
t.Fatal(err)
}
restarted, err := NewServer(dbPath, dataDir, DefaultConfig())
if err != nil {
t.Fatal(err)
}
defer restarted.Close()
restarted.SetupRoutes()
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.AddCookie(&http.Cookie{Name: "user_session", Value: token})
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
req.Header.Set("X-CSRF-Token", csrf)
res := httptest.NewRecorder()
restarted.Handler().ServeHTTP(res, req)
if res.Code != http.StatusFound {
t.Fatalf("logout status=%d body=%s", res.Code, res.Body.String())
}
if _, ok := restarted.loadSession(token, sessionScopeUser); ok {
t.Fatal("logout did not invalidate server-side session")
}
}
func TestAdminMutationRejectsMissingCSRFToken(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
token, csrf, err := s.createSession(sessionScopeAdmin, "admin")
if err != nil {
t.Fatal(err)
}
missing := httptest.NewRequest(http.MethodDelete, "/admin/api/keys/missing", nil)
missing.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
missingResult := httptest.NewRecorder()
s.Handler().ServeHTTP(missingResult, missing)
if missingResult.Code != http.StatusForbidden {
t.Fatalf("missing csrf status=%d, want 403", missingResult.Code)
}
valid := httptest.NewRequest(http.MethodDelete, "/admin/api/keys/missing", nil)
valid.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
valid.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
valid.Header.Set("X-CSRF-Token", csrf)
validResult := httptest.NewRecorder()
s.Handler().ServeHTTP(validResult, valid)
if validResult.Code != http.StatusOK {
t.Fatalf("valid csrf status=%d body=%s", validResult.Code, validResult.Body.String())
}
}
func TestAdminUserDeletionIsTransactionalAcrossOwnedRows(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
insertSyncUser(t, s, "user-a")
insertScopedSyncDevice(t, s, "device-a", "user-a", "vault-a", "token-a")
if status, response := serveJSON(t, s, http.MethodPost, "/api/v1/sync/push", "token-a", syncPushBody("device-a", "op-a", "")); status != http.StatusOK {
t.Fatalf("push status=%d body=%#v", status, response)
}
adminToken, csrf, err := s.createSession(sessionScopeAdmin, "admin")
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodDelete, "/admin/api/users/user-a", nil)
request.AddCookie(&http.Cookie{Name: "admin_session", Value: adminToken})
request.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
request.Header.Set("X-CSRF-Token", csrf)
response := httptest.NewRecorder()
s.Handler().ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("delete status=%d body=%s", response.Code, response.Body.String())
}
for table, where := range map[string]string{
"server_users": "id='user-a'",
"server_devices": "user_id='user-a'",
"server_ops": "user_id='user-a'",
"server_blob_refs": "user_id='user-a'",
} {
var count int
if err := s.db.QueryRow("SELECT COUNT(*) FROM " + table + " WHERE " + where).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("%s still has %d rows after user deletion", table, count)
}
}
}
func TestResetTokenIsHashedAndSingleUse(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
insertPairableUser(t, s, "user-a", "alice", "correct horse battery staple")
token, err := issueEmailToken(s.db, "user-a", "reset", time.Hour)
if err != nil {
t.Fatal(err)
}
var stored string
if err := s.db.QueryRow("SELECT token_hash FROM server_email_tokens WHERE user_id=?", "user-a").Scan(&stored); err != nil {
t.Fatal(err)
}
if stored == token || stored != emailTokenHash(token) {
t.Fatalf("stored reset credential is not a token hash: %q", stored)
}
if _, err := s.resetPasswordWithToken(token, "a new secure password"); err != nil {
t.Fatalf("first reset: %v", err)
}
if _, err := s.resetPasswordWithToken(token, "another secure password"); err != errResetTokenInvalid {
t.Fatalf("second reset err=%v, want invalid token", err)
}
}

158
internal/server/health.go Normal file
View File

@ -0,0 +1,158 @@
package server
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
)
type HealthStatus struct {
Status string `json:"status"`
Version string `json:"version"`
BuildCommit string `json:"build_commit"`
UptimeSeconds int64 `json:"uptime_seconds"`
DatabaseReachable bool `json:"database_reachable"`
BlobStorageWritable bool `json:"blob_storage_writable"`
SchemaVersion int `json:"schema_version"`
ServerTime string `json:"server_time"`
}
func (s *Server) healthStatus(ctx context.Context) HealthStatus {
health := HealthStatus{
Status: "ok",
Version: Version,
BuildCommit: BuildCommit,
UptimeSeconds: int64(time.Since(s.startedAt).Seconds()),
ServerTime: time.Now().UTC().Format(time.RFC3339),
}
if s.db == nil || s.db.PingContext(ctx) != nil {
health.DatabaseReachable = false
health.Status = "degraded"
} else {
health.DatabaseReachable = true
if err := s.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&health.SchemaVersion); err != nil {
health.DatabaseReachable = false
health.Status = "degraded"
}
}
health.BlobStorageWritable = s.blobStorageWritable()
if !health.BlobStorageWritable {
health.Status = "degraded"
}
return health
}
func (s *Server) blobStorageWritable() bool {
if s.blobsDir == "" {
return false
}
probe, err := os.CreateTemp(s.blobsDir, ".health-*")
if err != nil {
return false
}
name := probe.Name()
if err := probe.Close(); err != nil {
_ = os.Remove(name)
return false
}
return os.Remove(name) == nil
}
// ServerStats is intentionally independent from the web UI so a future
// admin panel can expose operational data without coupling to templates.
type ServerStats struct {
Users int `json:"users"`
ActiveUsers int `json:"active_users"`
BlockedUsers int `json:"blocked_users"`
UnconfirmedUsers int `json:"unconfirmed_users"`
ActiveDevices int `json:"active_devices"`
RevokedDevices int `json:"revoked_devices"`
Vaults int `json:"vaults"`
Operations int `json:"operations"`
Operations24h int `json:"operations_24h"`
Blobs int `json:"blobs"`
BlobReferences int `json:"blob_references"`
OrphanBlobs int `json:"orphan_blobs"`
TempUploads int `json:"temp_uploads"`
ExpiredSessions int `json:"expired_sessions"`
ExpiredTokens int `json:"expired_email_tokens"`
AuditEvents int `json:"audit_events"`
DatabaseBytes int64 `json:"database_bytes"`
BlobBytes int64 `json:"blob_bytes"`
LastSyncAt string `json:"last_sync_activity"`
LastCleanupAt string `json:"last_cleanup_at"`
}
func (s *Server) Stats(ctx context.Context) (ServerStats, error) {
var stats ServerStats
now := time.Now().UTC()
queries := []struct {
query string
target *int
}{
{"SELECT COUNT(*) FROM server_users", &stats.Users},
{"SELECT COUNT(*) FROM server_users WHERE confirmed=1 AND blocked=0", &stats.ActiveUsers},
{"SELECT COUNT(*) FROM server_users WHERE blocked=1", &stats.BlockedUsers},
{"SELECT COUNT(*) FROM server_users WHERE confirmed=0", &stats.UnconfirmedUsers},
{"SELECT COUNT(*) FROM server_devices WHERE COALESCE(revoked_at, '') = ''", &stats.ActiveDevices},
{"SELECT COUNT(*) FROM server_devices WHERE COALESCE(revoked_at, '') != ''", &stats.RevokedDevices},
{"SELECT COUNT(DISTINCT user_id || ':' || vault_id) FROM server_devices WHERE COALESCE(user_id,'') != '' AND COALESCE(vault_id,'') != ''", &stats.Vaults},
{"SELECT COUNT(*) FROM server_ops", &stats.Operations},
{"SELECT COUNT(*) FROM server_blobs", &stats.Blobs},
{"SELECT COUNT(*) FROM server_blob_refs", &stats.BlobReferences},
{"SELECT COUNT(*) FROM server_blobs b WHERE NOT EXISTS (SELECT 1 FROM server_blob_refs r WHERE r.sha256=b.sha256)", &stats.OrphanBlobs},
{"SELECT COUNT(*) FROM server_audit_log", &stats.AuditEvents},
}
for _, query := range queries {
if err := s.db.QueryRowContext(ctx, query.query).Scan(query.target); err != nil {
return ServerStats{}, err
}
}
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM server_ops WHERE created_at >= ?", now.Add(-24*time.Hour).Format(time.RFC3339)).Scan(&stats.Operations24h); err != nil {
return ServerStats{}, err
}
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM server_sessions WHERE expires_at <= ?", now.Format(time.RFC3339)).Scan(&stats.ExpiredSessions); err != nil {
return ServerStats{}, err
}
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM server_email_tokens WHERE expires_at <= ?", now.Format(time.RFC3339)).Scan(&stats.ExpiredTokens); err != nil {
return ServerStats{}, err
}
if err := s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(created_at),'') FROM server_audit_log WHERE event_type='retention_cleanup'").Scan(&stats.LastCleanupAt); err != nil {
return ServerStats{}, err
}
if err := s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(last_seen), '') FROM server_devices").Scan(&stats.LastSyncAt); err != nil {
return ServerStats{}, err
}
if info, err := os.Stat(s.dbPath); err == nil {
stats.DatabaseBytes = info.Size()
}
if err := filepath.WalkDir(s.blobsDir, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.Type().IsRegular() {
info, err := entry.Info()
if err != nil {
return err
}
stats.BlobBytes += info.Size()
}
return nil
}); err != nil && !os.IsNotExist(err) {
return ServerStats{}, fmt.Errorf("blob storage stats: %w", err)
}
if entries, err := os.ReadDir(s.blobsDir); err == nil {
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".upload-") {
stats.TempUploads++
}
}
} else if !os.IsNotExist(err) {
return ServerStats{}, fmt.Errorf("temporary upload stats: %w", err)
}
return stats, nil
}

View File

@ -4,8 +4,12 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strings"
)
func jsonOK(w http.ResponseWriter, v interface{}) {
@ -13,10 +17,43 @@ func jsonOK(w http.ResponseWriter, v interface{}) {
json.NewEncoder(w).Encode(v)
}
func jsonErr(w http.ResponseWriter, code int, msg string) {
func jsonOKStatus(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func jsonErr(w http.ResponseWriter, code int, msg string) {
jsonErrCode(w, code, defaultErrorCode(code), msg)
}
// jsonErrCode preserves the legacy human-readable error field while giving
// Desktop a stable machine-readable code for localized messages.
func jsonErrCode(w http.ResponseWriter, status int, machineCode, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg, "code": machineCode})
}
func defaultErrorCode(status int) string {
switch status {
case http.StatusBadRequest:
return "invalid_request"
case http.StatusUnauthorized:
return "unauthorized"
case http.StatusForbidden:
return "forbidden"
case http.StatusNotFound:
return "not_found"
case http.StatusMethodNotAllowed:
return "method_not_allowed"
case http.StatusRequestEntityTooLarge:
return "request_too_large"
case http.StatusTooManyRequests:
return "rate_limited"
default:
return "internal_error"
}
}
func jsonInternalError(w http.ResponseWriter, err error) {
@ -24,6 +61,52 @@ func jsonInternalError(w http.ResponseWriter, err error) {
jsonErr(w, http.StatusInternalServerError, "internal error")
}
func methodNotAllowed(w http.ResponseWriter, allowed ...string) {
w.Header().Set("Allow", strings.Join(allowed, ", "))
jsonErrCode(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
}
// decodeJSONBody enforces a hard byte limit, rejects a second JSON value, and
// keeps all request handlers on the same error contract.
func decodeJSONBody(w http.ResponseWriter, r *http.Request, destination interface{}, limit int64) bool {
if limit <= 0 {
limit = 1
}
r.Body = http.MaxBytesReader(w, r.Body, limit)
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(destination); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
jsonErrCode(w, http.StatusRequestEntityTooLarge, "request_too_large", "request body is too large")
return false
}
jsonErrCode(w, http.StatusBadRequest, "invalid_json", "invalid JSON request")
return false
}
var trailing interface{}
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
jsonErrCode(w, http.StatusBadRequest, "trailing_json", "request must contain one JSON value")
} else {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
jsonErrCode(w, http.StatusRequestEntityTooLarge, "request_too_large", "request body is too large")
} else {
jsonErrCode(w, http.StatusBadRequest, "invalid_json", "invalid JSON request")
}
}
return false
}
return true
}
func validateStringLength(name, value string, max int) error {
if len(value) > max {
return fmt.Errorf("%s is too long", name)
}
return nil
}
func sha256Hex(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:])

View File

@ -6,16 +6,144 @@ func t(locale, key string) string {
return v
}
}
if translations, ok := _translations["ru"]; ok {
if translations, ok := _translations["en"]; ok {
if v, ok := translations[key]; ok {
return v
}
}
return key
if locale == "ru" {
return "Перевод временно недоступен"
}
return "Translation unavailable"
}
var _translations = map[string]map[string]string{
"ru": {
"home.title": "Синхронизация Верстака",
"home.eyebrow": "Локальная работа, когда нужно",
"home.heading": "Ваши vault остаются вашими",
"home.description": "Verstak Sync Server безопасно связывает ваши устройства, не заменяя локальный vault.",
"home.login": "Войти",
"home.register": "Создать аккаунт",
"home.version": "Версия",
"nav.primary": "Основная навигация",
"nav.login": "Войти",
"nav.register": "Регистрация",
"footer.localFirst": "Локальная работа прежде всего",
"locale.label": "Язык",
"locale.system": "Системный",
"locale.apply": "Применить язык",
"auth.welcome": "Добро пожаловать",
"auth.account": "Учётная запись",
"auth.loginTitle": "Вход в синхронизацию",
"auth.login": "Войти",
"auth.logout": "Выйти",
"auth.register": "Создать аккаунт",
"auth.registerTitle": "Создать аккаунт",
"auth.haveAccount": "Уже есть аккаунт? Войти",
"auth.forgot": "Забыли пароль?",
"auth.forgotTitle": "Восстановление пароля",
"auth.forgotDescription": "Укажите email, и мы отправим ссылку для сброса пароля, если аккаунт существует.",
"auth.sendLink": "Отправить ссылку",
"auth.backLogin": "Вернуться ко входу",
"auth.resetTitle": "Новый пароль",
"auth.savePassword": "Сохранить пароль",
"field.username": "Имя пользователя",
"field.usernameOrEmail": "Имя пользователя или email",
"field.email": "Email",
"field.password": "Пароль",
"field.newPassword": "Новый пароль",
"field.confirmPassword": "Подтвердите пароль",
"register.resultTitle": "Проверьте почту",
"register.resultMessage": "Если SMTP настроен, мы отправили ссылку подтверждения. После подтверждения можно войти.",
"forgot.sentTitle": "Проверьте почту",
"forgot.sentMessage": "Если аккаунт существует, ссылка для сброса уже отправлена.",
"reset.doneTitle": "Пароль изменён",
"reset.doneMessage": "Теперь можно войти с новым паролем.",
"confirm.title": "Подтвердите email",
"confirm.description": "Подтвердите адрес email, чтобы активировать учётную запись.",
"confirm.action": "Подтвердить email",
"confirm.resultTitle": "Email подтверждён",
"confirm.resultMessage": "Учётная запись активирована. Теперь можно войти.",
"common.continue": "Продолжить",
"common.back": "Назад",
"common.actions": "Действия",
"error.label": "Ошибка",
"error.badRequest": "Некорректный запрос",
"error.tryAgain": "Проверьте данные и повторите попытку.",
"error.registrationDisabled": "Регистрация отключена администратором.",
"error.allFieldsRequired": "Все поля обязательны.",
"error.passwordInvalid": "Пароль не соответствует требованиям безопасности.",
"error.accountTaken": "Имя пользователя или email уже заняты.",
"error.emailRequired": "Введите email.",
"error.passwordMismatch": "Пароли не совпадают.",
"error.invalidPublicURL": "Укажите корректный абсолютный public URL с http или https.",
"error.rateLimited": "Слишком много попыток. Подождите и повторите запрос.",
"error.notFound": "Страница не найдена.",
"error.deviceMustBeRevoked": "Удалять можно только предварительно отозванное устройство.",
"home.unavailableTitle": "Сервер временно недоступен",
"home.unavailableMessage": "Сервер ещё не готов обслуживать запросы. Повторите попытку позже.",
"error.invalidCredentials": "Неверное имя пользователя или пароль.",
"error.internal": "Внутренняя ошибка. Повторите попытку позже.",
"admin.eyebrow": "Администрирование сервера",
"admin.loginTitle": "Вход администратора",
"admin.navigation": "Навигация администратора",
"admin.overview": "Обзор",
"admin.access": "Доступ",
"admin.activeDevices": "Активные устройства",
"admin.operations": "Операции",
"admin.vaults": "Хранилища",
"admin.storage": "Хранилище",
"admin.audit": "Аудит",
"admin.settings": "Настройки",
"admin.diagnostics": "Диагностика",
"admin.serviceHealth": "Состояние сервиса",
"admin.lastActivity": "Последняя активность",
"admin.noVaults": "Vault пока нет",
"admin.databaseBytes": "Размер базы данных, байт",
"admin.blobBytes": "Объём blobs, байт",
"admin.retentionNote": "Журнал операций не удаляется автоматически: он нужен новым устройствам для восстановления состояния.",
"admin.event": "Событие",
"admin.time": "Время",
"admin.noAudit": "Событий аудита пока нет",
"admin.database": "База данных доступна",
"admin.blobStorage": "Blob-хранилище доступно",
"admin.schema": "Версия схемы",
"admin.serverTime": "Время сервера",
"admin.healthJSON": "Открыть health JSON",
"admin.manage": "Управление",
"admin.saveUser": "Сохранить пользователя",
"admin.search": "Поиск",
"admin.all": "Все",
"admin.applyFilters": "Применить",
"admin.pagination": "Пагинация",
"admin.previous": "Назад",
"admin.next": "Далее",
"admin.runCleanup": "Запустить безопасную очистку",
"admin.downloadDiagnostics": "Скачать диагностику",
"admin.vaultPrivacy": "Содержимое файлов и операции не отображаются в диагностике.",
"admin.general": "Общие настройки",
"admin.serverName": "Название сервера",
"admin.allowRegistration": "Разрешить публичную регистрацию",
"admin.smtpConfigured": "SMTP настраивается отдельно и пароль никогда не возвращается в форму.",
"user.account": "Моя учётная запись",
"user.devices": "Подключённые устройства",
"user.emailConfirmed": "Email подтверждён",
"user.emailUnconfirmed": "Email не подтверждён",
"user.filterDevices": "Поиск устройств",
"user.connectInstruction": "Подключите новое устройство через приложение Verstak: откройте настройки синхронизации и выполните pairing с этим сервером.",
"user.deviceRevoked": "Устройство отозвано.",
"user.noDevices": "Устройств пока нет",
"device.name": "Устройство",
"device.vault": "Хранилище",
"device.version": "Версия клиента",
"device.lastSeen": "Последняя активность",
"device.created": "Подключено",
"device.status": "Статус",
"device.active": "Активно",
"device.revoked": "Отозвано",
"device.revoke": "Отозвать",
"device.revokeConfirm": "Отозвать это устройство? Для продолжения нужен пароль.",
"server.registerTitle": "Регистрация",
"server.register": "Регистрация",
"server.username": "Имя пользователя",
@ -111,10 +239,71 @@ var _translations = map[string]map[string]string{
"admin.smtpPassed": "✓ Тест пройден",
"admin.smtpRequired": "Укажите SMTP-сервер, порт и отправителя.",
"admin.smtpTestFailed": "Не удалось проверить настройки SMTP. Повторите попытку.",
"admin.settingsSaved": "Настройки сервера сохранены.",
"admin.smtpSaved": "Настройки SMTP сохранены.",
"admin.smtpStartTLS": "STARTTLS",
"admin.smtpTLS": "TLS",
"admin.warningReadiness": "Один или несколько компонентов сервера не готовы к работе.",
"admin.warningSMTP": "SMTP не настроен: письма подтверждения и сброса пароля не будут отправляться.",
"admin.warningOperations": "Журнал операций быстро растёт. Не удаляйте его без checkpoint-механизма.",
"admin.ops24h": "за 24 часа",
"admin.sort": "Сортировка",
"admin.created": "Создан",
"admin.ip": "IP",
"admin.message": "Сообщение",
"admin.severity": "Важность",
"admin.info": "Информация",
"admin.warningLevel": "Предупреждение",
"admin.errorLevel": "Ошибка",
"admin.publicURL": "Публичный URL",
"admin.saveSettings": "Сохранить настройки",
"admin.trustedProxies": "Доверенные reverse proxy",
"admin.network": "Сеть",
"admin.limits": "Лимиты и retention",
"admin.readOnlyConfig": "Лимиты задаются конфигурацией сервера и доступны здесь только для просмотра.",
"admin.maxJSONBody": "Максимальный JSON body",
"admin.maxPushOperations": "Операций в push",
"admin.maxPullPage": "Операций в pull page",
"admin.maxBlobBytes": "Максимальный размер blob",
"admin.maxVaultBlobBytes": "Квота blob vault",
"admin.maxUserBlobBytes": "Квота blob пользователя",
"admin.sequence": "Последняя sequence",
"admin.refresh": "Обновить",
"admin.copyDiagnostics": "Скопировать диагностику",
"admin.copied": "Скопировано",
"admin.blobReferences": "Ссылки на blobs",
"admin.orphanBlobs": "Blob без ссылок",
"admin.tempUploads": "Временные загрузки",
"admin.expiredSessions": "Истёкшие сессии",
"admin.expiredTokens": "Истёкшие email-токены",
"admin.lastCleanup": "Последняя очистка",
"admin.cleanupDone": "Безопасная очистка завершена.",
"audit.other": "Другое событие",
"audit.deviceAuthFailed": "Ошибка авторизации устройства",
"audit.devicePaired": "Устройство подключено",
"audit.deviceRevoked": "Устройство отозвано",
"audit.deviceDeleted": "Отозванное устройство удалено",
"audit.rateLimited": "Сработало ограничение попыток",
"audit.retentionCleanup": "Выполнена безопасная очистка",
"audit.smtpSettingsUpdated": "Настройки SMTP изменены",
"audit.smtpTestFailed": "Проверка SMTP не удалась",
"audit.smtpTestPassed": "Проверка SMTP прошла",
"audit.userBlockChanged": "Статус блокировки пользователя изменён",
"audit.userConfirmed": "Email пользователя подтверждён",
"audit.userCreated": "Пользователь создан",
"audit.userDeleted": "Пользователь удалён",
"audit.userPasswordReset": "Пароль пользователя сброшен",
"audit.userUpdated": "Пользователь изменён",
"audit.webSettingsUpdated": "Общие настройки изменены",
"status.ok": "Работает",
"status.degraded": "Требует внимания",
"status.available": "Доступно",
"status.unavailable": "Недоступно",
"admin.revokeConfirm": "Вы уверены?",
"common.loading": "Загрузка...",
"common.ok": "OK",
"common.error": "Ошибка",
"common.warning": "Предупреждения",
"admin.filterPlaceholder": "Поиск...",
"admin.email": "Email",
"admin.actions": "Действия",
@ -133,8 +322,14 @@ var _translations = map[string]map[string]string{
"admin.noUsers": "Нет пользователей",
"admin.resetPasswordConfirm": "Сбросить пароль",
"admin.resetPasswordMessage": "Новый пароль: ",
"admin.generatePassword": "Сгенерировать одноразовый пароль",
"admin.oneTimePasswordNotice": "Сохраните этот пароль сейчас: он будет показан только один раз.",
"admin.oneTimePasswordHint": "После ухода со страницы получить пароль повторно нельзя. Пользователь должен сменить его после входа.",
"admin.resetBtn": "Сбросить",
"admin.deleteUser": "Удалить",
"admin.deleteDevice": "Удалить устройство",
"admin.confirmUser": "Подтвердить email",
"admin.deleteUserConfirm": "Удалить пользователя и все связанные устройства, vault-связи, blobs и операции? Это действие нельзя отменить.",
"admin.deleteUserMessage": "Удалить пользователя %s?",
"admin.deleteBtn": "Удалить",
"admin.unblockUserTitle": "Разблокировать",
@ -146,6 +341,131 @@ var _translations = map[string]map[string]string{
"admin.createUserFailed": "Не удалось создать пользователя. Повторите попытку.",
},
"en": {
"home.title": "Verstak Sync",
"home.eyebrow": "Local work, when it matters",
"home.heading": "Your vaults stay yours",
"home.description": "Verstak Sync Server securely connects your devices without replacing the local vault.",
"home.login": "Login",
"home.register": "Create account",
"home.version": "Version",
"nav.primary": "Primary navigation",
"nav.login": "Login",
"nav.register": "Register",
"footer.localFirst": "Local-first by design",
"locale.label": "Language",
"locale.system": "System",
"locale.apply": "Apply language",
"auth.welcome": "Welcome",
"auth.account": "Account",
"auth.loginTitle": "Sign in to sync",
"auth.login": "Login",
"auth.logout": "Logout",
"auth.register": "Create account",
"auth.registerTitle": "Create an account",
"auth.haveAccount": "Already have an account? Login",
"auth.forgot": "Forgot password?",
"auth.forgotTitle": "Reset your password",
"auth.forgotDescription": "Enter your email and we will send a reset link if the account exists.",
"auth.sendLink": "Send reset link",
"auth.backLogin": "Back to login",
"auth.resetTitle": "New password",
"auth.savePassword": "Save password",
"field.username": "Username",
"field.usernameOrEmail": "Username or email",
"field.email": "Email",
"field.password": "Password",
"field.newPassword": "New password",
"field.confirmPassword": "Confirm password",
"register.resultTitle": "Check your email",
"register.resultMessage": "If SMTP is configured, we sent a confirmation link. You can sign in after confirmation.",
"forgot.sentTitle": "Check your email",
"forgot.sentMessage": "If the account exists, a reset link has been sent.",
"reset.doneTitle": "Password changed",
"reset.doneMessage": "You can now sign in with your new password.",
"confirm.title": "Confirm your email",
"confirm.description": "Confirm your email address to activate the account.",
"confirm.action": "Confirm email",
"confirm.resultTitle": "Email confirmed",
"confirm.resultMessage": "Your account is active. You can now sign in.",
"common.continue": "Continue",
"common.back": "Back",
"common.actions": "Actions",
"error.label": "Error",
"error.badRequest": "Invalid request",
"error.tryAgain": "Check the entered data and try again.",
"error.registrationDisabled": "Registration is disabled by the administrator.",
"error.allFieldsRequired": "All fields are required.",
"error.passwordInvalid": "The password does not meet the security requirements.",
"error.accountTaken": "Username or email is already in use.",
"error.emailRequired": "Enter an email address.",
"error.passwordMismatch": "Passwords do not match.",
"error.invalidPublicURL": "Enter a valid absolute public URL using http or https.",
"error.rateLimited": "Too many attempts. Wait and try again.",
"error.notFound": "Page not found.",
"error.deviceMustBeRevoked": "Only a revoked device can be deleted.",
"home.unavailableTitle": "Server is temporarily unavailable",
"home.unavailableMessage": "The server is not ready to serve requests yet. Try again later.",
"error.invalidCredentials": "Invalid username or password.",
"error.internal": "Internal error. Please try again later.",
"admin.eyebrow": "Server administration",
"admin.loginTitle": "Administrator login",
"admin.navigation": "Administrator navigation",
"admin.overview": "Overview",
"admin.access": "Access",
"admin.activeDevices": "Active devices",
"admin.operations": "Operations",
"admin.vaults": "Vaults",
"admin.storage": "Storage",
"admin.audit": "Audit log",
"admin.settings": "Settings",
"admin.diagnostics": "Diagnostics",
"admin.serviceHealth": "Service health",
"admin.lastActivity": "Last activity",
"admin.noVaults": "No vaults yet",
"admin.databaseBytes": "Database size, bytes",
"admin.blobBytes": "Blob storage, bytes",
"admin.retentionNote": "Operations are not deleted automatically: new devices need them to restore state.",
"admin.event": "Event",
"admin.time": "Time",
"admin.noAudit": "No audit events yet",
"admin.database": "Database reachable",
"admin.blobStorage": "Blob storage writable",
"admin.schema": "Schema version",
"admin.serverTime": "Server time",
"admin.healthJSON": "Open health JSON",
"admin.manage": "Manage",
"admin.saveUser": "Save user",
"admin.search": "Search",
"admin.all": "All",
"admin.applyFilters": "Apply",
"admin.pagination": "Pagination",
"admin.previous": "Previous",
"admin.next": "Next",
"admin.runCleanup": "Run safe cleanup",
"admin.downloadDiagnostics": "Download diagnostics",
"admin.vaultPrivacy": "File contents and operations are not displayed in diagnostics.",
"admin.general": "General settings",
"admin.serverName": "Server name",
"admin.allowRegistration": "Allow public registration",
"admin.smtpConfigured": "SMTP is configured separately and its password is never returned to a form.",
"user.account": "My account",
"user.devices": "Connected devices",
"user.emailConfirmed": "Email confirmed",
"user.emailUnconfirmed": "Email not confirmed",
"user.filterDevices": "Search devices",
"user.connectInstruction": "Connect another device in Verstak: open sync settings and pair it with this server.",
"user.deviceRevoked": "Device revoked.",
"user.noDevices": "No devices yet",
"device.name": "Device",
"device.vault": "Vault",
"device.version": "Client version",
"device.lastSeen": "Last activity",
"device.created": "Connected",
"device.status": "Status",
"device.active": "Active",
"device.revoked": "Revoked",
"device.revoke": "Revoke",
"device.revokeConfirm": "Revoke this device? Your password is required to continue.",
"server.registerTitle": "Registration",
"server.register": "Register",
"server.username": "Username",
@ -241,10 +561,71 @@ var _translations = map[string]map[string]string{
"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.settingsSaved": "Server settings saved.",
"admin.smtpSaved": "SMTP settings saved.",
"admin.smtpStartTLS": "STARTTLS",
"admin.smtpTLS": "TLS",
"admin.warningReadiness": "One or more server components are not ready.",
"admin.warningSMTP": "SMTP is not configured: confirmation and password-reset emails will not be sent.",
"admin.warningOperations": "The operation log is growing quickly. Do not prune it without a checkpoint mechanism.",
"admin.ops24h": "in the last 24 hours",
"admin.sort": "Sort",
"admin.created": "Created",
"admin.ip": "IP",
"admin.message": "Message",
"admin.severity": "Severity",
"admin.info": "Info",
"admin.warningLevel": "Warning",
"admin.errorLevel": "Error",
"admin.publicURL": "Public URL",
"admin.saveSettings": "Save settings",
"admin.trustedProxies": "Trusted reverse proxies",
"admin.network": "Network",
"admin.limits": "Limits and retention",
"admin.readOnlyConfig": "Limits are configured on the server and are read-only here.",
"admin.maxJSONBody": "Maximum JSON body",
"admin.maxPushOperations": "Operations per push",
"admin.maxPullPage": "Operations per pull page",
"admin.maxBlobBytes": "Maximum blob size",
"admin.maxVaultBlobBytes": "Vault blob quota",
"admin.maxUserBlobBytes": "User blob quota",
"admin.sequence": "Latest sequence",
"admin.refresh": "Refresh",
"admin.copyDiagnostics": "Copy diagnostics",
"admin.copied": "Copied",
"admin.blobReferences": "Blob references",
"admin.orphanBlobs": "Unreferenced blobs",
"admin.tempUploads": "Temporary uploads",
"admin.expiredSessions": "Expired sessions",
"admin.expiredTokens": "Expired email tokens",
"admin.lastCleanup": "Last cleanup",
"admin.cleanupDone": "Safe cleanup completed.",
"audit.other": "Other event",
"audit.deviceAuthFailed": "Device authentication failed",
"audit.devicePaired": "Device paired",
"audit.deviceRevoked": "Device revoked",
"audit.deviceDeleted": "Revoked device deleted",
"audit.rateLimited": "Rate limit triggered",
"audit.retentionCleanup": "Safe cleanup completed",
"audit.smtpSettingsUpdated": "SMTP settings updated",
"audit.smtpTestFailed": "SMTP test failed",
"audit.smtpTestPassed": "SMTP test passed",
"audit.userBlockChanged": "User block status changed",
"audit.userConfirmed": "User email confirmed",
"audit.userCreated": "User created",
"audit.userDeleted": "User deleted",
"audit.userPasswordReset": "User password reset",
"audit.userUpdated": "User updated",
"audit.webSettingsUpdated": "General settings updated",
"status.ok": "Operational",
"status.degraded": "Needs attention",
"status.available": "Available",
"status.unavailable": "Unavailable",
"admin.revokeConfirm": "Are you sure?",
"common.loading": "Loading...",
"common.ok": "OK",
"common.error": "Error",
"common.warning": "Warnings",
"admin.filterPlaceholder": "Search...",
"admin.email": "Email",
"admin.actions": "Actions",
@ -263,8 +644,14 @@ var _translations = map[string]map[string]string{
"admin.noUsers": "No users",
"admin.resetPasswordConfirm": "Reset Password",
"admin.resetPasswordMessage": "New password: ",
"admin.generatePassword": "Generate one-time password",
"admin.oneTimePasswordNotice": "Save this password now: it is shown only once.",
"admin.oneTimePasswordHint": "It cannot be retrieved after leaving this page. The user should change it after signing in.",
"admin.resetBtn": "Reset",
"admin.deleteUser": "Delete",
"admin.deleteDevice": "Delete device",
"admin.confirmUser": "Confirm email",
"admin.deleteUserConfirm": "Delete this user and all related devices, vault associations, blobs, and operations? This cannot be undone.",
"admin.deleteUserMessage": "Delete user %s?",
"admin.deleteBtn": "Delete",
"admin.unblockUserTitle": "Unblock",

View File

@ -56,7 +56,7 @@ func (s *Server) authenticateDevice(w http.ResponseWriter, r *http.Request) (aut
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).
FROM server_devices WHERE api_key=? AND legacy_api_key=1`, key).
Scan(&device.DeviceID, &userID, &vaultID, &revokedAt)
}
if err != nil {
@ -75,23 +75,25 @@ func (s *Server) authenticateDevice(w http.ResponseWriter, r *http.Request) (aut
}
if device.UserID != "" {
var blocked int
s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", device.UserID).Scan(&blocked)
if err := s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", device.UserID).Scan(&blocked); err != nil {
jsonInternalError(w, err)
return authenticatedDevice{}, false
}
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)
if _, err := s.db.Exec("UPDATE server_devices SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), device.DeviceID); err != nil {
jsonInternalError(w, err)
return authenticatedDevice{}, false
}
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
_, ok := s.requireSession(w, r, sessionScopeAdmin)
return ok
}
type PasswordError string

View File

@ -22,7 +22,7 @@ func (s *Server) resetPasswordWithToken(token, newPassword string) (string, erro
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)
WHERE token_hash=? AND purpose='reset'`, emailTokenHash(token)).Scan(&userID, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return "", errResetTokenInvalid
}
@ -35,7 +35,7 @@ func (s *Server) resetPasswordWithToken(token, newPassword string) (string, erro
}
deleted, err := tx.Exec(`DELETE FROM server_email_tokens
WHERE token=? AND purpose='reset' AND expires_at=?`, token, expiresAt)
WHERE token_hash=? AND purpose='reset' AND expires_at=?`, emailTokenHash(token), expiresAt)
if err != nil {
return "", err
}
@ -62,6 +62,9 @@ func (s *Server) resetPasswordWithToken(token, newPassword string) (string, erro
if updatedCount != 1 {
return "", errResetTokenInvalid
}
if _, err := tx.Exec("DELETE FROM server_email_tokens WHERE user_id=? AND purpose='reset'", userID); err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
return "", err
}

34
internal/server/proxy.go Normal file
View File

@ -0,0 +1,34 @@
package server
import (
"net"
"net/http"
"net/netip"
"strings"
)
// clientIPFromPeer accepts forwarding headers only when the TCP peer is a
// configured trusted proxy. The first X-Forwarded-For address is the original
// client under the documented nginx/Caddy single-proxy configuration.
func (s *Server) clientIPFromPeer(peer, forwardedFor string) string {
peerAddr, err := netip.ParseAddr(strings.TrimSpace(peer))
if err != nil || s == nil || s.cfg == nil || !s.cfg.isTrustedProxy(peerAddr) {
return peer
}
for _, value := range strings.Split(forwardedFor, ",") {
candidate := strings.TrimSpace(value)
if addr, err := netip.ParseAddr(candidate); err == nil {
return addr.String()
}
}
return peerAddr.String()
}
func (s *Server) remotePeerIsTrusted(r *http.Request) bool {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
addr, err := netip.ParseAddr(strings.Trim(host, "[]"))
return err == nil && s != nil && s.cfg != nil && s.cfg.isTrustedProxy(addr)
}

93
internal/server/rate.go Normal file
View File

@ -0,0 +1,93 @@
package server
import (
"math"
"net/http"
"strings"
"time"
)
var ratePolicies = map[string]RatePolicy{
"pair": {Limit: 5, Window: 15 * time.Minute},
"device-register": {Limit: 5, Window: 15 * time.Minute},
"auth-test": {Limit: 10, Window: 10 * time.Minute},
"login": {Limit: 10, Window: 10 * time.Minute},
"register": {Limit: 5, Window: time.Hour},
"forgot": {Limit: 5, Window: time.Hour},
"reset": {Limit: 8, Window: time.Hour},
"admin-reset": {Limit: 8, Window: time.Hour},
}
// rateRetryAfter applies an IP limit and, where a login/account is supplied,
// an additional bounded account bucket. It never logs submitted credentials.
func (s *Server) rateRetryAfter(r *http.Request, action, account string) (int, bool) {
policy, ok := ratePolicies[action]
if !ok {
return 0, true
}
ip := s.clientIP(r)
s.limiter.Cleanup(2 * policy.Window)
keys := []string{action + ":ip:" + ip}
account = strings.ToLower(strings.TrimSpace(account))
if account != "" {
// Keep attacker-controlled account strings out of the in-memory key and
// audit path while preserving an independent per-account bucket.
keys = append(keys, action+":account:"+sha256Hex(account))
}
for _, key := range keys {
if allowed, retryAfter := s.limiter.Allow(key, policy); !allowed {
seconds := int(math.Ceil(retryAfter.Seconds()))
if seconds < 1 {
seconds = 1
}
s.auditLog("rate_limit_exceeded", "", "", ip, "rate limit: "+action)
return seconds, false
}
}
return 0, true
}
// allowRate writes API-compatible JSON for transport endpoints.
func (s *Server) allowRate(w http.ResponseWriter, r *http.Request, action, account string) bool {
retryAfter, allowed := s.rateRetryAfter(r, action, account)
if allowed {
return true
}
w.Header().Set("Retry-After", strconvItoa(retryAfter))
jsonErr(w, http.StatusTooManyRequests, "too many attempts")
return false
}
// allowWebRate is the HTML counterpart of allowRate. Browser forms receive a
// localized error page instead of a machine-readable API error.
func (s *Server) allowWebRate(w http.ResponseWriter, r *http.Request, action, account, back string) bool {
retryAfter, allowed := s.rateRetryAfter(r, action, account)
if allowed {
return true
}
w.Header().Set("Retry-After", strconvItoa(retryAfter))
s.renderWebError(w, r, http.StatusTooManyRequests, "error.rateLimited", back)
return false
}
func strconvItoa(value int) string {
if value == 0 {
return "0"
}
negative := value < 0
if negative {
value = -value
}
var digits [20]byte
index := len(digits)
for value > 0 {
index--
digits[index] = byte('0' + value%10)
value /= 10
}
if negative {
index--
digits[index] = '-'
}
return string(digits[index:])
}

View File

@ -0,0 +1,81 @@
package server
import (
"sync"
"time"
)
// RatePolicy is a fixed-window policy. Buckets expire naturally and cleanup
// prevents unauthenticated traffic from consuming unbounded memory.
type RatePolicy struct {
Limit int
Window time.Duration
}
type rateBucket struct {
Count int
WindowStart time.Time
LastSeen time.Time
}
type rateLimiter struct {
mu sync.Mutex
buckets map[string]rateBucket
now func() time.Time
}
const maxRateLimitBuckets = 10000
func newRateLimiter(now func() time.Time) *rateLimiter {
if now == nil {
now = time.Now
}
return &rateLimiter{buckets: make(map[string]rateBucket), now: now}
}
// Allow consumes one attempt and reports the remaining wait when limited.
func (l *rateLimiter) Allow(key string, policy RatePolicy) (bool, time.Duration) {
if policy.Limit <= 0 || policy.Window <= 0 {
return true, 0
}
now := l.now().UTC()
l.mu.Lock()
defer l.mu.Unlock()
bucket := l.buckets[key]
if bucket.WindowStart.IsZero() && len(l.buckets) >= maxRateLimitBuckets {
// An attacker can vary IP/account keys faster than age cleanup runs.
// Evict the least recently seen bucket to keep memory strictly bounded.
var oldestKey string
var oldest time.Time
for candidate, existing := range l.buckets {
if oldestKey == "" || existing.LastSeen.Before(oldest) {
oldestKey, oldest = candidate, existing.LastSeen
}
}
if oldestKey != "" {
delete(l.buckets, oldestKey)
}
}
if bucket.WindowStart.IsZero() || !now.Before(bucket.WindowStart.Add(policy.Window)) {
bucket = rateBucket{WindowStart: now}
}
bucket.LastSeen = now
if bucket.Count >= policy.Limit {
l.buckets[key] = bucket
return false, bucket.WindowStart.Add(policy.Window).Sub(now)
}
bucket.Count++
l.buckets[key] = bucket
return true, 0
}
func (l *rateLimiter) Cleanup(maxAge time.Duration) {
now := l.now().UTC()
l.mu.Lock()
defer l.mu.Unlock()
for key, bucket := range l.buckets {
if now.Sub(bucket.LastSeen) > maxAge {
delete(l.buckets, key)
}
}
}

View File

@ -0,0 +1,45 @@
package server
import (
"os"
"path/filepath"
"strings"
"time"
)
// CleanupRetention only removes independently expiring data. It intentionally
// never prunes server_ops or content-addressed blobs: the operation log remains
// required for a newly paired device until a future checkpoint protocol exists.
func (s *Server) CleanupRetention(now time.Time) error {
if err := s.cleanupExpiredSessions(); err != nil {
return err
}
if _, err := s.db.Exec("DELETE FROM server_email_tokens WHERE expires_at <= ?", now.UTC().Format(time.RFC3339)); err != nil {
return err
}
if _, err := s.db.Exec("DELETE FROM server_idempotency_keys WHERE created_at < ?", now.Add(-time.Duration(s.cfg.Retention.IdempotencyHours)*time.Hour).UTC().Format(time.RFC3339)); err != nil {
return err
}
if _, err := s.db.Exec("DELETE FROM server_audit_log WHERE created_at < ?", now.AddDate(0, 0, -s.cfg.Retention.AuditDays).UTC().Format(time.RFC3339)); err != nil {
return err
}
entries, err := os.ReadDir(s.blobsDir)
if err != nil && !os.IsNotExist(err) {
return err
}
for _, entry := range entries {
if !strings.HasPrefix(entry.Name(), ".upload-") {
continue
}
info, err := entry.Info()
if err != nil {
return err
}
if info.ModTime().Before(now.Add(-time.Duration(s.cfg.Retention.TempUploadHours) * time.Hour)) {
if err := os.Remove(filepath.Join(s.blobsDir, entry.Name())); err != nil && !os.IsNotExist(err) {
return err
}
}
}
return nil
}

View File

@ -1,7 +1,16 @@
package server
func (s *Server) routes() {
s.mux.HandleFunc("/static/", s.handleStatic)
s.mux.HandleFunc("/locale", s.handleLocale)
s.mux.HandleFunc("/register/result", s.handleRegistrationResult)
s.mux.HandleFunc("/forgot/sent", s.handleForgotSent)
s.mux.HandleFunc("/reset/done", s.handleResetDone)
s.mux.HandleFunc("/confirm/result", s.handleConfirmResult)
s.mux.HandleFunc("/", s.handleHome)
s.mux.HandleFunc("/api/v1/health", s.handleHealth)
s.mux.HandleFunc("/livez", s.handleLiveness)
s.mux.HandleFunc("/readyz", 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)
@ -23,12 +32,25 @@ func (s *Server) routes() {
s.mux.HandleFunc("/reset", s.handleUserWebReset)
s.mux.HandleFunc("/logout", s.handleUserWebLogout)
s.mux.HandleFunc("/api/v1/user/devices", s.handleUserDevices)
s.mux.HandleFunc("/api/v1/user/devices/", s.handleUserWebDeviceAction)
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", s.handleAdminRoot)
s.mux.HandleFunc("/admin/logout", s.handleAdminWebLogout)
s.mux.HandleFunc("/admin/action", s.handleAdminWebAction)
s.mux.HandleFunc("/admin/password-result", s.handleAdminPasswordResult)
s.mux.HandleFunc("/admin/password-result/secret", s.handleAdminPasswordResultSecret)
s.mux.HandleFunc("/admin/dashboard", s.handleAdminWeb)
s.mux.HandleFunc("/admin/users", s.handleAdminWeb)
s.mux.HandleFunc("/admin/create-user", s.handleAdminCreateUserWeb)
s.mux.HandleFunc("/admin/api/users/create", s.handleAdminAPICreateUser)
s.mux.HandleFunc("/admin/devices", s.handleAdminDevices)
s.mux.HandleFunc("/admin/devices", s.handleAdminWeb)
s.mux.HandleFunc("/admin/vaults", s.handleAdminWeb)
s.mux.HandleFunc("/admin/vault/", s.handleAdminVaultDetail)
s.mux.HandleFunc("/admin/storage", s.handleAdminWeb)
s.mux.HandleFunc("/admin/audit", s.handleAdminWeb)
s.mux.HandleFunc("/admin/settings", s.handleAdminWeb)
s.mux.HandleFunc("/admin/diagnostics", s.handleAdminWeb)
s.mux.HandleFunc("/admin/diagnostics.json", s.handleAdminDiagnosticsJSON)
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)
@ -37,5 +59,4 @@ func (s *Server) routes() {
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)
}

View File

@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS server_devices (
token_hash TEXT,
token_prefix TEXT,
token_suffix TEXT,
legacy_api_key INTEGER NOT NULL DEFAULT 0,
user_id TEXT,
vault_id TEXT,
client_version TEXT,
@ -76,13 +77,23 @@ CREATE TABLE IF NOT EXISTS server_idempotency_keys (
);
CREATE TABLE IF NOT EXISTS server_email_tokens (
token TEXT PRIMARY KEY,
token_hash 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_sessions (
token_hash TEXT PRIMARY KEY,
csrf_hash TEXT NOT NULL,
scope TEXT NOT NULL,
subject_id TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
last_seen TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
op_id TEXT NOT NULL,
@ -95,6 +106,16 @@ CREATE TABLE IF NOT EXISTS server_blobs (
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_blob_refs (
user_id TEXT NOT NULL,
vault_id TEXT NOT NULL,
sha256 TEXT NOT NULL,
size INTEGER NOT NULL,
created_at TEXT NOT NULL,
last_accessed TEXT NOT NULL,
PRIMARY KEY (user_id, vault_id, sha256)
);
CREATE TABLE IF NOT EXISTS server_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
@ -114,6 +135,10 @@ 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 INDEX IF NOT EXISTS idx_server_blob_refs_scope ON server_blob_refs(user_id, vault_id, sha256);
CREATE INDEX IF NOT EXISTS idx_server_blob_refs_user ON server_blob_refs(user_id, sha256);
CREATE INDEX IF NOT EXISTS idx_server_sessions_expiry ON server_sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_server_sessions_subject ON server_sessions(scope, subject_id);
CREATE TABLE IF NOT EXISTS server_smtp_config (
key TEXT PRIMARY KEY,
@ -125,6 +150,8 @@ type sqliteColumn struct {
primaryKeyOrder int
}
const schemaVersion = 2
func migrateServerSchema(db *sql.DB) error {
tx, err := db.Begin()
if err != nil {
@ -150,6 +177,12 @@ func migrateServerSchema(db *sql.DB) error {
if err := backfillDeviceOwners(tx); err != nil {
return err
}
if err := migrateLegacyDeviceCredentials(tx); err != nil {
return err
}
if err := migrateEmailTokenHashes(tx); err != nil {
return err
}
if err := backfillOperationScope(tx); err != nil {
return err
}
@ -167,10 +200,67 @@ func migrateServerSchema(db *sql.DB) error {
ON server_devices(user_id, vault_id)`); err != nil {
return err
}
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", schemaVersion)); err != nil {
return err
}
return tx.Commit()
}
func migrateLegacyDeviceCredentials(tx *sql.Tx) error {
if err := ensureSQLiteColumn(tx, "server_devices", "legacy_api_key", "INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
// Existing rows predate the hash-only enrollment path. Their API keys keep
// working only when explicitly marked legacy; devices enrolled by this
// version store a disabled placeholder and can never authenticate with it.
_, err := tx.Exec(`UPDATE server_devices SET legacy_api_key=1
WHERE legacy_api_key=0 AND api_key NOT LIKE 'disabled:%'`)
return err
}
func migrateEmailTokenHashes(tx *sql.Tx) error {
columns, err := sqliteTableColumns(tx, "server_email_tokens")
if err != nil {
return err
}
if _, ok := columns["token_hash"]; ok {
return nil
}
if _, err := tx.Exec("ALTER TABLE server_email_tokens RENAME TO server_email_tokens_legacy"); err != nil {
return err
}
if _, err := tx.Exec(`CREATE TABLE server_email_tokens (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
purpose TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
)`); err != nil {
return err
}
rows, err := tx.Query(`SELECT token, user_id, purpose, expires_at, created_at FROM server_email_tokens_legacy`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var token, userID, purpose, expiresAt, createdAt string
if err := rows.Scan(&token, &userID, &purpose, &expiresAt, &createdAt); err != nil {
return err
}
if _, err := tx.Exec(`INSERT INTO server_email_tokens (token_hash, user_id, purpose, expires_at, created_at)
VALUES (?, ?, ?, ?, ?)`, sha256Hex(token), userID, purpose, expiresAt, createdAt); err != nil {
return err
}
}
if err := rows.Err(); err != nil {
return err
}
_, err = tx.Exec("DROP TABLE server_email_tokens_legacy")
return err
}
func ensureSQLiteColumn(tx *sql.Tx, table, column, definition string) error {
columns, err := sqliteTableColumns(tx, table)
if err != nil {

View File

@ -1,10 +1,9 @@
package server
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
@ -15,119 +14,56 @@ import (
_ "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
dbPath string
cfg *Config
tokens *tokenStore
userTokens *userTokenStore
blobsDir string
mux *http.ServeMux
pairLimit *pairRateLimit
limiter *rateLimiter
web *webRenderer
startedAt time.Time
secretMu sync.Mutex
webSecrets map[string]oneTimeWebSecret
}
// Version and BuildCommit are assigned through -ldflags during release builds.
var (
Version = "dev"
BuildCommit = "unknown"
)
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) {
if cfg == nil {
cfg = DefaultConfig()
}
if cfg.path == "" {
cfg.path = filepath.Join(dataDir, "config.yml")
}
if err := cfg.normalize(); err != nil {
return nil, fmt.Errorf("config: %w", err)
}
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 _, pragma := range []string{
"PRAGMA foreign_keys = ON",
"PRAGMA busy_timeout = 5000",
"PRAGMA journal_mode = WAL",
"PRAGMA synchronous = NORMAL",
} {
if _, err := db.Exec(pragma); err != nil {
db.Close()
return nil, fmt.Errorf("sqlite %s: %w", pragma, err)
}
}
for _, stmt := range strings.Split(serverSchema, ";") {
stmt = strings.TrimSpace(stmt)
@ -150,13 +86,20 @@ func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) {
return nil, err
}
web, err := newWebRenderer()
if err != nil {
db.Close()
return nil, fmt.Errorf("web templates: %w", err)
}
s := &Server{
db: db,
dbPath: dbPath,
cfg: cfg,
tokens: newTokenStore(),
userTokens: newUserTokenStore(),
blobsDir: blobsDir,
pairLimit: &pairRateLimit{},
limiter: newRateLimiter(nil),
web: web,
startedAt: time.Now().UTC(),
webSecrets: make(map[string]oneTimeWebSecret),
}
s.mux = http.NewServeMux()
return s, nil
@ -167,13 +110,46 @@ func (s *Server) SetupRoutes() {
}
func (s *Server) locale() string {
return "ru"
if s != nil && s.cfg != nil && isSupportedWebLocale(s.cfg.Web.DefaultLocale) {
return s.cfg.Web.DefaultLocale
}
return "en"
}
func (s *Server) Close() error {
return s.db.Close()
}
func (s *Server) ListenAndServe(addr string) error {
return http.ListenAndServe(addr, s.mux)
// Handler is the only HTTP entrypoint. Additional request security middleware
// is composed here so tests and production use the same path.
func (s *Server) Handler() http.Handler {
return securityHeaders(s.mux)
}
// HTTPServer creates a conservatively configured server suitable for running
// behind nginx or Caddy. It intentionally does not enable TLS itself.
func (s *Server) HTTPServer(addr string) *http.Server {
return &http.Server{
Addr: addr,
Handler: s.Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 16 << 10,
}
}
// ListenAndServe exists for callers that do not need to manage lifecycle. The
// command entrypoint uses HTTPServer plus graceful shutdown instead.
func (s *Server) ListenAndServe(addr string) error {
return s.HTTPServer(addr).ListenAndServe()
}
func (s *Server) clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
return s.clientIPFromPeer(host, r.Header.Get("X-Forwarded-For"))
}

View File

@ -64,23 +64,6 @@ func TestConfigSetAdmin(t *testing.T) {
}
}
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)
@ -111,7 +94,7 @@ func TestSyncPushPullStoresSequencedOps(t *testing.T) {
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 (?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO server_devices (id, name, api_key, legacy_api_key, user_id, vault_id, last_seen, created_at) VALUES (?, ?, ?, 1, ?, ?, ?, ?)",
"device-a", "Device A", "api-key", "user-a", "vault-a", now, now,
); err != nil {
t.Fatalf("insert device: %v", err)
@ -185,7 +168,7 @@ func TestRevokedLegacyAPIKeyCannotPushOrPull(t *testing.T) {
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 (?, ?, ?, ?, ?, ?)",
"INSERT INTO server_devices (id, name, api_key, legacy_api_key, last_seen, revoked_at, created_at) VALUES (?, ?, ?, 1, ?, ?, ?)",
"device-revoked", "Revoked Device", "revoked-key", now, now, now,
); err != nil {
t.Fatalf("insert device: %v", err)
@ -389,8 +372,8 @@ func TestWebResetRejectsExpiredToken(t *testing.T) {
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 {
(token_hash, user_id, purpose, expires_at, created_at)
VALUES (?, ?, 'reset', ?, ?)`, emailTokenHash("expired-reset-token"), "user-a", time.Now().Add(-time.Hour).UTC().Format(time.RFC3339), now); err != nil {
t.Fatalf("insert reset token: %v", err)
}
@ -438,18 +421,18 @@ func TestServerRenderedPagesEscapeStoredValues(t *testing.T) {
{
name: "user dashboard",
path: "/dashboard",
cookie: &http.Cookie{Name: "user_session", Value: s.userTokens.Create("user-a")},
cookie: testSessionCookie(t, s, sessionScopeUser, "user-a"),
containsDevID: true,
},
{
name: "admin users",
path: "/admin/users",
cookie: &http.Cookie{Name: "admin_session", Value: s.tokens.Create()},
cookie: testSessionCookie(t, s, sessionScopeAdmin, "admin"),
},
{
name: "admin devices",
path: "/admin/devices",
cookie: &http.Cookie{Name: "admin_session", Value: s.tokens.Create()},
cookie: testSessionCookie(t, s, sessionScopeAdmin, "admin"),
containsDevID: true,
},
}
@ -476,6 +459,19 @@ func TestServerRenderedPagesEscapeStoredValues(t *testing.T) {
}
}
func testSessionCookie(t *testing.T, s *Server, scope, subjectID string) *http.Cookie {
t.Helper()
token, _, err := s.createSession(scope, subjectID)
if err != nil {
t.Fatalf("create test session: %v", err)
}
name := "user_session"
if scope == sessionScopeAdmin {
name = "admin_session"
}
return &http.Cookie{Name: name, Value: token}
}
func TestNewServerMigratesLegacyOperationScope(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "legacy.db")
@ -644,12 +640,14 @@ func pairSyncDevice(t *testing.T, serverURL, username, password, vaultID string)
func postWebReset(t *testing.T, s *Server, token, password string) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{
"token": {token},
"password": {password},
"confirm": {password},
"token": {token},
"password": {password},
"confirm": {password},
"locale_csrf": {"test-public-csrf"},
}
request := httptest.NewRequest(http.MethodPost, "/reset", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(&http.Cookie{Name: webLocaleCSRFCookieName, Value: "test-public-csrf"})
response := httptest.NewRecorder()
s.mux.ServeHTTP(response, request)
return response

173
internal/server/sessions.go Normal file
View File

@ -0,0 +1,173 @@
package server
import (
"crypto/rand"
"crypto/subtle"
"database/sql"
"encoding/hex"
"fmt"
"net/http"
"strings"
"time"
)
const (
sessionScopeAdmin = "admin"
sessionScopeUser = "user"
sessionLifetime = 24 * time.Hour
)
type webSession struct {
Scope string
SubjectID string
CSRFHash string
ExpiresAt time.Time
}
func randomSecret(bytes int) (string, error) {
b := make([]byte, bytes)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// createSession stores only hashes. The plaintext session and CSRF values are
// returned once to be placed in cookies or an API login response.
func (s *Server) createSession(scope, subjectID string) (token, csrf string, err error) {
if scope != sessionScopeAdmin && scope != sessionScopeUser {
return "", "", fmt.Errorf("unknown session scope")
}
token, err = randomSecret(32)
if err != nil {
return "", "", err
}
csrf, err = randomSecret(32)
if err != nil {
return "", "", err
}
now := time.Now().UTC()
_, err = s.db.Exec(`INSERT INTO server_sessions
(token_hash, csrf_hash, scope, subject_id, expires_at, created_at, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
sha256Hex(token), sha256Hex(csrf), scope, subjectID,
now.Add(sessionLifetime).Format(time.RFC3339), now.Format(time.RFC3339), now.Format(time.RFC3339))
if err != nil {
return "", "", err
}
return token, csrf, nil
}
func (s *Server) loadSession(token, scope string) (webSession, bool) {
if token == "" {
return webSession{}, false
}
var session webSession
var storedTokenHash string
var expiresAt string
err := s.db.QueryRow(`SELECT token_hash, csrf_hash, scope, subject_id, expires_at
FROM server_sessions WHERE token_hash=? AND scope=?`, sha256Hex(token), scope).
Scan(&storedTokenHash, &session.CSRFHash, &session.Scope, &session.SubjectID, &expiresAt)
if err != nil {
return webSession{}, false
}
expires, err := time.Parse(time.RFC3339, expiresAt)
if err != nil || !time.Now().UTC().Before(expires) {
_, _ = s.db.Exec("DELETE FROM server_sessions WHERE token_hash=?", sha256Hex(token))
return webSession{}, false
}
session.ExpiresAt = expires
// The database lookup is indexed by a fixed-size hash. Keep a constant-time
// comparison at the final token-hash boundary as well.
if subtle.ConstantTimeCompare([]byte(storedTokenHash), []byte(sha256Hex(token))) != 1 {
return webSession{}, false
}
_, _ = s.db.Exec("UPDATE server_sessions SET last_seen=? WHERE token_hash=?", time.Now().UTC().Format(time.RFC3339), sha256Hex(token))
return session, true
}
func (s *Server) deleteSession(token string) error {
if token == "" {
return nil
}
_, err := s.db.Exec("DELETE FROM server_sessions WHERE token_hash=?", sha256Hex(token))
return err
}
func (s *Server) deleteSessionsForSubject(scope, subjectID string) error {
_, err := s.db.Exec("DELETE FROM server_sessions WHERE scope=? AND subject_id=?", scope, subjectID)
return err
}
func (s *Server) setSessionCookies(w http.ResponseWriter, r *http.Request, scope, token, csrf string) {
name := "user_session"
path := "/"
if scope == sessionScopeAdmin {
name = "admin_session"
path = "/admin"
}
secure := s.requestIsHTTPS(r)
http.SetCookie(w, &http.Cookie{
Name: name, Value: token, Path: path, HttpOnly: true, Secure: secure,
SameSite: http.SameSiteLaxMode, MaxAge: int(sessionLifetime.Seconds()),
})
http.SetCookie(w, &http.Cookie{
Name: "csrf_token", Value: csrf, Path: path, HttpOnly: false, Secure: secure,
SameSite: http.SameSiteStrictMode, MaxAge: int(sessionLifetime.Seconds()),
})
}
func (s *Server) clearSessionCookies(w http.ResponseWriter, r *http.Request, scope string) {
name := "user_session"
path := "/"
if scope == sessionScopeAdmin {
name = "admin_session"
path = "/admin"
}
secure := s.requestIsHTTPS(r)
for _, cookieName := range []string{name, "csrf_token"} {
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: path, HttpOnly: cookieName != "csrf_token", Secure: secure, MaxAge: -1})
}
}
func (s *Server) requestIsHTTPS(r *http.Request) bool {
if r.TLS != nil {
return true
}
if s == nil || s.cfg == nil {
return false
}
if !s.remotePeerIsTrusted(r) {
return false
}
return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https")
}
func (s *Server) requireSession(w http.ResponseWriter, r *http.Request, scope string) (webSession, bool) {
name := "user_session"
login := "/login"
if scope == sessionScopeAdmin {
name = "admin_session"
login = "/admin/login"
}
cookie, err := r.Cookie(name)
if err != nil {
http.Redirect(w, r, login, http.StatusFound)
return webSession{}, false
}
session, ok := s.loadSession(cookie.Value, scope)
if !ok {
http.Redirect(w, r, login, http.StatusFound)
return webSession{}, false
}
return session, true
}
func (s *Server) cleanupExpiredSessions() error {
_, err := s.db.Exec("DELETE FROM server_sessions WHERE expires_at <= ?", time.Now().UTC().Format(time.RFC3339))
return err
}
func isNoRows(err error) bool {
return err == sql.ErrNoRows
}

View File

@ -0,0 +1,95 @@
package server
import (
"encoding/json"
"fmt"
"strings"
)
const (
maxOpIDLength = 128
maxEntityTypeLength = 64
maxEntityIDLength = 4096
maxOpTypeLength = 64
maxIdempotencyKeyLength = 128
maxDeviceIDLength = 128
maxDeviceNameLength = 128
maxClientVersionLength = 256
maxVaultIDLength = 256
maxLoginLength = 320
)
type syncPushOperation 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"`
}
type syncPushRequest struct {
DeviceID string `json:"device_id"`
IdempotencyKey string `json:"idempotency_key"`
Ops []syncPushOperation `json:"ops"`
}
type syncPullRequest struct {
SinceSequence int `json:"since_sequence"`
PageLimit int `json:"page_limit"`
}
func (s *Server) validateSyncPush(req syncPushRequest) (code, message string) {
if len(req.Ops) > s.cfg.Limits.MaxPushOperations {
return "too_many_operations", "too many operations in one push"
}
for name, value := range map[string]struct {
value string
max int
}{
"device_id": {req.DeviceID, maxDeviceIDLength},
"idempotency_key": {req.IdempotencyKey, maxIdempotencyKeyLength},
} {
if err := validateStringLength(name, value.value, value.max); err != nil {
return "field_too_long", err.Error()
}
}
for _, op := range req.Ops {
for _, value := range []struct {
name string
value string
max int
}{
{"op_id", op.OpID, maxOpIDLength},
{"entity_type", op.EntityType, maxEntityTypeLength},
{"entity_id", op.EntityID, maxEntityIDLength},
{"op_type", op.OpType, maxOpTypeLength},
} {
if strings.TrimSpace(value.value) == "" {
return "invalid_operation", fmt.Sprintf("%s is required", value.name)
}
if err := validateStringLength(value.name, value.value, value.max); err != nil {
return "field_too_long", err.Error()
}
}
if len(op.PayloadJSON) > s.cfg.Limits.MaxPayloadJSON {
return "payload_too_large", "operation payload is too large"
}
if op.PayloadJSON != "" && !json.Valid([]byte(op.PayloadJSON)) {
return "invalid_payload", "operation payload_json must be valid JSON"
}
if op.ClientSequence < 0 || op.LastSeenServerSeq < 0 {
return "invalid_operation", "operation sequences must be non-negative"
}
}
return "", ""
}
func (s *Server) pullPageLimit(requested int) int {
if requested <= 0 || requested > s.cfg.Limits.MaxPullPage {
return s.cfg.Limits.MaxPullPage
}
return requested
}

View File

@ -1,823 +0,0 @@
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"),
)
}

View File

@ -0,0 +1,58 @@
:root { color-scheme: dark; --bg:#10151a; --surface:#172128; --surface-2:#1e2b33; --line:#2b3b45; --text:#e5eef1; --muted:#9bb0b8; --accent:#4fd1b5; --accent-ink:#08231f; --danger:#f08080; --focus:#8ee7d5; }
* { box-sizing:border-box; }
body { min-width:320px; margin:0; background:var(--bg); color:var(--text); font:15px/1.5 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
.site-header,.site-footer { display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:1rem clamp(1rem,4vw,4rem); border-bottom:1px solid var(--line); }
.site-footer { border-top:1px solid var(--line); border-bottom:0; color:var(--muted); font-size:.875rem; }
.brand { display:inline-flex; align-items:center; gap:.65rem; color:var(--text); font-weight:700; text-decoration:none; }
.site-nav { display:flex; gap:.9rem; }
.site-nav a { color:var(--muted); text-decoration:none; }
.site-nav a:hover,.site-nav a:focus-visible { color:var(--accent); }
.locale-form { margin-left:auto; }
.locale-form select { min-height:34px; padding:.3rem .55rem; border:1px solid var(--line); border-radius:7px; background:var(--surface-2); color:var(--text); }
.page-shell { width:min(100% - 2rem,1100px); min-height:calc(100vh - 150px); margin:0 auto; padding:clamp(2rem,6vw,5rem) 0; }
.card { border:1px solid var(--line); border-radius:16px; background:linear-gradient(145deg,var(--surface),#142027); box-shadow:0 14px 40px #0004; }
.hero { max-width:720px; padding:clamp(2rem,7vw,5rem); }
.auth-card,.message-card { width:min(100%,480px); margin:0 auto; padding:2rem; }
.eyebrow { margin:0; color:var(--accent); font-size:.8rem; font-weight:700; letter-spacing:.12em; text-transform:uppercase; }
.danger-text { color:var(--danger); }
.hero h1 { max-width:14ch; margin:.6rem 0; font-size:clamp(2.2rem,6vw,4.6rem); line-height:1.05; }
.lead { max-width:58ch; color:var(--muted); font-size:1.1rem; }
.actions { display:flex; flex-wrap:wrap; gap:.75rem; margin-top:2rem; }
.button { display:inline-flex; align-items:center; justify-content:center; min-height:42px; padding:.6rem 1rem; border:1px solid transparent; border-radius:8px; font:inherit; font-weight:650; text-decoration:none; cursor:pointer; }
.button.primary { background:var(--accent); color:var(--accent-ink); }
.button.secondary { border-color:var(--line); background:transparent; color:var(--text); }
.button.danger { background:#4c2529; color:#ffd9d9; }
.button:hover { filter:brightness(1.08); }
:focus-visible { outline:3px solid var(--focus); outline-offset:3px; }
.stack { display:grid; gap:1rem; }
.stack label { display:grid; gap:.35rem; font-weight:650; }
.stack input:not([type="checkbox"]),.stack select,.inline-form input { width:100%; min-height:42px; padding:.55rem .65rem; border:1px solid var(--line); border-radius:8px; background:#0d151a; color:var(--text); font:inherit; }
.check-field { display:flex !important; align-items:center; gap:.55rem; }.check-field input[type="checkbox"] { width:1.1rem; height:1.1rem; margin:0; accent-color:var(--accent); }
.flash { padding:.75rem; border-radius:8px; }
.flash.error { border:1px solid #6e343b; background:#3a2025; color:#ffd9d9; }
.flash.success { border:1px solid #286052; background:#173d36; color:#a5f1df; }
.one-time-secret { display:block; overflow-wrap:anywhere; padding:.8rem; border:1px solid var(--accent); border-radius:.55rem; background:#0c171d; color:var(--accent); font-size:1.1rem; }
.warning { border-color:#6b5b28; background:#302a16; color:#ffedb0; }
.dashboard-head { display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; margin-bottom:1.25rem; }
.table-card { padding:1.25rem; }
.table-scroll { overflow-x:auto; }
table { width:100%; min-width:720px; border-collapse:collapse; }
th,td { padding:.75rem; border-bottom:1px solid var(--line); text-align:left; vertical-align:middle; }
th { color:var(--muted); font-size:.8rem; letter-spacing:.06em; text-transform:uppercase; }
.badge { display:inline-flex; padding:.2rem .55rem; border-radius:999px; font-size:.8rem; font-weight:700; }
.badge.ok { background:#173d36; color:#91ecd9; }.badge.danger { background:#4c2529; color:#ffd9d9; }
.badge.warning { background:#4b421d; color:#ffed9a; }
.inline-form { display:flex; min-width:250px; gap:.4rem; }.inline-form input { min-width:120px; }
.link-button { padding:0; border:0; background:transparent; color:var(--muted); font:inherit; cursor:pointer; }
.admin-shell { display:grid; grid-template-columns:220px minmax(0,1fr); gap:1.5rem; }
.admin-nav { display:grid; align-content:start; gap:.35rem; padding:1rem; border:1px solid var(--line); border-radius:12px; background:var(--surface); }
.admin-nav a { padding:.55rem .65rem; border-radius:7px; color:var(--muted); text-decoration:none; }.admin-nav a[aria-current="page"],.admin-nav a:hover { background:var(--surface-2); color:var(--accent); }
.admin-content h1 { margin-top:0; }.panel { padding:1.25rem; }.section-heading { display:flex; align-items:center; justify-content:space-between; gap:1rem; margin-bottom:1rem; }
.stat-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:1rem; margin:1rem 0; }.stat { display:grid; gap:.35rem; padding:1.2rem; }.stat strong { color:var(--accent); font-size:1.8rem; }.stat span { color:var(--muted); }
.details { display:grid; grid-template-columns:minmax(150px,auto) 1fr; gap:.6rem 1rem; }.details dt { color:var(--muted); }.details dd { margin:0; overflow-wrap:anywhere; }
details { margin-top:.6rem; } summary { cursor:pointer; color:var(--accent); }.compact { margin-top:.7rem; }.compact input { min-height:36px; }
.list-filter,.pager { display:flex; flex-wrap:wrap; align-items:end; gap:.65rem; margin:0 0 1rem; }.list-filter label { display:grid; gap:.3rem; color:var(--muted); }.list-filter input,.list-filter select { min-height:38px; padding:.4rem .55rem; border:1px solid var(--line); border-radius:7px; background:#0d151a; color:var(--text); }.pager { justify-content:flex-end; align-items:center; }
.mono { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }.muted,.empty { color:var(--muted); }.empty { padding:2rem; text-align:center; }
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
.confirm-dialog { width:min(calc(100% - 2rem),420px); margin:auto; padding:1.25rem; border:1px solid var(--line); border-radius:14px; background:var(--surface); color:var(--text); box-shadow:0 24px 64px #0009; }.confirm-dialog::backdrop { background:#05080bb8; }.confirm-dialog h2,.confirm-dialog p { margin:0; }
@media (max-width:760px) { .site-header,.site-footer,.dashboard-head,.section-heading { align-items:flex-start; flex-direction:column; }.locale-form { margin-left:0; }.page-shell { width:min(100% - 1.25rem,1100px); padding:2rem 0; }.hero { padding:1.5rem; }.inline-form { min-width:0; flex-wrap:wrap; }.admin-shell { grid-template-columns:1fr; }.admin-nav { grid-template-columns:repeat(2,minmax(0,1fr)); }.admin-nav .eyebrow { grid-column:1 / -1; } }

View File

@ -0,0 +1,46 @@
document.addEventListener("change", function (event) {
if (event.target.matches("[data-auto-submit]")) event.target.form.requestSubmit();
});
const confirmationDialog = document.getElementById("confirm-dialog");
let confirmationForm = null;
let confirmationButton = null;
document.addEventListener("click", function (event) {
const button = event.target.closest("[data-confirm]");
if (!button || !button.form || !confirmationDialog) return;
event.preventDefault();
confirmationForm = button.form;
confirmationButton = button;
document.getElementById("confirm-dialog-message").textContent = button.dataset.confirm;
confirmationDialog.showModal();
});
if (confirmationDialog) {
confirmationDialog.addEventListener("close", function () {
if (confirmationDialog.returnValue === "confirm" && confirmationForm) {
confirmationForm.requestSubmit(confirmationButton);
}
confirmationForm = null;
confirmationButton = null;
});
}
document.addEventListener("click", async function (event) {
const button = event.target.closest("[data-copy-diagnostics]");
if (!button || !navigator.clipboard) return;
try {
const response = await fetch(button.dataset.copyDiagnostics, { credentials: "same-origin" });
if (!response.ok) return;
await navigator.clipboard.writeText(await response.text());
button.textContent = button.dataset.copiedLabel;
} catch (_) {
// The downloadable JSON link remains available when clipboard access is unavailable.
}
});
const oneTimeSecret = document.querySelector("[data-one-time-secret-url]");
if (oneTimeSecret) {
fetch(oneTimeSecret.dataset.oneTimeSecretUrl, { method: "POST", credentials: "same-origin", headers: { "X-CSRF-Token": oneTimeSecret.dataset.csrfToken } })
.then(async function (response) { if (!response.ok) throw new Error("one-time secret unavailable"); return response.json(); })
.then(function (data) { oneTimeSecret.querySelector(".one-time-secret").textContent = data.password; })
.catch(function () { oneTimeSecret.querySelector(".one-time-secret").textContent = "—"; });
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none"><path fill="#4fd1b5" d="M8 10h48v12H8zM14 26h36v12H14zM20 42h24v12H20z"/><path stroke="#e5eef1" stroke-width="4" d="M10 8h44v48H10z"/></svg>

After

Width:  |  Height:  |  Size: 213 B

View File

@ -0,0 +1,9 @@
{{define "admin_audit"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
<p class="eyebrow">{{t .Locale "admin.diagnostics"}}</p><h1>{{t .Locale "admin.audit"}}</h1>
<form class="list-filter" method="get" action="{{.CurrentPath}}"><label>{{t .Locale "admin.search"}}<input name="q" value="{{.List.Query}}" maxlength="160" placeholder="{{t .Locale "admin.search"}}"></label><label>{{t .Locale "admin.event"}}<input name="event" value="{{.List.Event}}" maxlength="160"></label><label>{{t .Locale "admin.user"}}<input name="user" value="{{.List.User}}" maxlength="160"></label><label>{{t .Locale "admin.severity"}}<select name="severity"><option value="">{{t .Locale "admin.all"}}</option><option value="info" {{if eq .List.Severity "info"}}selected{{end}}>{{t .Locale "admin.info"}}</option><option value="warning" {{if eq .List.Severity "warning"}}selected{{end}}>{{t .Locale "admin.warningLevel"}}</option><option value="error" {{if eq .List.Severity "error"}}selected{{end}}>{{t .Locale "admin.errorLevel"}}</option></select></label><button class="button secondary" type="submit">{{t .Locale "admin.applyFilters"}}</button></form>
<section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "admin.time"}}</th><th>{{t .Locale "admin.event"}}</th><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "admin.ip"}}</th><th>{{t .Locale "admin.severity"}}</th><th>{{t .Locale "admin.message"}}</th></tr></thead><tbody>{{range .Audit}}<tr><td>{{webtime $.Locale .At}}</td><td>{{auditlabel $.Locale .Event}}</td><td>{{.User}}</td><td>{{.Device}}</td><td>{{.IP}}</td><td><span class="badge {{if eq .Severity "error"}}danger{{else if eq .Severity "warning"}}warning{{else}}ok{{end}}">{{if eq .Severity "error"}}{{t $.Locale "admin.errorLevel"}}{{else if eq .Severity "warning"}}{{t $.Locale "admin.warningLevel"}}{{else}}{{t $.Locale "admin.info"}}{{end}}</span></td><td>{{.Message}}</td></tr>{{else}}<tr><td colspan="7" class="empty">{{t .Locale "admin.noAudit"}}</td></tr>{{end}}</tbody></table></div></section>
<nav class="pager" aria-label="{{t .Locale "admin.pagination"}}"><span>{{.List.Total}}</span>{{if .List.Previous}}<a class="button secondary" href="{{.CurrentPath}}?{{listparams .List .List.Previous}}">{{t .Locale "admin.previous"}}</a>{{end}}<span>{{.List.Page}} / {{.List.Pages}}</span>{{if .List.Next}}<a class="button secondary" href="{{.CurrentPath}}?{{listparams .List .List.Next}}">{{t .Locale "admin.next"}}</a>{{end}}</nav>
</div></section>
{{end}}

View File

@ -0,0 +1,2 @@
{{define "admin_create_user"}}{{template "layout" .}}{{end}}
{{define "content"}}<section class="auth-card card"><p class="eyebrow">{{t .Locale "admin.access"}}</p><h1>{{t .Locale "admin.createUser"}}</h1>{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}<form method="post" action="/admin/create-user" class="stack"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><label>{{t .Locale "field.username"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.email"}}<input name="email" type="email" autocomplete="email" required></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="new-password" minlength="8" maxlength="256" required></label><button class="button primary" type="submit">{{t .Locale "admin.createUser"}}</button></form><p class="muted"><a href="/admin/users">{{t .Locale "common.back"}}</a></p></section>{{end}}

View File

@ -0,0 +1,10 @@
{{define "admin_dashboard"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
<p class="eyebrow">{{t .Locale "admin.overview"}}</p><h1>{{t .Locale "admin.dashboard"}}</h1>
<div class="stat-grid"><article class="card stat"><strong>{{.Stats.Users}}</strong><span>{{t .Locale "admin.users"}} · {{.Stats.ActiveUsers}} {{t .Locale "device.active"}}</span></article><article class="card stat"><strong>{{.Stats.ActiveDevices}}</strong><span>{{t .Locale "admin.activeDevices"}} · {{.Stats.RevokedDevices}} {{t .Locale "device.revoked"}}</span></article><article class="card stat"><strong>{{.Stats.Vaults}}</strong><span>{{t .Locale "admin.vaults"}}</span></article><article class="card stat"><strong>{{.Stats.Operations}}</strong><span>{{t .Locale "admin.operations"}} · {{.Stats.Operations24h}} {{t .Locale "admin.ops24h"}}</span></article><article class="card stat"><strong>{{webbytes .Stats.DatabaseBytes}}</strong><span>{{t .Locale "admin.databaseBytes"}}</span></article><article class="card stat"><strong>{{webbytes .Stats.BlobBytes}}</strong><span>{{.Stats.Blobs}} {{t .Locale "admin.blobBytes"}}</span></article></div>
{{if .Warnings}}<section class="card panel warning"><h2>{{t .Locale "common.warning"}}</h2>{{range .Warnings}}<p>{{t $.Locale .}}</p>{{end}}</section>{{end}}
<section class="card panel"><h2>{{t .Locale "admin.serviceHealth"}}</h2><dl class="details"><dt>{{t .Locale "admin.status"}}</dt><dd>{{statuslabel .Locale .Health.Status}}</dd><dt>{{t .Locale "admin.version"}}</dt><dd>{{.Health.Version}} {{.Health.BuildCommit}}</dd><dt>{{t .Locale "admin.lastActivity"}}</dt><dd>{{webtime .Locale .Stats.LastSyncAt}}</dd><dt>{{t .Locale "admin.serverTime"}}</dt><dd>{{webtime .Locale .Health.ServerTime}}</dd></dl></section>
<section class="card table-card"><h2>{{t .Locale "admin.audit"}}</h2><div class="table-scroll"><table><thead><tr><th>{{t .Locale "admin.event"}}</th><th>{{t .Locale "admin.time"}}</th></tr></thead><tbody>{{range .Audit}}<tr><td>{{auditlabel $.Locale .Event}}</td><td>{{webtime $.Locale .At}}</td></tr>{{else}}<tr><td colspan="2" class="empty">{{t .Locale "admin.noAudit"}}</td></tr>{{end}}</tbody></table></div></section>
</div></section>
{{end}}

View File

@ -0,0 +1,9 @@
{{define "admin_devices"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
<p class="eyebrow">{{t .Locale "admin.access"}}</p><h1>{{t .Locale "admin.devices"}}</h1>
<form class="list-filter" method="get" action="{{.CurrentPath}}"><label>{{t .Locale "admin.search"}}<input name="q" value="{{.List.Query}}" maxlength="160" placeholder="{{t .Locale "admin.search"}}"></label><label>{{t .Locale "admin.user"}}<input name="user" value="{{.List.User}}" maxlength="160"></label><label>{{t .Locale "device.vault"}}<input name="vault" value="{{.List.Vault}}" maxlength="160"></label><label>{{t .Locale "device.version"}}<input name="version" value="{{.List.Version}}" maxlength="160"></label><label>{{t .Locale "device.status"}}<select name="status"><option value="">{{t .Locale "admin.all"}}</option><option value="active" {{if eq .List.Status "active"}}selected{{end}}>{{t .Locale "device.active"}}</option><option value="revoked" {{if eq .List.Status "revoked"}}selected{{end}}>{{t .Locale "device.revoked"}}</option></select></label><label>{{t .Locale "admin.sort"}}<select name="sort"><option value="created" {{if eq .List.Sort "created"}}selected{{end}}>{{t .Locale "admin.created"}}</option><option value="name" {{if eq .List.Sort "name"}}selected{{end}}>{{t .Locale "device.name"}}</option><option value="last_seen" {{if eq .List.Sort "last_seen"}}selected{{end}}>{{t .Locale "admin.lastSeen"}}</option></select></label><button class="button secondary" type="submit">{{t .Locale "admin.applyFilters"}}</button></form>
<section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "device.version"}}</th><th>{{t .Locale "admin.ip"}}</th><th>{{t .Locale "admin.created"}}</th><th>{{t .Locale "device.lastSeen"}}</th><th>{{t .Locale "device.status"}}</th><th>{{t .Locale "common.actions"}}</th></tr></thead><tbody>{{range .AdminDevices}}<tr><td>{{.Name}}{{if .TokenHint}}<small class="muted">{{.TokenHint}}</small>{{end}}</td><td>{{.User}}</td><td class="mono">{{short .Vault 16}}</td><td>{{.Version}}</td><td>{{.LastIP}}</td><td>{{webtime $.Locale .CreatedAt}}</td><td>{{webtime $.Locale .LastSeen}}</td><td>{{if .Revoked}}<span class="badge danger">{{t $.Locale "device.revoked"}}</span>{{else}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{end}}</td><td>{{if not .Revoked}}<form class="inline-form" method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="action" value="revoke-device"><input type="hidden" name="id" value="{{.ID}}"><input name="password" type="password" autocomplete="current-password" required placeholder="{{t $.Locale "field.password"}}"><button class="button danger" type="submit" data-confirm="{{t $.Locale "admin.revokeConfirm"}}">{{t $.Locale "device.revoke"}}</button></form>{{else}}<form class="inline-form" method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="action" value="delete-device"><input type="hidden" name="id" value="{{.ID}}"><input name="password" type="password" autocomplete="current-password" required placeholder="{{t $.Locale "field.password"}}"><button class="button danger" type="submit" data-confirm="{{t $.Locale "admin.revokeConfirm"}}">{{t $.Locale "admin.deleteDevice"}}</button></form>{{end}}</td></tr>{{else}}<tr><td colspan="9" class="empty">{{t .Locale "admin.noDevices"}}</td></tr>{{end}}</tbody></table></div></section>
<nav class="pager" aria-label="{{t .Locale "admin.pagination"}}"><span>{{.List.Total}}</span>{{if .List.Previous}}<a class="button secondary" href="{{.CurrentPath}}?{{listparams .List .List.Previous}}">{{t .Locale "admin.previous"}}</a>{{end}}<span>{{.List.Page}} / {{.List.Pages}}</span>{{if .List.Next}}<a class="button secondary" href="{{.CurrentPath}}?{{listparams .List .List.Next}}">{{t .Locale "admin.next"}}</a>{{end}}</nav>
</div></section>
{{end}}

View File

@ -0,0 +1,7 @@
{{define "admin_diagnostics"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
<p class="eyebrow">{{t .Locale "admin.diagnostics"}}</p><h1>{{t .Locale "admin.diagnostics"}}</h1>
<section class="card panel"><dl class="details"><dt>{{t .Locale "admin.status"}}</dt><dd>{{statuslabel .Locale .Health.Status}}</dd><dt>{{t .Locale "admin.database"}}</dt><dd>{{boollabel .Locale .Health.DatabaseReachable}}</dd><dt>{{t .Locale "admin.blobStorage"}}</dt><dd>{{boollabel .Locale .Health.BlobStorageWritable}}</dd><dt>{{t .Locale "admin.schema"}}</dt><dd>{{.Health.SchemaVersion}}</dd><dt>{{t .Locale "admin.serverTime"}}</dt><dd>{{webtime .Locale .Health.ServerTime}}</dd></dl><div class="actions"><a class="button secondary" href="/admin/diagnostics">{{t .Locale "admin.refresh"}}</a><button class="button secondary" type="button" data-copy-diagnostics="/admin/diagnostics.json" data-copy-label="{{t .Locale "admin.copyDiagnostics"}}" data-copied-label="{{t .Locale "admin.copied"}}">{{t .Locale "admin.copyDiagnostics"}}</button><a class="button secondary" href="/api/v1/health">{{t .Locale "admin.healthJSON"}}</a><a class="button secondary" href="/admin/diagnostics.json">{{t .Locale "admin.downloadDiagnostics"}}</a></div></section>
</div></section>
{{end}}

View File

@ -0,0 +1,2 @@
{{define "admin_login"}}{{template "layout" .}}{{end}}
{{define "content"}}<section class="auth-card card"><p class="eyebrow">{{t .Locale "admin.eyebrow"}}</p><h1>{{t .Locale "admin.loginTitle"}}</h1>{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}<form method="post" action="/admin/login" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><label>{{t .Locale "field.username"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button primary" type="submit">{{t .Locale "auth.login"}}</button></form></section>{{end}}

View File

@ -0,0 +1,14 @@
{{define "admin_nav"}}
<aside class="admin-nav" aria-label="{{t .Locale "admin.navigation"}}">
<p class="eyebrow">{{t .Locale "admin.eyebrow"}}</p>
<a href="/admin/dashboard" {{if eq .AdminPage "dashboard"}}aria-current="page"{{end}}>{{t .Locale "admin.overview"}}</a>
<a href="/admin/users" {{if eq .AdminPage "users"}}aria-current="page"{{end}}>{{t .Locale "admin.users"}}</a>
<a href="/admin/devices" {{if eq .AdminPage "devices"}}aria-current="page"{{end}}>{{t .Locale "admin.devices"}}</a>
<a href="/admin/vaults" {{if eq .AdminPage "vaults"}}aria-current="page"{{end}}>{{t .Locale "admin.vaults"}}</a>
<a href="/admin/storage" {{if eq .AdminPage "storage"}}aria-current="page"{{end}}>{{t .Locale "admin.storage"}}</a>
<a href="/admin/audit" {{if eq .AdminPage "audit"}}aria-current="page"{{end}}>{{t .Locale "admin.audit"}}</a>
<a href="/admin/settings" {{if eq .AdminPage "settings"}}aria-current="page"{{end}}>{{t .Locale "admin.settings"}}</a>
<a href="/admin/diagnostics" {{if eq .AdminPage "diagnostics"}}aria-current="page"{{end}}>{{t .Locale "admin.diagnostics"}}</a>
<form method="post" action="/admin/logout"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button class="link-button" type="submit">{{t .Locale "auth.logout"}}</button></form>
</aside>
{{end}}

View File

@ -0,0 +1,8 @@
{{define "admin_password_result"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content narrow-content">
<p class="eyebrow">{{t .Locale "admin.resultTitle"}}</p><h1>{{t .Locale "admin.resetPassword"}}</h1>
<section class="card panel warning" data-one-time-secret-url="/admin/password-result/secret" data-csrf-token="{{.CSRF}}"><p>{{t .Locale "admin.oneTimePasswordNotice"}}</p><code class="one-time-secret" aria-live="polite">{{t .Locale "common.loading"}}</code><p>{{t .Locale "admin.oneTimePasswordHint"}}</p></section>
<a class="button secondary" href="/admin/users">{{t .Locale "admin.users"}}</a>
</div></section>
{{end}}

View File

@ -0,0 +1,47 @@
{{define "admin_settings"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">
{{template "admin_nav" .}}
<div class="admin-content">
<p class="eyebrow">{{t .Locale "admin.settings"}}</p>
<h1>{{t .Locale "admin.settings"}}</h1>
{{if .Flash}}<p class="flash success" role="status">{{t .Locale .Flash}}</p>{{end}}
<section class="card panel">
<h2>{{t .Locale "admin.general"}}</h2>
<form method="post" action="/admin/action" class="stack">
<input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="web-settings">
<label>{{t .Locale "admin.serverName"}}<input name="server_name" value="{{.ServerName}}"></label>
<label>{{t .Locale "admin.publicURL"}}<input name="public_url" type="url" value="{{.PublicURL}}" placeholder="https://sync.example.test"></label>
<label>{{t .Locale "locale.label"}}<select name="default_locale"><option value="en" {{if eq .DefaultLocale "en"}}selected{{end}}>English</option><option value="ru" {{if eq .DefaultLocale "ru"}}selected{{end}}>Русский</option></select></label>
<label class="check-field"><input name="allow_registration" type="checkbox" {{if .AllowRegistration}}checked{{end}}>{{t .Locale "admin.allowRegistration"}}</label>
<label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label>
<button class="button primary">{{t .Locale "admin.saveSettings"}}</button>
</form>
</section>
<section class="card panel"><h2>{{t .Locale "admin.network"}}</h2><dl class="details"><dt>{{t .Locale "admin.trustedProxies"}}</dt><dd>{{if .TrustedProxies}}{{.TrustedProxies}}{{else}}—{{end}}</dd></dl></section>
<section class="card panel">
<h2>{{t .Locale "admin.smtpTitle"}}</h2>
<p class="muted">{{t .Locale "admin.smtpConfigured"}}</p>
<form method="post" action="/admin/action" class="stack">
<input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="smtp">
<label>{{t .Locale "admin.smtpServer"}}<input name="smtp_host" value="{{.SMTP.Host}}" autocomplete="off"></label>
<label>{{t .Locale "admin.smtpPort"}}<input name="smtp_port" inputmode="numeric" value="{{.SMTP.Port}}"></label>
<label>{{t .Locale "admin.smtpUsername"}}<input name="smtp_user" value="{{.SMTP.User}}" autocomplete="username"></label>
<label>{{t .Locale "admin.smtpPassword"}}<input name="smtp_pass" type="password" autocomplete="new-password"></label>
<label>{{t .Locale "admin.smtpType"}}<select name="smtp_security"><option value="none" {{if eq .SMTP.Security "none"}}selected{{end}}>{{t .Locale "admin.smtpNoEncryption"}}</option><option value="starttls" {{if eq .SMTP.Security "starttls"}}selected{{end}}>{{t .Locale "admin.smtpStartTLS"}}</option><option value="tls" {{if eq .SMTP.Security "tls"}}selected{{end}}>{{t .Locale "admin.smtpTLS"}}</option></select></label>
<label>{{t .Locale "admin.smtpFrom"}}<input name="smtp_from" type="email" value="{{.SMTP.From}}"></label>
<label>{{t .Locale "admin.smtpServerURL"}}<input name="server_url" type="url" value="{{.SMTP.ServerURL}}"></label>
<label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label>
<button class="button primary" type="submit">{{t .Locale "admin.smtpSave"}}</button>
</form>
<form method="post" action="/admin/action" class="inline-form top-gap">
<input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="smtp-test">
<label>{{t .Locale "field.email"}}<input name="test_to" type="email" placeholder="{{.SMTP.From}}"></label>
<label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label>
<button class="button secondary" type="submit">{{t .Locale "admin.smtpTest"}}</button>
</form>
</section>
<section class="card panel"><h2>{{t .Locale "admin.limits"}}</h2><p class="muted">{{t .Locale "admin.readOnlyConfig"}}</p><dl class="details"><dt>{{t .Locale "admin.maxJSONBody"}}</dt><dd>{{webbytes .Limits.MaxJSONBody}}</dd><dt>{{t .Locale "admin.maxPushOperations"}}</dt><dd>{{.Limits.MaxPushOperations}}</dd><dt>{{t .Locale "admin.maxPullPage"}}</dt><dd>{{.Limits.MaxPullPage}}</dd><dt>{{t .Locale "admin.maxBlobBytes"}}</dt><dd>{{webbytes .Limits.MaxBlobBytes}}</dd><dt>{{t .Locale "admin.maxVaultBlobBytes"}}</dt><dd>{{webbytes .Limits.MaxVaultBlobBytes}}</dd><dt>{{t .Locale "admin.maxUserBlobBytes"}}</dt><dd>{{webbytes .Limits.MaxUserBlobBytes}}</dd></dl></section>
</div>
</section>
{{end}}

View File

@ -0,0 +1,10 @@
{{define "admin_storage"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
<p class="eyebrow">{{t .Locale "admin.storage"}}</p><h1>{{t .Locale "admin.storage"}}</h1>
{{if .Flash}}<p class="flash success" role="status">{{t .Locale .Flash}}</p>{{end}}
<div class="stat-grid"><article class="card stat"><strong>{{webbytes .Stats.DatabaseBytes}}</strong><span>{{t .Locale "admin.databaseBytes"}}</span></article><article class="card stat"><strong>{{webbytes .Stats.BlobBytes}}</strong><span>{{.Stats.Blobs}} {{t .Locale "admin.blobBytes"}}</span></article><article class="card stat"><strong>{{.Stats.Operations}}</strong><span>{{t .Locale "admin.operations"}}</span></article></div>
<section class="card panel"><dl class="details"><dt>{{t .Locale "admin.blobReferences"}}</dt><dd>{{.Stats.BlobReferences}}</dd><dt>{{t .Locale "admin.orphanBlobs"}}</dt><dd>{{.Stats.OrphanBlobs}}</dd><dt>{{t .Locale "admin.tempUploads"}}</dt><dd>{{.Stats.TempUploads}}</dd><dt>{{t .Locale "admin.expiredSessions"}}</dt><dd>{{.Stats.ExpiredSessions}}</dd><dt>{{t .Locale "admin.expiredTokens"}}</dt><dd>{{.Stats.ExpiredTokens}}</dd><dt>{{t .Locale "admin.lastCleanup"}}</dt><dd>{{webtime .Locale .Stats.LastCleanupAt}}</dd></dl></section>
<p class="muted">{{t .Locale "admin.retentionNote"}}</p><form method="post" action="/admin/action" class="inline-form"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="cleanup"><label class="sr-only">{{t .Locale "field.password"}}</label><input name="password" type="password" autocomplete="current-password" required placeholder="{{t .Locale "field.password"}}"><button class="button secondary" type="submit" data-confirm="{{t .Locale "admin.revokeConfirm"}}">{{t .Locale "admin.runCleanup"}}</button></form>
</div></section>
{{end}}

View File

@ -0,0 +1,9 @@
{{define "admin_users"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
<div class="section-heading"><div><p class="eyebrow">{{t .Locale "admin.access"}}</p><h1>{{t .Locale "admin.users"}}</h1></div><a class="button primary" href="/admin/create-user">{{t .Locale "admin.createUser"}}</a></div>
<form class="list-filter" method="get" action="{{.CurrentPath}}"><label>{{t .Locale "admin.search"}}<input name="q" value="{{.List.Query}}" maxlength="160"></label><label>{{t .Locale "device.status"}}<select name="status"><option value="">{{t .Locale "admin.all"}}</option><option value="active" {{if eq .List.Status "active"}}selected{{end}}>{{t .Locale "device.active"}}</option><option value="blocked" {{if eq .List.Status "blocked"}}selected{{end}}>{{t .Locale "admin.blocked"}}</option><option value="unconfirmed" {{if eq .List.Status "unconfirmed"}}selected{{end}}>{{t .Locale "admin.unconfirmed"}}</option></select></label><label>{{t .Locale "admin.sort"}}<select name="sort"><option value="created" {{if eq .List.Sort "created"}}selected{{end}}>{{t .Locale "admin.created"}}</option><option value="username" {{if eq .List.Sort "username"}}selected{{end}}>{{t .Locale "field.username"}}</option><option value="last_seen" {{if eq .List.Sort "last_seen"}}selected{{end}}>{{t .Locale "admin.lastSeen"}}</option></select></label><button class="button secondary" type="submit">{{t .Locale "admin.applyFilters"}}</button></form>
<section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "field.username"}}</th><th>{{t .Locale "field.email"}}</th><th>{{t .Locale "admin.devices"}}</th><th>{{t .Locale "admin.vaults"}}</th><th>{{t .Locale "admin.lastSeen"}}</th><th>{{t .Locale "admin.created"}}</th><th>{{t .Locale "device.status"}}</th><th>{{t .Locale "common.actions"}}</th></tr></thead><tbody>{{range .AdminUsers}}<tr><td>{{.Username}}</td><td>{{.Email}}</td><td>{{.Devices}}</td><td>{{.Vaults}}</td><td>{{webtime $.Locale .LastSeen}}</td><td>{{webtime $.Locale .CreatedAt}}</td><td>{{if .Blocked}}<span class="badge danger">{{t $.Locale "admin.blocked"}}</span>{{else if .Confirmed}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{else}}<span class="badge">{{t $.Locale "admin.unconfirmed"}}</span>{{end}}</td><td><details><summary>{{t $.Locale "admin.manage"}}</summary>{{if not .Confirmed}}<form class="stack compact" method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="action" value="confirm-user"><input type="hidden" name="id" value="{{.ID}}"><label>{{t $.Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button secondary" type="submit">{{t $.Locale "admin.confirmUser"}}</button></form>{{end}}<form class="stack compact" method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="action" value="toggle-user"><input type="hidden" name="id" value="{{.ID}}"><label>{{t $.Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button secondary" type="submit" data-confirm="{{t $.Locale "admin.revokeConfirm"}}">{{if .Blocked}}{{t $.Locale "admin.unblock"}}{{else}}{{t $.Locale "admin.block"}}{{end}}</button></form><form method="post" action="/admin/action" class="stack compact"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="action" value="edit-user"><label>{{t $.Locale "field.username"}}<input name="username" value="{{.Username}}" required></label><label>{{t $.Locale "field.email"}}<input name="email" type="email" value="{{.Email}}" required></label><button class="button secondary" type="submit">{{t $.Locale "admin.saveUser"}}</button></form><form method="post" action="/admin/action" class="stack compact"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="action" value="reset-user-password"><label>{{t $.Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button secondary" type="submit" data-confirm="{{t $.Locale "admin.resetPasswordConfirm"}}">{{t $.Locale "admin.generatePassword"}}</button></form><form method="post" action="/admin/action" class="stack compact"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="action" value="delete-user"><label>{{t $.Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button danger" type="submit" data-confirm="{{t $.Locale "admin.deleteUserConfirm"}}">{{t $.Locale "admin.deleteUser"}}</button></form></details></td></tr>{{else}}<tr><td colspan="8" class="empty">{{t .Locale "admin.noUsers"}}</td></tr>{{end}}</tbody></table></div></section>
<nav class="pager" aria-label="{{t .Locale "admin.pagination"}}"><span>{{.List.Total}}</span>{{if .List.Previous}}<a class="button secondary" href="{{.CurrentPath}}?{{listparams .List .List.Previous}}">{{t .Locale "admin.previous"}}</a>{{end}}<span>{{.List.Page}} / {{.List.Pages}}</span>{{if .List.Next}}<a class="button secondary" href="{{.CurrentPath}}?{{listparams .List .List.Next}}">{{t .Locale "admin.next"}}</a>{{end}}</nav>
</div></section>
{{end}}

View File

@ -0,0 +1,7 @@
{{define "admin_vaults"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
<p class="eyebrow">{{t .Locale "admin.storage"}}</p><h1>{{t .Locale "admin.vaults"}}</h1>
<section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "admin.devices"}}</th><th>{{t .Locale "admin.operations"}}</th><th>{{t .Locale "admin.lastActivity"}}</th></tr></thead><tbody>{{range .Vaults}}<tr><td>{{.User}}</td><td class="mono"><a href="/admin/vault/?user={{.UserID}}&amp;vault={{.Vault}}">{{short .Vault 24}}</a></td><td>{{.Devices}}</td><td>{{.Operations}}</td><td>{{.LastActivity}}</td></tr>{{else}}<tr><td colspan="5" class="empty">{{t .Locale "admin.noVaults"}}</td></tr>{{end}}</tbody></table></div></section>
</div></section>
{{end}}

View File

@ -0,0 +1,2 @@
{{define "confirm"}}{{template "layout" .}}{{end}}
{{define "content"}}<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "confirm.title"}}</h1><p class="muted">{{t .Locale "confirm.description"}}</p><form method="post" action="/api/v1/auth/confirm" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><input type="hidden" name="token" value="{{.Token}}"><button class="button primary" type="submit">{{t .Locale "confirm.action"}}</button></form></section>{{end}}

View File

@ -0,0 +1,7 @@
{{define "dashboard"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="dashboard-head"><div><p class="eyebrow">{{t .Locale "user.account"}}</p><h1>{{.UserName}}</h1><p class="muted">{{.Email}} · {{if .UserConfirmed}}<span class="badge ok">{{t .Locale "user.emailConfirmed"}}</span>{{else}}<span class="badge danger">{{t .Locale "user.emailUnconfirmed"}}</span>{{end}}</p></div><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button class="button secondary" type="submit">{{t .Locale "auth.logout"}}</button></form></section>
{{if .Flash}}<p class="flash {{if .FlashError}}error{{else}}success{{end}}" role="{{if .FlashError}}alert{{else}}status{{end}}">{{t .Locale .Flash}}</p>{{end}}
<section class="card panel"><p class="muted">{{t .Locale "user.connectInstruction"}}</p></section>
<section class="card table-card"><div class="section-heading"><h2>{{t .Locale "user.devices"}}</h2><form class="list-filter" method="get" action="/dashboard"><label class="sr-only">{{t .Locale "user.filterDevices"}}</label><input name="q" value="{{.List.Query}}" placeholder="{{t .Locale "user.filterDevices"}}"><button class="button secondary" type="submit">{{t .Locale "admin.applyFilters"}}</button></form></div>{{if .Devices}}<div class="table-scroll"><table><thead><tr><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "device.version"}}</th><th>{{t .Locale "device.created"}}</th><th>{{t .Locale "device.lastSeen"}}</th><th>{{t .Locale "device.status"}}</th><th>{{t .Locale "common.actions"}}</th></tr></thead><tbody>{{range .Devices}}<tr><td>{{.Name}}</td><td><span class="mono">{{short .Vault 18}}</span></td><td>{{.ClientVersion}}</td><td>{{webtime $.Locale .CreatedAt}}</td><td>{{webtime $.Locale .LastSeen}}</td><td>{{if .Revoked}}<span class="badge danger">{{t $.Locale "device.revoked"}}</span>{{else}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{end}}</td><td>{{if not .Revoked}}<form method="post" action="/api/v1/user/devices/{{.ID}}/revoke" class="inline-form"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><label class="sr-only">{{t $.Locale "field.password"}}</label><input name="password" type="password" autocomplete="current-password" required placeholder="{{t $.Locale "field.password"}}"><button class="button danger" type="submit" data-confirm="{{t $.Locale "device.revokeConfirm"}}">{{t $.Locale "device.revoke"}}</button></form>{{end}}</td></tr>{{end}}</tbody></table></div>{{else}}<p class="empty">{{t .Locale "user.noDevices"}}</p>{{end}}</section>
{{end}}

View File

@ -0,0 +1,2 @@
{{define "error"}}{{template "layout" .}}{{end}}
{{define "content"}}<section class="message-card card"><p class="eyebrow danger-text">{{t .Locale "error.label"}}</p><h1>{{t .Locale .Heading}}</h1><p class="lead">{{t .Locale .Message}}</p>{{if .BackURL}}<a class="button secondary" href="{{.BackURL}}">{{t .Locale "common.back"}}</a>{{end}}</section>{{end}}

View File

@ -0,0 +1,5 @@
{{define "forgot"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "auth.forgotTitle"}}</h1><p class="muted">{{t .Locale "auth.forgotDescription"}}</p>
<form method="post" action="/forgot" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><label>{{t .Locale "field.email"}}<input name="email" type="email" autocomplete="email" required autofocus></label><button class="button primary" type="submit">{{t .Locale "auth.sendLink"}}</button></form><p class="muted"><a href="/login">{{t .Locale "auth.backLogin"}}</a></p></section>
{{end}}

View File

@ -0,0 +1,10 @@
{{define "home"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="hero card">
<p class="eyebrow">{{t .Locale "home.eyebrow"}}</p>
<h1>{{t .Locale "home.heading"}}</h1>
<p class="lead">{{t .Locale "home.description"}}</p>
<div class="actions"><a class="button primary" href="/login">{{t .Locale "home.login"}}</a>{{if .AllowRegistration}}<a class="button secondary" href="/register">{{t .Locale "home.register"}}</a>{{end}}</div>
<p class="muted">{{t .Locale "home.version"}} {{.Version}} · {{.BuildCommit}} · <span class="badge ok">{{statuslabel .Locale .Status}}</span></p>
</section>
{{end}}

View File

@ -0,0 +1,36 @@
{{define "layout"}}
<!doctype html>
<html lang="{{.Locale}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{t .Locale .Title}} · {{.ServerName}}</title>
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<header class="site-header">
<a class="brand" href="/" aria-label="{{.ServerName}}"><img src="/static/logo.svg" alt="" width="28" height="28"><span>Verstak Sync</span></a>
<nav class="site-nav" aria-label="{{t .Locale "nav.primary"}}">
<a href="/login">{{t .Locale "nav.login"}}</a>
{{if .AllowRegistration}}<a href="/register">{{t .Locale "nav.register"}}</a>{{end}}
</nav>
<form class="locale-form" action="/locale" method="post" aria-label="{{t .Locale "locale.label"}}">
<input type="hidden" name="from" value="{{.CurrentURL}}">
<input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}">
<label class="sr-only" for="locale">{{t .Locale "locale.label"}}</label>
<select id="locale" name="locale" data-auto-submit>
<option value="system" {{if eq .LocalePreference "system"}}selected{{end}}>{{t .Locale "locale.system"}}</option>
<option value="ru" {{if eq .LocalePreference "ru"}}selected{{end}}>Русский</option>
<option value="en" {{if eq .LocalePreference "en"}}selected{{end}}>English</option>
</select>
<button class="sr-only" type="submit">{{t .Locale "locale.apply"}}</button>
</form>
</header>
<main class="page-shell">{{template "content" .}}</main>
<footer class="site-footer"><span>{{.ServerName}}</span><span>{{t .Locale "footer.localFirst"}}</span></footer>
<dialog id="confirm-dialog" class="confirm-dialog" aria-labelledby="confirm-dialog-title"><form method="dialog" class="stack"><h2 id="confirm-dialog-title">{{t .Locale "admin.confirmTitle"}}</h2><p id="confirm-dialog-message"></p><div class="actions"><button class="button secondary" value="cancel">{{t .Locale "admin.modalCancel"}}</button><button class="button danger" value="confirm">{{t .Locale "admin.modalConfirm"}}</button></div></form></dialog>
<script src="/static/app.js" defer></script>
</body>
</html>
{{end}}

View File

@ -0,0 +1,7 @@
{{define "login"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.welcome"}}</p><h1>{{t .Locale "auth.loginTitle"}}</h1>
{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}
<form method="post" action="/login" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><label>{{t .Locale "field.usernameOrEmail"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button primary" type="submit">{{t .Locale "auth.login"}}</button></form>
<p class="muted"><a href="/forgot">{{t .Locale "auth.forgot"}}</a>{{if .AllowRegistration}} · <a href="/register">{{t .Locale "auth.register"}}</a>{{end}}</p></section>
{{end}}

View File

@ -0,0 +1,2 @@
{{define "message"}}{{template "layout" .}}{{end}}
{{define "content"}}<section class="message-card card"><p class="eyebrow">Verstak Sync</p><h1>{{t .Locale .Heading}}</h1><p class="lead">{{t .Locale .Message}}</p>{{if .BackURL}}<a class="button primary" href="{{.BackURL}}">{{t .Locale "common.continue"}}</a>{{end}}</section>{{end}}

View File

@ -0,0 +1,7 @@
{{define "register"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "auth.registerTitle"}}</h1>
{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}
<form method="post" action="/register" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><label>{{t .Locale "field.username"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.email"}}<input name="email" type="email" autocomplete="email" required></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="new-password" minlength="8" maxlength="256" required></label><button class="button primary" type="submit">{{t .Locale "auth.register"}}</button></form>
<p class="muted"><a href="/login">{{t .Locale "auth.haveAccount"}}</a></p></section>
{{end}}

View File

@ -0,0 +1,6 @@
{{define "reset"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "auth.resetTitle"}}</h1>
{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}
<form method="post" action="/reset" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><input type="hidden" name="token" value="{{.Token}}"><label>{{t .Locale "field.newPassword"}}<input name="password" type="password" autocomplete="new-password" minlength="8" maxlength="256" required autofocus></label><label>{{t .Locale "field.confirmPassword"}}<input name="confirm" type="password" autocomplete="new-password" minlength="8" maxlength="256" required></label><button class="button primary" type="submit">{{t .Locale "auth.savePassword"}}</button></form></section>
{{end}}

View File

@ -0,0 +1,2 @@
{{define "unavailable"}}{{template "layout" .}}{{end}}
{{define "content"}}<section class="message-card card"><p class="eyebrow">{{t .Locale "home.eyebrow"}}</p><h1>{{t .Locale .Heading}}</h1><p class="muted">{{t .Locale .Message}}</p></section>{{end}}

View File

@ -0,0 +1,10 @@
{{define "vault_detail"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
<p class="eyebrow">{{t .Locale "admin.vaults"}}</p><h1 class="mono">{{short .VaultDetail.Vault 32}}</h1><p class="muted">{{.VaultDetail.User}}</p>
<div class="stat-grid"><article class="card stat"><strong>{{.VaultDetail.Devices}}</strong><span>{{t .Locale "admin.devices"}} · {{.VaultDetail.Active}} {{t .Locale "admin.active"}} · {{.VaultDetail.Revoked}} {{t .Locale "admin.revoked"}}</span></article><article class="card stat"><strong>{{.VaultDetail.Operations}}</strong><span>{{t .Locale "admin.operations"}} · {{t .Locale "admin.sequence"}} {{.VaultDetail.Sequence}}</span></article><article class="card stat"><strong>{{webbytes .VaultDetail.BlobBytes}}</strong><span>{{t .Locale "admin.blobBytes"}}</span></article></div>
<section class="card panel"><dl class="details"><dt>{{t .Locale "admin.lastActivity"}}</dt><dd>{{webtime .Locale .VaultDetail.LastActivity}}</dd></dl><p class="muted">{{t .Locale "admin.vaultPrivacy"}}</p></section>
<section class="card table-card"><h2>{{t .Locale "admin.devices"}}</h2><div class="table-scroll"><table><thead><tr><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "device.version"}}</th><th>{{t .Locale "admin.ip"}}</th><th>{{t .Locale "device.lastSeen"}}</th><th>{{t .Locale "device.status"}}</th></tr></thead><tbody>{{range .VaultDevices}}<tr><td>{{.Name}}</td><td>{{.Version}}</td><td>{{.LastIP}}</td><td>{{webtime $.Locale .LastSeen}}</td><td>{{if .Revoked}}<span class="badge danger">{{t $.Locale "device.revoked"}}</span>{{else}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{end}}</td></tr>{{else}}<tr><td colspan="5" class="empty">{{t .Locale "admin.noDevices"}}</td></tr>{{end}}</tbody></table></div></section>
<a class="button secondary" href="/admin/vaults">{{t .Locale "common.back"}}</a>
</div></section>
{{end}}

View File

@ -0,0 +1,851 @@
package server
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
type oneTimeWebSecret struct {
Value string
ExpiresAt time.Time
}
// storeAdminOneTimeSecret keeps a generated password only long enough for the
// currently authenticated administrator to retrieve it once. It is never
// written to the database, URL, audit log, or cookie.
func (s *Server) storeAdminOneTimeSecret(sessionToken, secret string) {
s.secretMu.Lock()
defer s.secretMu.Unlock()
now := time.Now().UTC()
for key, value := range s.webSecrets {
if !now.Before(value.ExpiresAt) {
delete(s.webSecrets, key)
}
}
s.webSecrets[sha256Hex(sessionToken)] = oneTimeWebSecret{Value: secret, ExpiresAt: now.Add(5 * time.Minute)}
}
func (s *Server) takeAdminOneTimeSecret(sessionToken string) string {
s.secretMu.Lock()
defer s.secretMu.Unlock()
key := sha256Hex(sessionToken)
value, ok := s.webSecrets[key]
delete(s.webSecrets, key)
if !ok || !time.Now().UTC().Before(value.ExpiresAt) {
return ""
}
return value.Value
}
func (s *Server) handleAdminRoot(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
}
func (s *Server) handleAdminPasswordResult(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
if !s.requireAdminCookie(w, r) {
return
}
w.Header().Set("Cache-Control", "no-store, max-age=0")
s.renderPage(w, r, "admin_password_result", webPage{Title: "admin.resetPassword", Admin: true, AdminPage: "users"})
}
func (s *Server) handleAdminPasswordResultSecret(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w, http.MethodPost)
return
}
if !s.requireAdminMutation(w, r) {
return
}
cookie, err := r.Cookie("admin_session")
if err != nil {
jsonErrCode(w, http.StatusForbidden, "session_invalid", "administrator session is required")
return
}
secret := s.takeAdminOneTimeSecret(cookie.Value)
if secret == "" {
jsonErrCode(w, http.StatusGone, "one_time_secret_expired", "one-time password is no longer available")
return
}
w.Header().Set("Cache-Control", "no-store, max-age=0")
jsonOK(w, map[string]string{"password": secret})
}
func (s *Server) handleAdminVaultDetail(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
if !s.requireAdminCookie(w, r) {
return
}
userID, vaultID := r.URL.Query().Get("user"), r.URL.Query().Get("vault")
if userID == "" || vaultID == "" {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/admin/vaults")
return
}
var d webVaultDetail
if err := s.db.QueryRow(`SELECT COALESCE((SELECT username FROM server_users WHERE id=?),''), COUNT(DISTINCT d.id), COUNT(DISTINCT CASE WHEN COALESCE(d.revoked_at,'')='' THEN d.id END), COUNT(DISTINCT CASE WHEN COALESCE(d.revoked_at,'')!='' THEN d.id END), COUNT(DISTINCT o.op_id), COALESCE(MAX(o.server_sequence),0), COALESCE(MAX(d.last_seen),'') FROM server_devices d LEFT JOIN server_ops o ON o.user_id=d.user_id AND o.vault_id=d.vault_id WHERE d.user_id=? AND d.vault_id=?`, userID, userID, vaultID).Scan(&d.User, &d.Devices, &d.Active, &d.Revoked, &d.Operations, &d.Sequence, &d.LastActivity); err != nil {
jsonInternalError(w, err)
return
}
d.Vault = vaultID
if err := s.db.QueryRow(`SELECT COALESCE(SUM(size),0) FROM server_blob_refs WHERE user_id=? AND vault_id=?`, userID, vaultID).Scan(&d.BlobBytes); err != nil {
jsonInternalError(w, err)
return
}
rows, err := s.db.Query(`SELECT d.id,d.name,COALESCE(u.username,''),COALESCE(d.vault_id,''),COALESCE(d.client_version,''),COALESCE(d.last_ip,''),COALESCE(d.last_seen,''),COALESCE(d.revoked_at,''),d.created_at,COALESCE(d.token_prefix,''),COALESCE(d.token_suffix,'') FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id WHERE d.user_id=? AND d.vault_id=? ORDER BY d.created_at DESC`, userID, vaultID)
if err != nil {
jsonInternalError(w, err)
return
}
defer rows.Close()
var devices []webAdminDevice
for rows.Next() {
var device webAdminDevice
var revoked, prefix, suffix string
if err := rows.Scan(&device.ID, &device.Name, &device.User, &device.Vault, &device.Version, &device.LastIP, &device.LastSeen, &revoked, &device.CreatedAt, &prefix, &suffix); err != nil {
jsonInternalError(w, err)
return
}
device.Revoked = revoked != ""
if prefix != "" || suffix != "" {
device.TokenHint = prefix + "…" + suffix
}
devices = append(devices, device)
}
if err := rows.Err(); err != nil {
jsonInternalError(w, err)
return
}
s.renderPage(w, r, "vault_detail", webPage{Title: "admin.vaults", Admin: true, AdminPage: "vaults", VaultDetail: d, VaultDevices: devices})
}
func (s *Server) handleAdminCreateUserWeb(w http.ResponseWriter, r *http.Request) {
if !s.requireAdminCookie(w, r) {
return
}
switch r.Method {
case http.MethodGet:
s.renderPage(w, r, "admin_create_user", webPage{Title: "admin.createUser", Admin: true})
case http.MethodPost:
if !s.requireAdminMutation(w, r) {
return
}
if err := r.ParseForm(); err != nil {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/admin/create-user")
return
}
username, email, password := strings.TrimSpace(r.FormValue("username")), strings.TrimSpace(r.FormValue("email")), r.FormValue("password")
if username == "" || email == "" || password == "" {
s.renderPage(w, r, "admin_create_user", webPage{Title: "admin.createUser", Admin: true, Flash: "error.allFieldsRequired"})
return
}
if err := validatePassword(password); err != "" {
s.renderPage(w, r, "admin_create_user", webPage{Title: "admin.createUser", Admin: true, Flash: "error.passwordInvalid"})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
jsonInternalError(w, err)
return
}
id := make([]byte, 12)
if _, err := rand.Read(id); err != nil {
jsonInternalError(w, err)
return
}
userID := hex.EncodeToString(id)
if _, 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), time.Now().UTC().Format(time.RFC3339)); err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
s.renderPage(w, r, "admin_create_user", webPage{Title: "admin.createUser", Admin: true, Flash: "error.accountTaken"})
return
}
jsonInternalError(w, err)
return
}
s.auditLog("user_created", userID, "", s.clientIP(r), "created by administrator")
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
default:
methodNotAllowed(w, http.MethodGet, http.MethodPost)
}
}
func (s *Server) handleAdminWeb(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
if !s.requireAdminCookie(w, r) {
return
}
page := strings.TrimPrefix(r.URL.Path, "/admin/")
if page == "" || page == "admin" {
page = "dashboard"
}
allowed := map[string]bool{"dashboard": true, "users": true, "devices": true, "vaults": true, "storage": true, "audit": true, "settings": true, "diagnostics": true}
if !allowed[page] {
s.handleNotFound(w, r)
return
}
stats, err := s.Stats(r.Context())
if err != nil {
log.Printf("admin stats: %v", err)
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/")
return
}
data := webPage{Title: "admin." + page, Admin: true, AdminPage: page, Stats: stats, Health: s.healthStatus(r.Context())}
switch page {
case "dashboard":
data.Audit, _, err = s.webAudit(webList{Page: 1, PerPage: 5})
if err == nil {
data.AdminDevices, _, err = s.webAdminDevices(webList{Page: 1, PerPage: 5})
}
if !data.Health.DatabaseReachable || !data.Health.BlobStorageWritable {
data.Warnings = append(data.Warnings, "admin.warningReadiness")
}
if s.smtpGet("smtp_host") == "" {
data.Warnings = append(data.Warnings, "admin.warningSMTP")
}
if stats.Operations > 100000 {
data.Warnings = append(data.Warnings, "admin.warningOperations")
}
case "users":
data.List = webListFromRequest(r)
data.AdminUsers, data.List, err = s.webAdminUsers(data.List)
case "devices":
data.List = webListFromRequest(r)
data.AdminDevices, data.List, err = s.webAdminDevices(data.List)
case "vaults":
data.Vaults, err = s.webVaults()
case "audit":
data.List = webListFromRequest(r)
data.Audit, data.List, err = s.webAudit(data.List)
case "settings":
data.SMTP = s.webSMTP()
switch r.URL.Query().Get("flash") {
case "settings_saved":
data.Flash = "admin.settingsSaved"
case "smtp_saved":
data.Flash = "admin.smtpSaved"
case "smtp_test_passed":
data.Flash = "admin.smtpPassed"
case "smtp_test_failed":
data.Flash = "admin.smtpTestFailed"
}
case "storage":
if r.URL.Query().Get("flash") == "cleanup_done" {
data.Flash = "admin.cleanupDone"
}
}
if err != nil {
log.Printf("admin %s: %v", page, err)
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/admin/dashboard")
return
}
s.renderPage(w, r, "admin_"+page, data)
}
func webListFromRequest(r *http.Request) webList {
trim := func(value string) string {
value = strings.TrimSpace(value)
if len(value) > 160 {
return value[:160]
}
return value
}
list := webList{Query: trim(r.URL.Query().Get("q")), Status: trim(r.URL.Query().Get("status")), Sort: trim(r.URL.Query().Get("sort")), User: trim(r.URL.Query().Get("user")), Vault: trim(r.URL.Query().Get("vault")), Version: trim(r.URL.Query().Get("version")), Event: trim(r.URL.Query().Get("event")), Severity: trim(r.URL.Query().Get("severity")), Page: 1, PerPage: 25}
if value, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && value > 0 {
list.Page = value
}
if value, err := strconv.Atoi(r.URL.Query().Get("per_page")); err == nil && value > 0 && value <= 100 {
list.PerPage = value
}
return list
}
func finishWebList(list webList, total int) webList {
list.Total = total
list.Pages = (total + list.PerPage - 1) / list.PerPage
if list.Pages == 0 {
list.Pages = 1
}
if list.Page > list.Pages {
list.Page = list.Pages
}
if list.Page > 1 {
list.Previous = list.Page - 1
}
if list.Page < list.Pages {
list.Next = list.Page + 1
}
return list
}
func (s *Server) webAdminUsers(list webList) ([]webAdminUser, webList, error) {
where := ""
args := []interface{}{}
if list.Query != "" {
where = " WHERE (u.username LIKE ? OR u.email LIKE ?)"
like := "%" + list.Query + "%"
args = append(args, like, like)
}
if list.Status == "active" || list.Status == "blocked" || list.Status == "unconfirmed" {
condition := map[string]string{"active": "u.confirmed=1 AND u.blocked=0", "blocked": "u.blocked=1", "unconfirmed": "u.confirmed=0"}[list.Status]
if where == "" {
where = " WHERE " + condition
} else {
where += " AND " + condition
}
}
var total int
if err := s.db.QueryRow("SELECT COUNT(*) FROM server_users u"+where, args...).Scan(&total); err != nil {
return nil, list, err
}
list = finishWebList(list, total)
order := map[string]string{"username": "u.username COLLATE NOCASE ASC", "last_seen": "COALESCE(u.last_seen,'') DESC", "created": "u.created_at DESC"}[list.Sort]
if order == "" {
list.Sort = "created"
order = "u.created_at DESC"
}
queryArgs := append([]interface{}{}, args...)
queryArgs = append(queryArgs, list.PerPage, (list.Page-1)*list.PerPage)
rows, err := s.db.Query(`SELECT u.id,u.username,u.email,u.confirmed,u.blocked,u.created_at,COALESCE(u.last_seen,''),COUNT(ud.device_id),(SELECT COUNT(DISTINCT vd.vault_id) FROM server_devices vd WHERE vd.user_id=u.id AND COALESCE(vd.vault_id,'')!='') FROM server_users u LEFT JOIN server_user_devices ud ON ud.user_id=u.id`+where+` GROUP BY u.id ORDER BY `+order+` LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
return nil, list, err
}
defer rows.Close()
var out []webAdminUser
for rows.Next() {
var u webAdminUser
var confirmed, blocked int
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &confirmed, &blocked, &u.CreatedAt, &u.LastSeen, &u.Devices, &u.Vaults); err != nil {
return nil, list, err
}
u.Confirmed = confirmed != 0
u.Blocked = blocked != 0
out = append(out, u)
}
return out, list, rows.Err()
}
func (s *Server) webAdminDevices(list webList) ([]webAdminDevice, webList, error) {
where := ""
args := []interface{}{}
addCondition := func(condition string, values ...interface{}) {
if where == "" {
where = " WHERE " + condition
} else {
where += " AND " + condition
}
args = append(args, values...)
}
if list.Query != "" {
like := "%" + list.Query + "%"
addCondition("(d.name LIKE ? OR u.username LIKE ? OR d.vault_id LIKE ?)", like, like, like)
}
if list.Status == "active" || list.Status == "revoked" {
condition := map[string]string{"active": "COALESCE(d.revoked_at,'')=''", "revoked": "COALESCE(d.revoked_at,'')!=''"}[list.Status]
addCondition(condition)
}
if list.User != "" {
addCondition("u.username LIKE ?", "%"+list.User+"%")
}
if list.Vault != "" {
addCondition("d.vault_id LIKE ?", "%"+list.Vault+"%")
}
if list.Version != "" {
addCondition("d.client_version LIKE ?", "%"+list.Version+"%")
}
var total int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id`+where, args...).Scan(&total); err != nil {
return nil, list, err
}
list = finishWebList(list, total)
order := map[string]string{"name": "d.name COLLATE NOCASE ASC", "last_seen": "COALESCE(d.last_seen,'') DESC", "created": "d.created_at DESC"}[list.Sort]
if order == "" {
list.Sort = "created"
order = "d.created_at DESC"
}
queryArgs := append([]interface{}{}, args...)
queryArgs = append(queryArgs, list.PerPage, (list.Page-1)*list.PerPage)
rows, err := s.db.Query(`SELECT d.id,d.name,COALESCE(u.username,''),COALESCE(d.vault_id,''),COALESCE(d.client_version,''),COALESCE(d.last_ip,''),COALESCE(d.last_seen,''),COALESCE(d.revoked_at,''),d.created_at,COALESCE(d.token_prefix,''),COALESCE(d.token_suffix,'') FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id`+where+` ORDER BY `+order+` LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
return nil, list, err
}
defer rows.Close()
var out []webAdminDevice
for rows.Next() {
var d webAdminDevice
var revoked, prefix, suffix string
if err := rows.Scan(&d.ID, &d.Name, &d.User, &d.Vault, &d.Version, &d.LastIP, &d.LastSeen, &revoked, &d.CreatedAt, &prefix, &suffix); err != nil {
return nil, list, err
}
if prefix != "" || suffix != "" {
d.TokenHint = prefix + "…" + suffix
}
d.Revoked = revoked != ""
if d.LastSeen == "" {
d.LastSeen = "—"
}
out = append(out, d)
}
return out, list, rows.Err()
}
func (s *Server) webVaults() ([]webVault, error) {
rows, err := s.db.Query(`SELECT COALESCE(u.username,''),d.user_id,d.vault_id,COUNT(DISTINCT d.id),COUNT(o.op_id),COALESCE(MAX(d.last_seen),'') FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id LEFT JOIN server_ops o ON o.user_id=d.user_id AND o.vault_id=d.vault_id WHERE COALESCE(d.user_id,'')!='' AND COALESCE(d.vault_id,'')!='' GROUP BY d.user_id,d.vault_id ORDER BY MAX(d.last_seen) DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []webVault
for rows.Next() {
var v webVault
if err := rows.Scan(&v.User, &v.UserID, &v.Vault, &v.Devices, &v.Operations, &v.LastActivity); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
func (s *Server) webAudit(list webList) ([]webAudit, webList, error) {
where := ""
args := []interface{}{}
addCondition := func(condition string, values ...interface{}) {
if where == "" {
where = " WHERE " + condition
} else {
where += " AND " + condition
}
args = append(args, values...)
}
if list.Query != "" {
like := "%" + list.Query + "%"
addCondition("(a.event_type LIKE ? OR a.user_id LIKE ? OR a.device_id LIKE ?)", like, like, like)
}
if list.Event != "" {
addCondition("a.event_type LIKE ?", "%"+list.Event+"%")
}
if list.User != "" {
addCondition("a.user_id LIKE ?", "%"+list.User+"%")
}
if list.Severity == "error" {
addCondition("(a.event_type LIKE '%failed%' OR a.event_type LIKE '%error%')")
} else if list.Severity == "warning" {
addCondition("a.event_type LIKE '%rate_limit%'")
}
var total int
if err := s.db.QueryRow("SELECT COUNT(*) FROM server_audit_log a"+where, args...).Scan(&total); err != nil {
return nil, list, err
}
list = finishWebList(list, total)
queryArgs := append([]interface{}{}, args...)
queryArgs = append(queryArgs, list.PerPage, (list.Page-1)*list.PerPage)
rows, err := s.db.Query(`SELECT a.event_type,COALESCE(u.username,a.user_id,''),COALESCE(d.name,a.device_id,''),COALESCE(a.ip,''),COALESCE(a.message,''),a.created_at FROM server_audit_log a LEFT JOIN server_users u ON u.id=a.user_id LEFT JOIN server_devices d ON d.id=a.device_id`+where+` ORDER BY a.id DESC LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
return nil, list, err
}
defer rows.Close()
var out []webAudit
for rows.Next() {
var a webAudit
if err := rows.Scan(&a.Event, &a.User, &a.Device, &a.IP, &a.Message, &a.At); err != nil {
return nil, list, err
}
a.Severity = auditSeverity(a.Event)
out = append(out, a)
}
return out, list, rows.Err()
}
func auditSeverity(event string) string {
if strings.Contains(event, "failed") || strings.Contains(event, "error") {
return "error"
}
if strings.Contains(event, "rate_limit") || strings.Contains(event, "revoked") || strings.Contains(event, "blocked") {
return "warning"
}
return "info"
}
func (s *Server) webSMTP() webSMTP {
return webSMTP{Host: s.smtpGet("smtp_host"), Port: s.smtpGet("smtp_port"), User: s.smtpGet("smtp_user"), Security: s.smtpGet("smtp_security"), From: s.smtpGet("smtp_from"), ServerURL: s.smtpGet("server_url")}
}
func (s *Server) adminReauth(r *http.Request, subject string) bool {
return s.cfg.CheckAdmin(subject, r.FormValue("password"))
}
func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w, http.MethodPost)
return
}
session, ok := s.requireSession(w, r, sessionScopeAdmin)
if !ok || !s.verifyCSRF(w, r, session) {
return
}
if err := r.ParseForm(); err != nil {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/admin/dashboard")
return
}
action := r.FormValue("action")
switch action {
case "web-settings":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/settings")
return
}
locale := r.FormValue("default_locale")
if locale != "ru" && locale != "en" {
locale = "en"
}
publicURL := strings.TrimRight(strings.TrimSpace(r.FormValue("public_url")), "/")
if publicURL != "" {
parsed, err := url.ParseRequestURI(publicURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
s.renderWebError(w, r, http.StatusBadRequest, "error.invalidPublicURL", "/admin/settings")
return
}
}
s.cfg.mu.Lock()
s.cfg.Web.DefaultLocale = locale
s.cfg.Web.AllowRegistration = r.FormValue("allow_registration") == "on"
s.cfg.Web.ServerName = strings.TrimSpace(r.FormValue("server_name"))
s.cfg.PublicURL = publicURL
err := s.cfg.saveLocked()
s.cfg.mu.Unlock()
if err != nil {
jsonInternalError(w, err)
return
}
s.auditLog("web_settings_updated", "", "", s.clientIP(r), "updated by administrator")
http.Redirect(w, r, "/admin/settings?flash=settings_saved", http.StatusSeeOther)
case "toggle-user":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users")
return
}
id := r.FormValue("id")
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
defer tx.Rollback()
var blocked int
if err := tx.QueryRow("SELECT blocked FROM server_users WHERE id=?", id).Scan(&blocked); err != nil {
if err == sql.ErrNoRows {
s.renderWebError(w, r, http.StatusNotFound, "error.badRequest", "/admin/users")
return
}
jsonInternalError(w, err)
return
}
newValue := 1
if blocked != 0 {
newValue = 0
}
if _, err := tx.Exec("UPDATE server_users SET blocked=? WHERE id=?", newValue, id); err != nil {
jsonInternalError(w, err)
return
}
if newValue != 0 {
if _, err := tx.Exec("DELETE FROM server_sessions WHERE scope='user' AND subject_id=?", id); err != nil {
jsonInternalError(w, err)
return
}
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
s.auditLog("user_block_changed", id, "", s.clientIP(r), "changed by administrator")
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
case "edit-user":
id, username, email := r.FormValue("id"), strings.TrimSpace(r.FormValue("username")), strings.ToLower(strings.TrimSpace(r.FormValue("email")))
if id == "" || username == "" || email == "" {
s.renderWebError(w, r, http.StatusBadRequest, "error.allFieldsRequired", "/admin/users")
return
}
if _, err := s.db.Exec("UPDATE server_users SET username=?, email=? WHERE id=?", username, email, id); err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
s.renderWebError(w, r, http.StatusConflict, "error.accountTaken", "/admin/users")
return
}
jsonInternalError(w, err)
return
}
s.auditLog("user_updated", id, "", s.clientIP(r), "updated by administrator")
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
case "confirm-user":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users")
return
}
id := r.FormValue("id")
result, err := s.db.Exec("UPDATE server_users SET confirmed=1 WHERE id=?", id)
if err != nil {
jsonInternalError(w, err)
return
}
if changed, err := result.RowsAffected(); err != nil || changed == 0 {
if err != nil {
jsonInternalError(w, err)
} else {
s.renderWebError(w, r, http.StatusNotFound, "error.badRequest", "/admin/users")
}
return
}
s.auditLog("user_confirmed", id, "", s.clientIP(r), "confirmed by administrator")
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
case "reset-user-password":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users")
return
}
id := r.FormValue("id")
password, err := randomSecret(16)
if err != nil {
jsonInternalError(w, err)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
jsonInternalError(w, err)
return
}
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
defer tx.Rollback()
if _, err := tx.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), id); err != nil {
jsonInternalError(w, err)
return
}
if _, err := tx.Exec("DELETE FROM server_sessions WHERE scope='user' AND subject_id=?", id); err != nil {
jsonInternalError(w, err)
return
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
s.auditLog("user_password_reset", id, "", s.clientIP(r), "reset by administrator")
cookie, err := r.Cookie("admin_session")
if err != nil {
jsonInternalError(w, err)
return
}
s.storeAdminOneTimeSecret(cookie.Value, password)
http.Redirect(w, r, "/admin/password-result", http.StatusSeeOther)
case "delete-user":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users")
return
}
id := r.FormValue("id")
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
defer tx.Rollback()
for _, statement := range []string{"DELETE FROM server_sessions WHERE subject_id=? AND scope='user'", "DELETE FROM server_email_tokens WHERE user_id=?", "DELETE FROM server_blob_refs WHERE user_id=?", "DELETE FROM server_idempotency_keys WHERE user_id=?", "DELETE FROM server_tombstones WHERE user_id=?", "DELETE FROM server_revisions WHERE op_id IN (SELECT op_id FROM server_ops WHERE user_id=?)", "DELETE FROM server_ops WHERE user_id=?", "DELETE FROM server_user_devices WHERE user_id=?", "DELETE FROM server_devices WHERE user_id=?", "DELETE FROM server_audit_log WHERE user_id=?", "DELETE FROM server_users WHERE id=?"} {
if _, err := tx.Exec(statement, id); err != nil {
jsonInternalError(w, err)
return
}
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
s.auditLog("user_deleted", id, "", s.clientIP(r), "deleted by administrator")
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
case "revoke-device":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/devices")
return
}
id := r.FormValue("id")
if err := s.revokeDevice(id, time.Now().UTC().Format(time.RFC3339)); err != nil {
jsonInternalError(w, err)
return
}
s.auditLog("device_revoked", "", id, s.clientIP(r), "revoked by administrator")
http.Redirect(w, r, "/admin/devices", http.StatusSeeOther)
case "delete-device":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/devices")
return
}
id := r.FormValue("id")
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
defer tx.Rollback()
var revoked string
if err := tx.QueryRow("SELECT COALESCE(revoked_at,'') FROM server_devices WHERE id=?", id).Scan(&revoked); err != nil {
if err == sql.ErrNoRows {
s.renderWebError(w, r, http.StatusNotFound, "error.badRequest", "/admin/devices")
} else {
jsonInternalError(w, err)
}
return
}
if revoked == "" {
s.renderWebError(w, r, http.StatusConflict, "error.deviceMustBeRevoked", "/admin/devices")
return
}
for _, statement := range []string{"DELETE FROM server_user_devices WHERE device_id=?", "DELETE FROM server_devices WHERE id=?"} {
if _, err := tx.Exec(statement, id); err != nil {
jsonInternalError(w, err)
return
}
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
s.auditLog("device_deleted", "", id, s.clientIP(r), "revoked device deleted by administrator")
http.Redirect(w, r, "/admin/devices", http.StatusSeeOther)
case "smtp":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/settings")
return
}
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
defer tx.Rollback()
for _, key := range []string{"smtp_host", "smtp_port", "smtp_user", "smtp_security", "smtp_from", "server_url"} {
if _, err := tx.Exec("INSERT OR REPLACE INTO server_smtp_config (key, value) VALUES (?, ?)", key, r.FormValue(key)); err != nil {
jsonInternalError(w, err)
return
}
}
if pass := r.FormValue("smtp_pass"); pass != "" {
if _, err := tx.Exec("INSERT OR REPLACE INTO server_smtp_config (key, value) VALUES (?, ?)", "smtp_pass", pass); err != nil {
jsonInternalError(w, err)
return
}
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
s.auditLog("smtp_settings_updated", "", "", s.clientIP(r), "updated by administrator")
http.Redirect(w, r, "/admin/settings?flash=smtp_saved", http.StatusSeeOther)
case "smtp-test":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/settings")
return
}
smtp := s.webSMTP()
to := strings.TrimSpace(r.FormValue("test_to"))
if to == "" {
to = smtp.From
}
if smtp.Host == "" || smtp.Port == "" || smtp.From == "" || to == "" {
http.Redirect(w, r, "/admin/settings?flash=smtp_test_failed", http.StatusSeeOther)
return
}
if err := s.smtpTest(smtp.Host, smtp.Port, smtp.User, s.smtpGet("smtp_pass"), smtp.Security, smtp.From, to); err != nil {
log.Printf("admin SMTP test failed: %v", err)
s.auditLog("smtp_test_failed", "", "", s.clientIP(r), "tested by administrator")
http.Redirect(w, r, "/admin/settings?flash=smtp_test_failed", http.StatusSeeOther)
return
}
s.auditLog("smtp_test_passed", "", "", s.clientIP(r), "tested by administrator")
http.Redirect(w, r, "/admin/settings?flash=smtp_test_passed", http.StatusSeeOther)
case "cleanup":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/storage")
return
}
if err := s.CleanupRetention(time.Now().UTC()); err != nil {
jsonInternalError(w, err)
return
}
s.auditLog("retention_cleanup", "", "", s.clientIP(r), "safe retention cleanup run by administrator")
http.Redirect(w, r, "/admin/storage?flash=cleanup_done", http.StatusSeeOther)
default:
s.renderWebError(w, r, http.StatusBadRequest, "error.badRequest", "/admin/dashboard")
}
}
func (s *Server) handleAdminWebLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w, http.MethodPost)
return
}
if !s.requireAdminMutation(w, r) {
return
}
if cookie, err := r.Cookie("admin_session"); err == nil {
if err := s.deleteSession(cookie.Value); err != nil {
jsonInternalError(w, err)
return
}
}
s.clearSessionCookies(w, r, sessionScopeAdmin)
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
}
// handleAdminDiagnosticsJSON is intentionally a separate, authenticated
// download: it contains operational state but never paths, credentials,
// tokens, payloads, or user content.
func (s *Server) handleAdminDiagnosticsJSON(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
if !s.requireAdminCookie(w, r) {
return
}
stats, err := s.Stats(r.Context())
if err != nil {
jsonInternalError(w, err)
return
}
jsonOK(w, map[string]interface{}{
"health": s.healthStatus(r.Context()),
"stats": stats,
"limits": map[string]interface{}{
"max_json_body": s.cfg.Limits.MaxJSONBody,
"max_push_operations": s.cfg.Limits.MaxPushOperations,
"max_pull_page": s.cfg.Limits.MaxPullPage,
"max_blob_bytes": s.cfg.Limits.MaxBlobBytes,
},
"web": map[string]interface{}{
"default_locale": s.cfg.Web.DefaultLocale,
"registration_allowed": s.cfg.Web.AllowRegistration,
},
})
}

View File

@ -0,0 +1,110 @@
package server
import (
"crypto/subtle"
"net/http"
"strings"
)
const (
webLocaleCookieName = "verstak_locale"
webLocaleCSRFCookieName = "verstak_locale_csrf"
)
func isSupportedWebLocale(locale string) bool {
return locale == "en" || locale == "ru"
}
// resolveWebLocale has a deliberately small, documented locale model. The
// cookie is a user preference; "system" delegates to Accept-Language, then
// the configured server default, and finally English.
func resolveWebLocale(r *http.Request, cfg *Config) string {
configured := "en"
if cfg != nil && isSupportedWebLocale(cfg.Web.DefaultLocale) {
configured = cfg.Web.DefaultLocale
}
if cookie, err := r.Cookie(webLocaleCookieName); err == nil {
switch cookie.Value {
case "ru", "en":
return cookie.Value
case "system":
if locale := localeFromAcceptLanguage(r.Header.Get("Accept-Language")); locale != "" {
return locale
}
return configured
default:
return configured
}
}
if locale := localeFromAcceptLanguage(r.Header.Get("Accept-Language")); locale != "" {
return locale
}
return configured
}
func localeFromAcceptLanguage(header string) string {
for _, part := range strings.Split(header, ",") {
language := strings.ToLower(strings.TrimSpace(strings.SplitN(part, ";", 2)[0]))
if language == "ru" || strings.HasPrefix(language, "ru-") {
return "ru"
}
if language == "en" || strings.HasPrefix(language, "en-") {
return "en"
}
}
return ""
}
func (s *Server) webLocale(r *http.Request) string {
return resolveWebLocale(r, s.cfg)
}
func (s *Server) webLocalePreference(r *http.Request) string {
if cookie, err := r.Cookie(webLocaleCookieName); err == nil && (cookie.Value == "ru" || cookie.Value == "en" || cookie.Value == "system") {
return cookie.Value
}
return "system"
}
func (s *Server) setWebLocale(w http.ResponseWriter, r *http.Request, locale string) {
secure := s.requestIsHTTPS(r)
http.SetCookie(w, &http.Cookie{
Name: webLocaleCookieName, Value: locale, Path: "/", HttpOnly: true,
Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: 365 * 24 * 60 * 60,
})
}
// webLocaleCSRF protects the public language-preference form without
// conflating it with session CSRF tokens. Its value is rendered by the server,
// while the matching cookie is HttpOnly and never read by client-side code.
func (s *Server) webLocaleCSRF(w http.ResponseWriter, r *http.Request) string {
if cookie, err := r.Cookie(webLocaleCSRFCookieName); err == nil && cookie.Value != "" {
return cookie.Value
}
token, err := randomSecret(32)
if err != nil {
return ""
}
http.SetCookie(w, &http.Cookie{
Name: webLocaleCSRFCookieName, Value: token, Path: "/", HttpOnly: true,
Secure: s.requestIsHTTPS(r), SameSite: http.SameSiteStrictMode, MaxAge: 24 * 60 * 60,
})
return token
}
func (s *Server) verifyWebLocaleCSRF(r *http.Request) bool {
cookie, err := r.Cookie(webLocaleCSRFCookieName)
if err != nil || cookie.Value == "" || !s.sameOrigin(r) {
return false
}
candidate := r.FormValue("locale_csrf")
return candidate != "" && subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(candidate)) == 1
}
func (s *Server) requirePublicWebMutation(w http.ResponseWriter, r *http.Request, back string) bool {
if s.verifyWebLocaleCSRF(r) {
return true
}
s.renderWebError(w, r, http.StatusForbidden, "error.tryAgain", back)
return false
}

View File

@ -0,0 +1,698 @@
package server
import (
"io/fs"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
)
func TestResolveWebLocaleCookieOverridesSystemAcceptLanguage(t *testing.T) {
cfg := DefaultConfig()
cfg.Web.DefaultLocale = "en"
req := httptest.NewRequest(http.MethodGet, "/login", nil)
req.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en;q=0.8")
req.AddCookie(&http.Cookie{Name: webLocaleCookieName, Value: "en"})
if got := resolveWebLocale(req, cfg); got != "en" {
t.Fatalf("locale = %q, want cookie locale en", got)
}
}
func TestFormatWebTimeUsesSelectedLocale(t *testing.T) {
stamp := "2026-07-17T13:45:00Z"
if got := formatWebTime("ru", stamp); !strings.Contains(got, "17.07.2026") {
t.Fatalf("Russian timestamp = %q", got)
}
if got := formatWebTime("en", stamp); !strings.Contains(got, "Jul 17, 2026") {
t.Fatalf("English timestamp = %q", got)
}
}
func TestTranslationCatalogsHaveMatchingKeysAndHideUnknownKeys(test *testing.T) {
for key := range _translations["en"] {
if _, ok := _translations["ru"][key]; !ok {
test.Fatalf("English key %q is missing in Russian catalog", key)
}
}
for key := range _translations["ru"] {
if _, ok := _translations["en"][key]; !ok {
test.Fatalf("Russian key %q is missing in English catalog", key)
}
}
if got := t("en", "missing.key"); got == "missing.key" || strings.Contains(got, "missing.key") {
test.Fatalf("unknown key leaked to UI: %q", got)
}
}
func TestEmbeddedTemplatesUseExternalAssetsAndNoInlineEventHandlers(t *testing.T) {
entries, err := fs.Glob(webFS, "web/templates/*.html")
if err != nil {
t.Fatal(err)
}
for _, name := range entries {
body, err := webFS.ReadFile(name)
if err != nil {
t.Fatal(err)
}
text := string(body)
for _, forbidden := range []string{"<style", " onclick=", " onchange="} {
if strings.Contains(strings.ToLower(text), forbidden) {
t.Fatalf("%s contains forbidden inline asset/handler %q", name, forbidden)
}
}
}
}
func TestConfirmationUsesLocalDialogInsteadOfBrowserPrompt(t *testing.T) {
layout, err := webFS.ReadFile("web/templates/layout.html")
if err != nil {
t.Fatal(err)
}
script, err := webFS.ReadFile("web/static/app.js")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(layout), `id="confirm-dialog"`) || !strings.Contains(string(script), ".showModal()") || strings.Contains(string(script), "window.confirm") {
t.Fatalf("local confirmation dialog is missing or browser prompt remains")
}
}
func TestEmbeddedTemplateTranslationKeysExist(t *testing.T) {
entries, err := fs.Glob(webFS, "web/templates/*.html")
if err != nil {
t.Fatal(err)
}
keyPattern := regexp.MustCompile(`t\s+\$?\.Locale\s+"([^"]+)"`)
for _, name := range entries {
body, err := webFS.ReadFile(name)
if err != nil {
t.Fatal(err)
}
for _, match := range keyPattern.FindAllStringSubmatch(string(body), -1) {
for _, locale := range []string{"ru", "en"} {
if _, ok := _translations[locale][match[1]]; !ok {
t.Fatalf("%s references missing %s translation key %q", name, locale, match[1])
}
}
}
}
}
func TestPublicHomeUsesSharedLocalizedTemplateLayout(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Accept-Language", "ru-RU")
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("home status = %d", res.Code)
}
body := res.Body.String()
for _, want := range []string{`<html lang="ru">`, `/static/app.css`, "Verstak Sync"} {
if !strings.Contains(body, want) {
t.Fatalf("home is missing %q: %s", want, body)
}
}
if strings.Contains(body, "<style>") {
t.Fatalf("home must use embedded static CSS, not inline style: %s", body)
}
}
func TestPublicHomeUsesUnavailablePageWhenReadinessFails(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
s.SetupRoutes()
if err := s.db.Close(); err != nil {
t.Fatal(err)
}
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/", nil))
if res.Code != http.StatusServiceUnavailable || !strings.Contains(res.Body.String(), `<html lang="en">`) {
t.Fatalf("unavailable page=%d: %s", res.Code, res.Body.String())
}
}
func TestPublicTemplateRoutesRenderInBothLocales(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
for _, locale := range []string{"ru", "en"} {
for _, path := range []string{"/", "/login", "/register", "/forgot", "/admin/login"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(&http.Cookie{Name: webLocaleCookieName, Value: locale})
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("%s %s = %d", locale, path, res.Code)
}
if !strings.Contains(res.Body.String(), `<html lang="`+locale+`">`) {
t.Fatalf("%s %s has wrong document language", locale, path)
}
}
}
}
func TestAdminPagesRenderWithActiveNavigationInBothLocales(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES ('u1','alice','alice@example.test','hash',1,'2026-01-01T00:00:00Z')"); err != nil {
t.Fatal(err)
}
if _, err := s.db.Exec("INSERT INTO server_devices (id,name,api_key,user_id,vault_id,created_at) VALUES ('d1','Laptop','legacy','u1','vault-a','2026-01-01T00:00:00Z')"); err != nil {
t.Fatal(err)
}
token, _, err := s.createSession(sessionScopeAdmin, "admin")
if err != nil {
t.Fatal(err)
}
for _, locale := range []string{"ru", "en"} {
for _, tc := range []struct{ path, active string }{
{"/admin/dashboard", "/admin/dashboard"}, {"/admin/users", "/admin/users"}, {"/admin/devices", "/admin/devices"}, {"/admin/vaults", "/admin/vaults"}, {"/admin/vault/?user=u1&vault=vault-a", "/admin/vaults"}, {"/admin/storage", "/admin/storage"}, {"/admin/audit", "/admin/audit"}, {"/admin/settings", "/admin/settings"}, {"/admin/diagnostics", "/admin/diagnostics"},
} {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
req.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
req.AddCookie(&http.Cookie{Name: webLocaleCookieName, Value: locale})
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), `<html lang="`+locale+`">`) || !strings.Contains(res.Body.String(), `href="`+tc.active+`" aria-current="page"`) {
t.Fatalf("%s %s = %d; active navigation missing: %s", locale, tc.path, res.Code, res.Body.String())
}
}
}
}
func TestConfirmationPageUsesSharedTemplateAndEscapesToken(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/confirm?token=%3Cscript%3E", nil)
req.AddCookie(&http.Cookie{Name: webLocaleCookieName, Value: "ru"})
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), `<html lang="ru">`) || strings.Contains(res.Body.String(), "<script>") {
t.Fatalf("confirmation template/escaping failure: %d %s", res.Code, res.Body.String())
}
}
func TestNewServerSetsConfigPathInsideDataDirectory(t *testing.T) {
dir := t.TempDir()
cfg := DefaultConfig()
s, err := NewServer(dir+"/server.db", dir+"/data", cfg)
if err != nil {
t.Fatal(err)
}
defer s.Close()
if !strings.HasPrefix(cfg.path, dir+"/data/") {
t.Fatalf("config path escaped data directory: %q", cfg.path)
}
}
func TestWebSessionScopesDoNotCrossAuthorizePages(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
adminToken, _, err := s.createSession(sessionScopeAdmin, "admin")
if err != nil {
t.Fatal(err)
}
userToken, _, err := s.createSession(sessionScopeUser, "user")
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct{ path, cookie string }{{"/dashboard", adminToken}, {"/admin/dashboard", userToken}} {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
if tc.path == "/dashboard" {
req.AddCookie(&http.Cookie{Name: "admin_session", Value: tc.cookie})
} else {
req.AddCookie(&http.Cookie{Name: "user_session", Value: tc.cookie})
}
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusFound {
t.Fatalf("%s with wrong scope = %d", tc.path, res.Code)
}
}
}
func TestAdminVaultDetailIsScopedAndDoesNotExposePayload(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES ('u1','alice','a@example.test','hash',1,'2026-01-01T00:00:00Z')"); err != nil {
t.Fatal(err)
}
if _, err := s.db.Exec("INSERT INTO server_devices (id,name,api_key,user_id,vault_id,created_at) VALUES ('d1','Laptop','legacy','u1','vault-a','2026-01-01T00:00:00Z')"); err != nil {
t.Fatal(err)
}
if _, err := s.db.Exec("INSERT INTO server_ops (op_id,server_sequence,user_id,vault_id,device_id,entity_type,entity_id,op_type,payload_json,created_at,pushed_at) VALUES ('op1',1,'u1','vault-a','d1','file','x','create','{\"secret\":\"payload\"}','2026-01-01T00:00:00Z','2026-01-01T00:00:00Z')"); err != nil {
t.Fatal(err)
}
token, _, err := s.createSession(sessionScopeAdmin, "admin")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/admin/vault/?user=u1&vault=vault-a", nil)
req.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("vault detail=%d: %s", res.Code, res.Body.String())
}
if strings.Contains(res.Body.String(), "payload") || strings.Contains(res.Body.String(), "secret") {
t.Fatalf("vault detail leaked operation payload: %s", res.Body.String())
}
}
func TestLocaleSelectionUsesCookieAndPRG(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader("locale=ru&from=/login&locale_csrf=locale-test-token"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: webLocaleCSRFCookieName, Value: "locale-test-token"})
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusSeeOther || res.Header().Get("Location") != "/login" {
t.Fatalf("locale response = %d %q", res.Code, res.Header().Get("Location"))
}
cookie := res.Result().Cookies()[0]
if cookie.Name != webLocaleCookieName || cookie.Value != "ru" || !cookie.HttpOnly || cookie.SameSite != http.SameSiteLaxMode {
t.Fatalf("unexpected locale cookie: %#v", cookie)
}
}
func TestLocaleSelectionPreservesResetQuery(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader("locale=ru&from=/reset%3Ftoken%3Dopaque-token&locale_csrf=locale-test-token"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: webLocaleCSRFCookieName, Value: "locale-test-token"})
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusSeeOther || res.Header().Get("Location") != "/reset?token=opaque-token" {
t.Fatalf("locale redirect=%d %q", res.Code, res.Header().Get("Location"))
}
}
func TestLocaleSelectionRejectsMissingOrMismatchedCSRF(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
for _, tc := range []struct {
name, body, cookie string
}{
{"missing", "locale=ru&from=/login", ""},
{"mismatched", "locale=ru&from=/login&locale_csrf=other", "locale-test-token"},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader(tc.body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if tc.cookie != "" {
req.AddCookie(&http.Cookie{Name: webLocaleCSRFCookieName, Value: tc.cookie})
}
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusForbidden {
t.Fatalf("locale CSRF status=%d, want 403", res.Code)
}
})
}
}
func TestPublicWebFormsRejectMissingCSRF(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
for _, path := range []string{"/register", "/login", "/forgot", "/reset", "/admin/login"} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("username=alice&email=alice%40example.test&password=password&confirm=password"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusForbidden {
t.Fatalf("%s without public CSRF = %d, want 403", path, res.Code)
}
}
}
func TestWebResponsesHaveSecurityHeaders(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/login", nil))
for name, value := range map[string]string{"X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "Referrer-Policy": "same-origin"} {
if got := res.Header().Get(name); got != value {
t.Fatalf("%s=%q, want %q", name, got, value)
}
}
if got := res.Header().Get("Content-Security-Policy"); !strings.Contains(got, "default-src 'self'") || !strings.Contains(got, "frame-ancestors 'none'") {
t.Fatalf("CSP=%q", got)
}
}
func TestAdminLoginUsesSharedTemplateAndAdminRootRedirects(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
login := httptest.NewRecorder()
s.Handler().ServeHTTP(login, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
if login.Code != http.StatusOK || !strings.Contains(login.Body.String(), "/static/app.css") || strings.Contains(login.Body.String(), "<style>") {
t.Fatalf("admin login did not use shared template: %d %s", login.Code, login.Body.String())
}
root := httptest.NewRecorder()
s.Handler().ServeHTTP(root, httptest.NewRequest(http.MethodGet, "/admin", nil))
if root.Code != http.StatusFound || root.Header().Get("Location") != "/admin/dashboard" {
t.Fatalf("admin root=%d %q", root.Code, root.Header().Get("Location"))
}
}
func TestDiagnosticsDownloadRequiresAdminAndDoesNotExposePaths(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
unauthorized := httptest.NewRecorder()
s.Handler().ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/admin/diagnostics.json", nil))
if unauthorized.Code != http.StatusFound {
t.Fatalf("unauthorized diagnostics = %d", unauthorized.Code)
}
token, _, err := s.createSession(sessionScopeAdmin, "admin")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/admin/diagnostics.json", nil)
req.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("diagnostics = %d: %s", res.Code, res.Body.String())
}
if strings.Contains(res.Body.String(), s.dbPath) || strings.Contains(res.Body.String(), s.blobsDir) {
t.Fatalf("diagnostics leaked internal path: %s", res.Body.String())
}
}
func TestAdminUserListSearchStatusAndPagination(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
for i, username := range []string{"alice", "alina", "blocked-user", "bob"} {
blocked := 0
if username == "blocked-user" {
blocked = 1
}
if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,blocked,created_at) VALUES (?, ?, ?, 'hash', 1, ?, '2026-01-01T00:00:00Z')", strconvItoa(i), username, username+"@example.test", blocked); err != nil {
t.Fatal(err)
}
}
items, list, err := s.webAdminUsers(webList{Query: "ali", Page: 1, PerPage: 1})
if err != nil {
t.Fatal(err)
}
if list.Total != 2 || list.Pages != 2 || len(items) != 1 || items[0].Username != "alice" {
t.Fatalf("search pagination: list=%+v items=%+v", list, items)
}
items, list, err = s.webAdminUsers(webList{Status: "blocked", Page: 1, PerPage: 25})
if err != nil {
t.Fatal(err)
}
if list.Total != 1 || len(items) != 1 || !items[0].Blocked {
t.Fatalf("status filter: list=%+v items=%+v", list, items)
}
}
func TestAdminSettingsRendersAndSavesSMTPConfiguration(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
if err := s.cfg.SetAdmin("admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
s.SetupRoutes()
token, csrf, err := s.createSession(sessionScopeAdmin, "admin")
if err != nil {
t.Fatal(err)
}
get := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
get.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
get.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
getResult := httptest.NewRecorder()
s.Handler().ServeHTTP(getResult, get)
if getResult.Code != http.StatusOK {
t.Fatalf("settings page = %d: %s", getResult.Code, getResult.Body.String())
}
for _, field := range []string{"smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_security", "smtp_from", "server_url"} {
if !strings.Contains(getResult.Body.String(), `name="`+field+`"`) {
t.Fatalf("settings page is missing SMTP field %q: %s", field, getResult.Body.String())
}
}
body := "csrf_token=" + csrf + "&action=smtp&smtp_host=mail.example.test&smtp_port=587&smtp_user=mailer&smtp_pass=mail-secret&smtp_security=starttls&smtp_from=sync%40example.test&server_url=https%3A%2F%2Fsync.example.test&password=correct+horse+battery+staple"
post := httptest.NewRequest(http.MethodPost, "/admin/action", strings.NewReader(body))
post.Header.Set("Content-Type", "application/x-www-form-urlencoded")
post.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
post.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
postResult := httptest.NewRecorder()
s.Handler().ServeHTTP(postResult, post)
if postResult.Code != http.StatusSeeOther || postResult.Header().Get("Location") != "/admin/settings?flash=smtp_saved" {
t.Fatalf("save SMTP = %d %q: %s", postResult.Code, postResult.Header().Get("Location"), postResult.Body.String())
}
if got := s.smtpGet("smtp_host"); got != "mail.example.test" {
t.Fatalf("saved SMTP host = %q", got)
}
}
func TestUserDashboardOnlyRendersOwnFilteredDevices(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
for _, user := range []struct{ id, name string }{{"user-a", "alice"}, {"user-b", "bob"}} {
if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES (?, ?, ?, 'hash', 1, '2026-01-01T00:00:00Z')", user.id, user.name, user.name+"@example.test"); err != nil {
t.Fatal(err)
}
}
for _, device := range []struct{ id, user, name string }{{"device-a", "user-a", "Alice laptop"}, {"device-b", "user-b", "Bob workstation"}} {
if _, err := s.db.Exec("INSERT INTO server_devices (id,name,api_key,user_id,vault_id,created_at) VALUES (?, ?, ?, ?, 'vault', '2026-01-01T00:00:00Z')", device.id, device.name, "key-"+device.id, device.user); err != nil {
t.Fatal(err)
}
if _, err := s.db.Exec("INSERT INTO server_user_devices (user_id,device_id) VALUES (?,?)", device.user, device.id); err != nil {
t.Fatal(err)
}
}
token, _, err := s.createSession(sessionScopeUser, "user-a")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/dashboard?q=Alice", nil)
req.AddCookie(&http.Cookie{Name: "user_session", Value: token})
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("dashboard=%d: %s", res.Code, res.Body.String())
}
if !strings.Contains(res.Body.String(), "Alice laptop") || strings.Contains(res.Body.String(), "Bob workstation") {
t.Fatalf("dashboard leaked or missed device: %s", res.Body.String())
}
}
func TestAdminDeviceFiltersAndAuditSearchRemainBounded(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
for _, user := range []struct{ id, name string }{{"u1", "alice"}, {"u2", "bob"}} {
if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES (?, ?, ?, 'hash', 1, '2026-01-01T00:00:00Z')", user.id, user.name, user.name+"@example.test"); err != nil {
t.Fatal(err)
}
}
for _, device := range []struct{ id, user, vault, version string }{{"d1", "u1", "vault-a", "2.0"}, {"d2", "u2", "vault-b", "1.0"}} {
if _, err := s.db.Exec("INSERT INTO server_devices (id,name,api_key,user_id,vault_id,client_version,created_at) VALUES (?, ?, ?, ?, ?, ?, '2026-01-01T00:00:00Z')", device.id, device.id, "key-"+device.id, device.user, device.vault, device.version); err != nil {
t.Fatal(err)
}
}
devices, list, err := s.webAdminDevices(webList{User: "alice", Vault: "vault-a", Version: "2.0", Sort: "name", Page: 1, PerPage: 25})
if err != nil || len(devices) != 1 || devices[0].ID != "d1" || list.Sort != "name" {
t.Fatalf("filtered devices=%+v list=%+v err=%v", devices, list, err)
}
if _, err := s.db.Exec("INSERT INTO server_audit_log (event_type,user_id,message,created_at) VALUES ('device_paired','u1','safe','2026-01-01T00:00:00Z')"); err != nil {
t.Fatal(err)
}
audit, _, err := s.webAudit(webList{Event: "' OR 1=1 --", Page: 1, PerPage: 25})
if err != nil || len(audit) != 0 {
t.Fatalf("audit injection filter returned=%+v err=%v", audit, err)
}
}
func TestAdminCanConfirmUnconfirmedUserWithCSRFAndReauth(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
if err := s.cfg.SetAdmin("admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
s.SetupRoutes()
if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES ('u1','alice','alice@example.test','hash',0,'2026-01-01T00:00:00Z')"); err != nil {
t.Fatal(err)
}
token, csrf, err := s.createSession(sessionScopeAdmin, "admin")
if err != nil {
t.Fatal(err)
}
body := "csrf_token=" + csrf + "&action=confirm-user&id=u1&password=correct+horse+battery+staple"
req := httptest.NewRequest(http.MethodPost, "/admin/action", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusSeeOther || res.Header().Get("Location") != "/admin/users" {
t.Fatalf("confirm user=%d %q: %s", res.Code, res.Header().Get("Location"), res.Body.String())
}
var confirmed int
if err := s.db.QueryRow("SELECT confirmed FROM server_users WHERE id='u1'").Scan(&confirmed); err != nil || confirmed != 1 {
t.Fatalf("confirmed=%d err=%v", confirmed, err)
}
}
func TestAdminPasswordResetShowsGeneratedSecretOnce(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
if err := s.cfg.SetAdmin("admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
s.SetupRoutes()
if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES ('u1','alice','alice@example.test','old',1,'2026-01-01T00:00:00Z')"); err != nil {
t.Fatal(err)
}
adminToken, csrf, err := s.createSession(sessionScopeAdmin, "admin")
if err != nil {
t.Fatal(err)
}
if _, _, err := s.createSession(sessionScopeUser, "u1"); err != nil {
t.Fatal(err)
}
body := "csrf_token=" + csrf + "&action=reset-user-password&id=u1&password=correct+horse+battery+staple"
request := httptest.NewRequest(http.MethodPost, "/admin/action", strings.NewReader(body))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(&http.Cookie{Name: "admin_session", Value: adminToken})
request.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
response := httptest.NewRecorder()
s.Handler().ServeHTTP(response, request)
if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/admin/password-result" {
t.Fatalf("reset=%d %q: %s", response.Code, response.Header().Get("Location"), response.Body.String())
}
var sessions int
if err := s.db.QueryRow("SELECT COUNT(*) FROM server_sessions WHERE scope='user' AND subject_id='u1'").Scan(&sessions); err != nil || sessions != 0 {
t.Fatalf("user sessions=%d err=%v", sessions, err)
}
resultRequest := httptest.NewRequest(http.MethodGet, "/admin/password-result", nil)
resultRequest.AddCookie(&http.Cookie{Name: "admin_session", Value: adminToken})
resultResponse := httptest.NewRecorder()
s.Handler().ServeHTTP(resultResponse, resultRequest)
if resultResponse.Code != http.StatusOK || !strings.Contains(resultResponse.Header().Get("Cache-Control"), "no-store") || !strings.Contains(resultResponse.Body.String(), "data-one-time-secret-url") {
t.Fatalf("password result=%d headers=%v body=%s", resultResponse.Code, resultResponse.Header(), resultResponse.Body.String())
}
secretRequest := httptest.NewRequest(http.MethodPost, "/admin/password-result/secret", nil)
secretRequest.Header.Set("X-CSRF-Token", csrf)
secretRequest.AddCookie(&http.Cookie{Name: "admin_session", Value: adminToken})
secretRequest.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
secretResponse := httptest.NewRecorder()
s.Handler().ServeHTTP(secretResponse, secretRequest)
if secretResponse.Code != http.StatusOK || !strings.Contains(secretResponse.Header().Get("Cache-Control"), "no-store") || !strings.Contains(secretResponse.Body.String(), `"password"`) {
t.Fatalf("secret response=%d headers=%v body=%s", secretResponse.Code, secretResponse.Header(), secretResponse.Body.String())
}
secondRequest := httptest.NewRequest(http.MethodPost, "/admin/password-result/secret", nil)
secondRequest.Header.Set("X-CSRF-Token", csrf)
secondRequest.AddCookie(&http.Cookie{Name: "admin_session", Value: adminToken})
secondRequest.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
secondResponse := httptest.NewRecorder()
s.Handler().ServeHTTP(secondResponse, secondRequest)
if secondResponse.Code != http.StatusGone {
t.Fatalf("second password result=%d: %s", secondResponse.Code, secondResponse.Body.String())
}
}
func TestResolveWebLocaleSystemUsesAcceptLanguageAndFallsBack(t *testing.T) {
cfg := DefaultConfig()
cfg.Web.DefaultLocale = "en"
for _, test := range []struct {
name string
cookie string
header string
want string
}{
{"system russian", "system", "ru-RU,ru;q=0.9", "ru"},
{"unknown cookie", "de", "ru-RU", "en"},
{"no header", "system", "", "en"},
} {
t.Run(test.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/login", nil)
req.Header.Set("Accept-Language", test.header)
req.AddCookie(&http.Cookie{Name: webLocaleCookieName, Value: test.cookie})
if got := resolveWebLocale(req, cfg); got != test.want {
t.Fatalf("locale = %q, want %q", got, test.want)
}
})
}
}

View File

@ -0,0 +1,370 @@
package server
import (
"embed"
"html/template"
"io/fs"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
//go:embed web/templates/*.html web/static/*
var webFS embed.FS
type webRenderer struct {
templates map[string]*template.Template
static fs.FS
}
type webPage struct {
Locale string
LocalePreference string
DefaultLocale string
Title string
ServerName string
PublicURL string
TrustedProxies string
Limits Limits
CurrentPath string
CurrentURL string
CSRF string
LocaleCSRF string
Flash string
FlashError bool
AllowRegistration bool
Version string
BuildCommit string
Now time.Time
Heading string
Message string
Status string
FormAction string
BackURL string
Token string
Admin bool
UserName string
Email string
UserConfirmed bool
Devices []webDevice
AdminPage string
Stats ServerStats
Health HealthStatus
AdminUsers []webAdminUser
AdminDevices []webAdminDevice
Vaults []webVault
VaultDevices []webAdminDevice
Audit []webAudit
Warnings []string
SMTP webSMTP
List webList
VaultDetail webVaultDetail
}
type webAdminUser struct {
ID, Username, Email, CreatedAt, LastSeen string
Confirmed, Blocked bool
Devices, Vaults int
}
type webAdminDevice struct {
ID, Name, User, Vault, Version, LastIP, LastSeen, CreatedAt, TokenHint string
Revoked bool
}
type webVault struct {
User, UserID, Vault string
Devices, Operations int
LastActivity string
}
type webAudit struct{ Event, User, Device, IP, Message, Severity, At string }
type webSMTP struct{ Host, Port, User, Security, From, ServerURL string }
type webList struct {
Query, Status, Sort, User, Vault, Version, Event, Severity string
Page int
PerPage int
Total int
Pages int
Previous int
Next int
}
func (list webList) params(page int) string {
values := url.Values{}
for key, value := range map[string]string{"q": list.Query, "status": list.Status, "sort": list.Sort, "user": list.User, "vault": list.Vault, "version": list.Version, "event": list.Event, "severity": list.Severity} {
if value != "" {
values.Set(key, value)
}
}
if page > 1 {
values.Set("page", strconvItoa(page))
}
if list.PerPage != 25 {
values.Set("per_page", strconvItoa(list.PerPage))
}
return values.Encode()
}
type webVaultDetail struct {
User, Vault, LastActivity string
Devices, Active, Revoked int
Operations, Sequence int
BlobBytes int64
}
type webDevice struct {
ID string
Name string
Vault string
ClientVersion string
CreatedAt string
LastSeen string
Revoked bool
TokenHint string
}
func newWebRenderer() (*webRenderer, error) {
funcs := template.FuncMap{
"t": func(locale, key string) string { return t(locale, key) },
"webtime": func(locale, value string) string { return formatWebTime(locale, value) },
"webbytes": func(value int64) string { return formatWebBytes(value) },
"listparams": func(list webList, page int) string { return list.params(page) },
"auditlabel": func(locale, event string) string { return auditEventLabel(locale, event) },
"statuslabel": func(locale, status string) string { return statusLabel(locale, status) },
"boollabel": func(locale string, value bool) string { return boolLabel(locale, value) },
"short": func(value string, length int) string {
if len(value) <= length || length < 5 {
return value
}
return value[:length-1] + "…"
},
}
layout, err := template.New("layout.html").Funcs(funcs).ParseFS(webFS, "web/templates/layout.html", "web/templates/admin_nav.html")
if err != nil {
return nil, err
}
renderer := &webRenderer{templates: make(map[string]*template.Template)}
for _, page := range []string{"home", "unavailable", "login", "register", "forgot", "reset", "confirm", "message", "error", "admin_login", "dashboard", "admin_dashboard", "admin_users", "admin_devices", "admin_vaults", "admin_storage", "admin_audit", "admin_diagnostics", "admin_create_user", "admin_password_result", "vault_detail", "admin_settings"} {
clone, err := layout.Clone()
if err != nil {
return nil, err
}
if _, err := clone.ParseFS(webFS, "web/templates/"+page+".html"); err != nil {
return nil, err
}
renderer.templates[page] = clone
}
renderer.static, err = fs.Sub(webFS, "web/static")
if err != nil {
return nil, err
}
return renderer, nil
}
func formatWebTime(locale, value string) string {
if value == "" {
return "—"
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return value
}
if locale == "ru" {
return parsed.Local().Format("02.01.2006 15:04")
}
return parsed.Local().Format("Jan 2, 2006 15:04")
}
func formatWebBytes(value int64) string {
if value < 1024 {
return strconvItoa(int(value)) + " B"
}
units := []string{"KB", "MB", "GB", "TB"}
amount := float64(value)
for _, unit := range units {
amount /= 1024
if amount < 1024 || unit == "TB" {
return strconv.FormatFloat(amount, 'f', 1, 64) + " " + unit
}
}
return "0 B"
}
func auditEventLabel(locale, event string) string {
keys := map[string]string{
"device_auth_failed": "audit.deviceAuthFailed",
"device_paired": "audit.devicePaired",
"device_revoked": "audit.deviceRevoked",
"device_deleted": "audit.deviceDeleted",
"rate_limit_exceeded": "audit.rateLimited",
"retention_cleanup": "audit.retentionCleanup",
"smtp_settings_updated": "audit.smtpSettingsUpdated",
"smtp_test_failed": "audit.smtpTestFailed",
"smtp_test_passed": "audit.smtpTestPassed",
"user_block_changed": "audit.userBlockChanged",
"user_confirmed": "audit.userConfirmed",
"user_created": "audit.userCreated",
"user_deleted": "audit.userDeleted",
"user_password_reset": "audit.userPasswordReset",
"user_updated": "audit.userUpdated",
"web_settings_updated": "audit.webSettingsUpdated",
}
if key := keys[event]; key != "" {
return t(locale, key)
}
return t(locale, "audit.other")
}
func statusLabel(locale, status string) string {
if status == "ok" {
return t(locale, "status.ok")
}
return t(locale, "status.degraded")
}
func boolLabel(locale string, value bool) string {
if value {
return t(locale, "status.available")
}
return t(locale, "status.unavailable")
}
func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page string, data webPage) {
s.renderPageStatus(w, r, page, data, http.StatusOK)
}
func (s *Server) renderPageStatus(w http.ResponseWriter, r *http.Request, page string, data webPage, status int) {
if s.web == nil || s.web.templates[page] == nil {
jsonInternalError(w, errWebTemplateUnavailable)
return
}
data.Locale = s.webLocale(r)
data.LocalePreference = s.webLocalePreference(r)
data.DefaultLocale = s.cfg.Web.DefaultLocale
data.ServerName = s.cfg.Web.ServerName
data.PublicURL = s.cfg.PublicURL
data.TrustedProxies = strings.Join(s.cfg.TrustedProxies, ", ")
data.Limits = s.cfg.Limits
data.CurrentPath = r.URL.Path
data.CurrentURL = r.URL.RequestURI()
data.LocaleCSRF = s.webLocaleCSRF(w, r)
data.AllowRegistration = s.cfg.Web.AllowRegistration
data.Version = Version
data.BuildCommit = BuildCommit
data.Now = time.Now().UTC()
if cookie, err := r.Cookie("csrf_token"); err == nil {
data.CSRF = cookie.Value
}
if data.Admin || data.UserName != "" {
w.Header().Set("Cache-Control", "no-store")
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
if status != http.StatusOK {
w.WriteHeader(status)
}
if err := s.web.templates[page].ExecuteTemplate(w, page, data); err != nil {
jsonInternalError(w, err)
}
}
var errWebTemplateUnavailable = &webTemplateError{}
type webTemplateError struct{}
func (*webTemplateError) Error() string { return "web template unavailable" }
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
methodNotAllowed(w, http.MethodGet, http.MethodHead)
return
}
http.StripPrefix("/static/", http.FileServer(http.FS(s.web.static))).ServeHTTP(w, r)
}
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
s.handleNotFound(w, r)
return
}
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
status := s.healthStatus(r.Context())
if status.Status != "ok" {
s.renderPageStatus(w, r, "unavailable", webPage{Title: "home.unavailableTitle", Heading: "home.unavailableTitle", Message: "home.unavailableMessage"}, http.StatusServiceUnavailable)
return
}
s.renderPage(w, r, "home", webPage{Title: "home.title", Status: status.Status})
}
func (s *Server) handleLocale(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w, http.MethodPost)
return
}
if err := r.ParseForm(); err != nil {
s.renderPage(w, r, "error", webPage{Title: "error.badRequest", Heading: "error.badRequest", Message: "error.tryAgain"})
return
}
if !s.verifyWebLocaleCSRF(r) {
s.renderPageStatus(w, r, "error", webPage{Title: "error.label", Heading: "error.badRequest", Message: "error.tryAgain", BackURL: "/"}, http.StatusForbidden)
return
}
locale := r.FormValue("locale")
if locale != "ru" && locale != "en" && locale != "system" {
locale = "system"
}
s.setWebLocale(w, r, locale)
from := r.FormValue("from")
if !strings.HasPrefix(from, "/") || strings.HasPrefix(from, "//") {
from = "/"
}
http.Redirect(w, r, from, http.StatusSeeOther)
}
func (s *Server) handleRegistrationResult(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
s.renderPage(w, r, "message", webPage{Title: "register.resultTitle", Heading: "register.resultTitle", Message: "register.resultMessage", BackURL: "/login"})
}
func (s *Server) handleForgotSent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
s.renderPage(w, r, "message", webPage{Title: "forgot.sentTitle", Heading: "forgot.sentTitle", Message: "forgot.sentMessage", BackURL: "/login"})
}
func (s *Server) handleResetDone(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
w.Header().Set("Cache-Control", "no-store")
s.renderPage(w, r, "message", webPage{Title: "reset.doneTitle", Heading: "reset.doneTitle", Message: "reset.doneMessage", BackURL: "/login"})
}
func (s *Server) handleConfirmResult(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
w.Header().Set("Cache-Control", "no-store")
s.renderPage(w, r, "message", webPage{Title: "confirm.resultTitle", Heading: "confirm.resultTitle", Message: "confirm.resultMessage", BackURL: "/login"})
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; script-src 'self'; style-src 'self'")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "same-origin")
w.Header().Set("X-Frame-Options", "DENY")
next.ServeHTTP(w, r)
})
}

View File

@ -0,0 +1,7 @@
## Highlights
- First public alpha of the optional Verstak synchronization server.
- Provides isolated user and vault synchronization, an admin interface, user management, SMTP configuration, and password-reset flows.
- Includes a Linux archive, installation script, backup guidance, and security hardening for server-rendered data and API keys.
**Commit history**: https://github.com/mirivlad/verstak-sync-server/commits/v0.1.0-alpha.2

View File

@ -0,0 +1,5 @@
## Highlights
- Client-facing errors no longer expose internal server details.
- The SMTP security selector follows the rest of the server settings UI.
- The release workflow uploads only artifacts belonging to the requested version and marks alpha versions as prereleases.

View File

@ -0,0 +1,3 @@
## Highlights
- Updated AGENTS.md API endpoint documentation to match the current server implementation.

View File

@ -22,7 +22,9 @@ mkdir -p "$OUTPUT_DIR"
# Build
echo "→ Building server binary..."
cd "$REPO_ROOT"
go build -o "$BINARY" ./cmd/server
VERSION="${VERSION:-dev}"
COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
go build -ldflags "-X github.com/verstak/verstak-sync-server/internal/server.Version=$VERSION -X github.com/verstak/verstak-sync-server/internal/server.BuildCommit=$COMMIT" -o "$BINARY" ./cmd/server
echo "✅ Binary built: $BINARY"
ls -lh "$BINARY"

140
scripts/install.sh Normal file → Executable file
View File

@ -1,113 +1,89 @@
#!/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)
#
# Install a locally built Verstak Sync Server without exposing an admin
# password through argv or the installation log.
set -eu
umask 077
set -e
# Defaults
PORT="${VERSTAK_PORT:-47732}"
LISTEN="${VERSTAK_LISTEN:-127.0.0.1:47732}"
USER="verstak"
ADMIN_USER=""
ADMIN_PASS=""
ADMIN_PASS_FILE=""
BIN="./verstak-sync-server"
# Parse flags
while [ $# -gt 0 ]; do
while [ "$#" -gt 0 ]; do
case "$1" in
--port) PORT="$2"; shift 2 ;;
--listen) LISTEN="$2"; shift 2 ;;
--port) LISTEN="127.0.0.1:$2"; shift 2 ;; # compatibility, still loopback
--user) USER="$2"; shift 2 ;;
--admin-user) ADMIN_USER="$2"; shift 2 ;;
--admin-pass) ADMIN_PASS="$2"; shift 2 ;;
--admin-pass-file) ADMIN_PASS_FILE="$2"; shift 2 ;;
--bin) BIN="$2"; shift 2 ;;
*) echo "Unknown: $1"; exit 1 ;;
*) echo "Unknown option: $1" >&2; exit 2 ;;
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
if [ -z "$ADMIN_USER" ]; then
echo "Usage: $0 --admin-user USER [--admin-pass-file FILE] [--listen 127.0.0.1:47732]" >&2
exit 2
fi
if [ "$(id -u)" -ne 0 ]; then
echo "This script must be run as root (sudo)."
echo "This script must be run as root (sudo)." >&2
exit 1
fi
if [ ! -f "$BIN" ]; then
echo "Binary not found: $BIN. Build it first with ./scripts/build.sh" >&2
exit 1
fi
echo "=== Verstak Sync Server Installation ==="
echo "Port: $PORT"
echo "User: $USER"
echo "Admin: $ADMIN_USER"
echo "Binary: $BIN"
echo ""
PASS_TMP="$(mktemp /tmp/verstak-admin-pass.XXXXXX)"
trap 'rm -f "$PASS_TMP"' EXIT HUP INT TERM
if [ -n "$ADMIN_PASS_FILE" ]; then
if [ ! -r "$ADMIN_PASS_FILE" ]; then
echo "Admin password file is not readable" >&2
exit 1
fi
cp "$ADMIN_PASS_FILE" "$PASS_TMP"
else
printf 'Initial admin password: ' >&2
stty -echo
IFS= read -r ADMIN_PASS
stty echo
printf '\n' >&2
printf '%s\n' "$ADMIN_PASS" > "$PASS_TMP"
unset ADMIN_PASS
fi
INSTALL_DIR="/opt/verstak-sync-server"
DATA_DIR="/var/lib/verstak-sync-server"
ENV_DIR="/etc/verstak-server"
install -d -m 0755 "$INSTALL_DIR"
install -m 0755 "$BIN" "$INSTALL_DIR/verstak-sync-server"
# 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
install -d -o "$USER" -g "$USER" -m 0750 "$DATA_DIR"
chown "$USER:$USER" "$PASS_TMP"
# 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" &
# Initialize config as the service account. The process only receives the path
# to a 0600 temporary file, never password text in argv.
runuser -u "$USER" -- "$INSTALL_DIR/verstak-sync-server" \
--data "$DATA_DIR" --listen "$LISTEN" --admin-user "$ADMIN_USER" \
--admin-pass-file "$PASS_TMP" >/dev/null 2>&1 &
SERVER_PID=$!
sleep 2
sleep 1
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"
install -d -m 0750 "$ENV_DIR"
printf 'VERSTAK_LISTEN=%s\n' "$LISTEN" > "$ENV_DIR/env"
chmod 0640 "$ENV_DIR/env"
cp "$(dirname "$0")/../verstak-server.service" /etc/systemd/system/verstak-server.service
chmod 0644 /etc/systemd/system/verstak-server.service
# 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
systemctl restart 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"
echo "Installed verstak-server listening on $LISTEN."
echo "Admin: http://$LISTEN/admin/login"

View File

@ -6,6 +6,7 @@ VERSION="${1:-}"
REPOSITORY="mirivlad/verstak-sync-server"
RELEASE_SCRIPT="${VERSTAK_RELEASE_SCRIPT:-$ROOT/scripts/release.sh}"
RELEASE_DIR="${VERSTAK_RELEASE_DIR:-$ROOT/release}"
RELEASE_NOTES_DIR="${VERSTAK_RELEASE_NOTES_DIR:-$ROOT/release-notes}"
GIT_BIN="${GIT_BIN:-git}"
GH_BIN="${GH_BIN:-gh}"
@ -62,7 +63,17 @@ 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)
NOTES_FILE="$RELEASE_NOTES_DIR/$VERSION.md"
if [[ ! -s "$NOTES_FILE" ]]; then
echo "human-readable release notes are required: $NOTES_FILE" >&2
exit 1
fi
RELEASE_OPTIONS=(--notes-file "$NOTES_FILE" --generate-notes --verify-tag)
PREVIOUS_TAG="$("$GIT_BIN" describe --tags --abbrev=0 "${HEAD}^" 2>/dev/null || true)"
if [[ -n "$PREVIOUS_TAG" ]]; then
RELEASE_OPTIONS+=(--notes-start-tag "$PREVIOUS_TAG")
fi
if [[ "$VERSION" == *-* ]]; then
RELEASE_OPTIONS+=(--prerelease)
else

View File

@ -15,9 +15,9 @@ if [[ "$(go env GOOS)" != "linux" || "$(go env GOARCH)" != "amd64" ]]; then
fi
echo "=== verstak sync server release $VERSION ==="
"$ROOT/scripts/build.sh"
VERSION="$VERSION" "$ROOT/scripts/build.sh"
RELEASE_ROOT="$ROOT/release"
RELEASE_ROOT="${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"

153
scripts/smoke-web-browser.mjs Executable file
View File

@ -0,0 +1,153 @@
#!/usr/bin/env node
// Browser smoke driver for scripts/smoke-web.sh. It uses Node's bundled
// undici WebSocket client and Chromium DevTools; no npm package is required.
import { createRequire } from "node:module";
import { writeFile } from "node:fs/promises";
const require = createRequire(import.meta.url);
const { WebSocket } = require("undici");
const [baseURL, outputDir] = process.argv.slice(2);
if (!baseURL || !outputDir) throw new Error("usage: smoke-web-browser.mjs <base-url> <output-dir>");
const debugURL = process.env.CHROME_DEBUG_URL || "http://127.0.0.1:9223";
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitFor(check, label) {
for (let attempt = 0; attempt < 100; attempt += 1) {
if (await check()) return;
await delay(100);
}
throw new Error(`timed out waiting for ${label}`);
}
const targets = await (await fetch(`${debugURL}/json/list`)).json();
const target = targets.find((item) => item.type === "page");
if (!target?.webSocketDebuggerUrl) throw new Error("Chromium DevTools page target is unavailable");
const socket = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((resolve, reject) => {
socket.addEventListener("open", resolve, { once: true });
socket.addEventListener("error", reject, { once: true });
});
let nextID = 1;
const pending = new Map();
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (!message.id) return;
const request = pending.get(message.id);
if (!request) return;
pending.delete(message.id);
if (message.error) request.reject(new Error(`${message.error.message} (${message.error.code})`));
else request.resolve(message.result);
});
function cdp(method, params = {}) {
const id = nextID++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
socket.send(JSON.stringify({ id, method, params }));
});
}
async function evaluate(expression) {
const result = await cdp("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
if (result.exceptionDetails) throw new Error(result.exceptionDetails.text || "browser evaluation failed");
return result.result.value;
}
async function navigate(path) {
await cdp("Page.navigate", { url: new URL(path, baseURL).href });
await waitFor(async () => String(await evaluate("location.href")).startsWith(new URL(path, baseURL).href), path);
await waitFor(() => evaluate("document.readyState === 'complete'"), `${path} document readiness`);
}
async function submit(selector, values, expectedPath) {
const payload = JSON.stringify(values);
await evaluate(`(() => { const form = document.querySelector(${JSON.stringify(selector)}); if (!form) throw new Error('form not found: ${selector}'); const values = ${payload}; for (const [name, value] of Object.entries(values)) { const input = form.querySelector('[name="' + name + '"]'); if (!input) throw new Error('input not found: ' + name); input.value = value; } form.requestSubmit(); })()`);
await waitFor(async () => new URL(await evaluate("location.href")).pathname === expectedPath, expectedPath);
}
async function screenshot(name) {
const image = await cdp("Page.captureScreenshot", { format: "png" });
await writeFile(`${outputDir}/${name}.png`, Buffer.from(image.data, "base64"));
}
async function confirmDialog(expectedPath) {
await waitFor(() => evaluate("document.querySelector('#confirm-dialog')?.open === true"), "confirmation dialog");
await evaluate("document.querySelector('#confirm-dialog button[value=confirm]').click()");
await waitFor(() => evaluate("document.querySelector('#confirm-dialog')?.open === false"), "confirmation dialog close");
await delay(300);
await waitFor(async () => new URL(await evaluate("location.href")).pathname === expectedPath, expectedPath);
}
await cdp("Page.enable");
await cdp("Runtime.enable");
// Public locale selector, then a real admin login through the rendered form.
await navigate("/");
await screenshot("public-en");
await waitFor(() => evaluate("Boolean(document.querySelector('.locale-form select'))"), "locale selector");
await evaluate("document.querySelector('.locale-form select').value = 'ru'; document.querySelector('.locale-form').requestSubmit()");
await waitFor(() => evaluate("document.documentElement?.lang === 'ru'"), "Russian locale");
await screenshot("public-ru");
await navigate("/admin/login");
await submit('form[action="/admin/login"]', { username: "admin", password: "browser-smoke-admin-password" }, "/admin/dashboard");
// Seed data before rendering admin views, so screenshots cover non-empty
// users/devices/vaults/storage/audit states.
await navigate("/admin/create-user");
await submit('form[action="/admin/create-user"]', { username: "browser-smoke-user", email: "browser-smoke@example.test", password: "browser-smoke-password" }, "/admin/users");
const paired = await fetch(`${baseURL}/api/client/pair`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ login: "browser-smoke-user", password: "browser-smoke-password", device_name: "Browser smoke laptop", vault_id: "browser-smoke-vault", client_version: "smoke" }) });
if (!paired.ok) throw new Error(`pairing smoke device failed: ${paired.status}`);
await navigate("/admin/dashboard");
await screenshot("admin-dashboard");
// Core admin navigation with populated data.
for (const [path, name] of [["/admin/users", "admin-users"], ["/admin/devices", "admin-devices"], ["/admin/vaults", "admin-vaults"], ["/admin/storage", "admin-storage"], ["/admin/audit", "admin-audit"], ["/admin/settings", "admin-settings"], ["/admin/diagnostics", "admin-diagnostics"]]) {
await navigate(path);
await screenshot(name);
}
await navigate("/admin/vaults");
const vaultDetail = await evaluate("document.querySelector('a[href^=\"/admin/vault/\"]')?.getAttribute('href')");
if (!vaultDetail) throw new Error("vault detail link not found");
await navigate(vaultDetail);
await screenshot("admin-vault-detail");
await navigate("/admin/users?q=browser-smoke-user");
// Exercise the destructive confirmation dialog by blocking and unblocking the
// temporary user. requestSubmit is intentionally not used for this action.
async function toggleTemporaryUser() {
await evaluate(`(() => { const row = [...document.querySelectorAll('tbody tr')].find((item) => item.textContent.includes('browser-smoke-user')); if (!row) throw new Error('temporary user row not found'); const details = row.querySelector('details'); details.open = true; const form = [...row.querySelectorAll('form')].find((item) => item.querySelector('[name=action]')?.value === 'toggle-user'); form.querySelector('[name=password]').value = 'browser-smoke-admin-password'; form.querySelector('button').click(); })()`);
await confirmDialog("/admin/users");
await navigate("/admin/users?q=browser-smoke-user");
}
await toggleTemporaryUser();
await toggleTemporaryUser();
// A password reset is generated server-side, rendered once with no secret in
// the URL, and invalidates any prior user session. Keep it only in this test
// process so the subsequent user login exercises the generated credential.
await evaluate(`(() => { const row = [...document.querySelectorAll('tbody tr')].find((item) => item.textContent.includes('browser-smoke-user')); const details = row.querySelector('details'); details.open = true; const form = [...row.querySelectorAll('form')].find((item) => item.querySelector('[name=action]')?.value === 'reset-user-password'); form.querySelector('[name=password]').value = 'browser-smoke-admin-password'; form.querySelector('button').click(); })()`);
await confirmDialog("/admin/password-result");
await waitFor(() => evaluate("!['Загрузка...', 'Loading...', '—'].includes(document.querySelector('.one-time-secret')?.textContent.trim())"), "generated one-time password");
await screenshot("admin-password-result");
const generatedPassword = await evaluate("document.querySelector('.one-time-secret')?.textContent.trim()");
if (!generatedPassword || (await evaluate("location.href")).includes(generatedPassword)) throw new Error("one-time password result is missing or leaked into the URL");
await navigate("/admin/users?q=browser-smoke-user");
// Revoke the real temporary device through the browser UI and assert the
// modal path again.
await navigate("/admin/devices?q=Browser%20smoke");
await evaluate(`(() => { const row = [...document.querySelectorAll('tbody tr')].find((item) => item.textContent.includes('Browser smoke laptop')); if (!row) throw new Error('smoke device row not found'); const form = row.querySelector('form'); form.querySelector('[name=password]').value = 'browser-smoke-admin-password'; form.querySelector('button').click(); })()`);
await confirmDialog("/admin/devices");
// Server-side filters and HTML logout complete the browser path.
await navigate("/admin/audit?q=device");
await screenshot("admin-audit-filtered");
await evaluate("document.querySelector('form[action=\"/admin/logout\"]').requestSubmit()");
await waitFor(async () => new URL(await evaluate("location.href")).pathname === "/admin/login", "admin logout");
await navigate("/login");
await submit('form[action="/login"]', { username: "browser-smoke-user", password: generatedPassword }, "/dashboard");
await screenshot("user-dashboard");
await evaluate("document.querySelector('form[action=\"/logout\"]').requestSubmit()");
await waitFor(async () => new URL(await evaluate("location.href")).pathname === "/login", "user logout");
socket.close();
console.log("interactive Chromium web smoke passed");

24
scripts/smoke-web.sh Executable file
View File

@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TMP="$(mktemp -d /tmp/verstak-sync-web.XXXXXX)"
PORT="47794"
DEBUG_PORT="9223"
cleanup() { kill "${BROWSER_PID:-}" 2>/dev/null || true; kill "${SERVER_PID:-}" 2>/dev/null || true; wait "${BROWSER_PID:-}" 2>/dev/null || true; wait "${SERVER_PID:-}" 2>/dev/null || true; if [[ "${KEEP_SMOKE_ARTIFACTS:-}" == "1" ]]; then printf 'web smoke artefacts retained at %s\n' "$TMP"; else rm -rf "$TMP"; fi; }
trap cleanup EXIT
printf '%s\n' 'browser-smoke-admin-password' > "$TMP/admin-pass"
chmod 600 "$TMP/admin-pass"
(cd "$ROOT" && go run ./cmd/server --data "$TMP/data" --listen "127.0.0.1:$PORT" --admin-user admin --admin-pass-file "$TMP/admin-pass") >"$TMP/server.log" 2>&1 &
SERVER_PID=$!
for _ in {1..30}; do curl --noproxy '*' -fsS "http://127.0.0.1:$PORT/" >/dev/null 2>&1 && break; sleep 1; done
curl --noproxy '*' -fsS "http://127.0.0.1:$PORT/" >/dev/null
chromium --headless --no-sandbox --disable-gpu --window-size=1440,900 --remote-debugging-port="$DEBUG_PORT" --user-data-dir="$TMP/chromium" about:blank >"$TMP/chromium.log" 2>&1 &
BROWSER_PID=$!
for _ in {1..30}; do curl --noproxy '*' -fsS "http://127.0.0.1:$DEBUG_PORT/json/list" >/dev/null 2>&1 && break; sleep 1; done
curl --noproxy '*' -fsS "http://127.0.0.1:$DEBUG_PORT/json/list" >/dev/null
CHROME_DEBUG_URL="http://127.0.0.1:$DEBUG_PORT" node "$ROOT/scripts/smoke-web-browser.mjs" "http://127.0.0.1:$PORT" "$TMP"
chromium --headless --no-sandbox --disable-gpu --screenshot="$TMP/public-mobile.png" --window-size=390,844 "http://127.0.0.1:$PORT/login" >/dev/null 2>&1
test -s "$TMP/public-en.png" && test -s "$TMP/public-mobile.png" && test -s "$TMP/admin-dashboard.png" && test -s "$TMP/admin-settings.png"
echo "interactive web browser smoke passed (set KEEP_SMOKE_ARTIFACTS=1 to retain temporary screenshots)"

View File

@ -15,9 +15,10 @@ if [[ ! -x "$PUBLISHER" ]]; then
exit 1
fi
mkdir -p "$WORK/bin" "$WORK/release" "$WORK/state"
mkdir -p "$WORK/bin" "$WORK/release" "$WORK/release-notes" "$WORK/state"
LOG="$WORK/log"
export LOG
printf '## Highlights\n\nHuman-readable release notes.\n' > "$WORK/release-notes/$VERSION.md"
cat > "$WORK/release.sh" <<'SCRIPT'
#!/usr/bin/env bash
@ -48,6 +49,7 @@ case "${1:-}" in
fi
echo test-commit
;;
describe) echo v0.0.0-previous ;;
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 ;;
@ -73,6 +75,7 @@ chmod +x "$WORK/bin/gh"
run_publisher() {
VERSTAK_RELEASE_SCRIPT="$WORK/release.sh" \
VERSTAK_RELEASE_DIR="$WORK/release" \
VERSTAK_RELEASE_NOTES_DIR="$WORK/release-notes" \
GIT_BIN="$WORK/bin/git" \
GH_BIN="$WORK/bin/gh" \
EXPECTED_ROOT="$ROOT" \
@ -87,6 +90,9 @@ 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 -- "--notes-file $WORK/release-notes/$VERSION.md" "$LOG" >/dev/null
grep -F -- "--generate-notes" "$LOG" >/dev/null
grep -F -- "--notes-start-tag v0.0.0-previous" "$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

View File

@ -7,16 +7,20 @@ 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
ExecStart=/opt/verstak-sync-server/verstak-sync-server --data /var/lib/verstak-sync-server
Restart=on-failure
RestartSec=5
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ProtectSystem=full
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectControlGroups=true
ProtectKernelModules=true
LockPersonality=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
ReadWritePaths=/var/lib/verstak-sync-server
StateDirectory=verstak-sync-server
RuntimeDirectory=verstak-sync-server