Compare commits
16 Commits
v0.1.0-alp
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
3bbeb42d37 | |
|
|
f35ee2aaa2 | |
|
|
d1e4068636 | |
|
|
e8605f15a2 | |
|
|
0014910638 | |
|
|
b3f8fb69a0 | |
|
|
513675c852 | |
|
|
50d0d7df38 | |
|
|
6ca174b2d9 | |
|
|
5ca030e5f9 | |
|
|
f82d3b0224 | |
|
|
4b78f30a61 | |
|
|
b7f730cba9 | |
|
|
487ede8e4f | |
|
|
e68d057ec7 | |
|
|
4e4eaac66f |
|
|
@ -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
|
||||
|
|
|
|||
71
AGENTS.md
71
AGENTS.md
|
|
@ -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
213
README.md
|
|
@ -1,3 +1,17 @@
|
|||
<div align="center">
|
||||
|
||||
# Verstak Sync Server
|
||||
|
||||
### Optional self-hosted synchronization relay for Verstak vaults.
|
||||
|
||||
**English** · [Русский](README.ru.md)
|
||||
|
||||
[](https://github.com/mirivlad/verstak-sync-server/releases)
|
||||

|
||||
[](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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
<div align="center">
|
||||
|
||||
# Verstak Sync Server
|
||||
|
||||
### Собственный сервер синхронизации для vault-хранилищ Верстака.
|
||||
|
||||
[English](README.md) · **Русский**
|
||||
|
||||
[](https://github.com/mirivlad/verstak-sync-server/releases)
|
||||

|
||||
[](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.
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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) }
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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[:])
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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:])
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()">×</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()">×</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()">×</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()">×</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()">×</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,'&').replace(/</g,'<').replace(/>/g,'>')}
|
||||
function escJS(s){return s.replace(/'/g,"\\'").replace(/"/g,'"')}
|
||||
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"),
|
||||
)
|
||||
}
|
||||
|
|
@ -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; } }
|
||||
|
|
@ -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 = "—"; });
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}&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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
## Highlights
|
||||
|
||||
- Updated AGENTS.md API endpoint documentation to match the current server implementation.
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -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)"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue