Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d05ffc8cf3 | ||
|
|
0d0ae92dec | ||
|
|
66bd53f835 | ||
|
|
4958e6ca44 | ||
|
|
8da7f10c6b | ||
|
|
2ea29cb503 | ||
|
|
31f18dade1 | ||
|
|
657cc23e7a | ||
|
|
086bc4ea4a | ||
|
|
5c8644d5e1 | ||
|
|
1084c91854 | ||
|
|
2e6b451c3d | ||
|
|
fcf0a07fcc | ||
|
|
c793084fa4 | ||
|
|
c9b35295cb | ||
|
|
cec2305b15 | ||
|
|
8b9c07ae06 | ||
|
|
5e2be2b888 | ||
|
|
dc4507a7ab | ||
|
|
81756e598f | ||
|
|
e0ae36998e | ||
|
|
69dacb40b0 | ||
|
|
984acd2f7e |
+16
@@ -0,0 +1,16 @@
|
||||
# Binaries
|
||||
/server
|
||||
/verstak-sync-server
|
||||
*.exe
|
||||
|
||||
# Data directory
|
||||
server-data/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
release/
|
||||
@@ -0,0 +1,17 @@
|
||||
Verstak Sync Server
|
||||
Copyright (C) 2026 Verstak contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under
|
||||
the terms of the GNU Affero General Public License as published by the Free
|
||||
Software Foundation, either version 3 of the License, or (at your option) any
|
||||
later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
details.
|
||||
|
||||
The complete license text is available at:
|
||||
https://www.gnu.org/licenses/agpl-3.0.html
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
@@ -1,3 +1,214 @@
|
||||
# verstak-sync-server
|
||||
# Verstak Sync Server
|
||||
|
||||
Verstak Sync Server — HTTP API, auth/pairing, device registry, vault operation log, blob upload/download, conflict handling
|
||||
Standalone sync server for Verstak2 platform.
|
||||
|
||||
## Overview
|
||||
|
||||
This server provides synchronization between devices running Verstak2. It handles:
|
||||
|
||||
- Device registration and authentication
|
||||
- Vault-scoped operation log sync with server sequence numbers and conflict detection
|
||||
- Blob storage for attachments
|
||||
- User management with email confirmation
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Build (produces binary at build/bin/verstak-sync-server)
|
||||
./scripts/build.sh
|
||||
|
||||
# Run
|
||||
./build/bin/verstak-sync-server --port 47732 --data ./server-data
|
||||
|
||||
# First run with admin user
|
||||
./build/bin/verstak-sync-server --admin-user admin --admin-pass secret
|
||||
```
|
||||
|
||||
## Release packages
|
||||
|
||||
Build a Linux amd64 archive locally:
|
||||
|
||||
```bash
|
||||
./scripts/release.sh v0.1.0-alpha.1
|
||||
```
|
||||
|
||||
It runs the server build and tests, then writes
|
||||
`release/verstak-sync-server-linux-amd64-<version>.tar.gz` and `SHA256SUMS`.
|
||||
The archive contains the server binary, systemd service file and install
|
||||
script.
|
||||
|
||||
Publish those same assets to GitHub Releases:
|
||||
|
||||
```bash
|
||||
./scripts/publish-github-release.sh v0.1.0-alpha.1
|
||||
```
|
||||
|
||||
The publisher requires an authenticated [`gh`](https://cli.github.com/) CLI
|
||||
and a clean local `main` equal to `origin/main`. It creates and pushes an
|
||||
annotated tag when necessary, then creates or updates the GitHub Release.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--port` | 47732 | HTTP port |
|
||||
| `--data` | ./server-data | Data directory |
|
||||
| `--admin-user` | | Create admin user (first run) |
|
||||
| `--admin-pass` | | Admin password (first run) |
|
||||
|
||||
Production installs use:
|
||||
|
||||
- binary: `/opt/verstak-sync-server/verstak-sync-server`;
|
||||
- data directory: `/var/lib/verstak-sync-server`;
|
||||
- port environment file: `/etc/verstak-server/env`;
|
||||
- service: `verstak-server`.
|
||||
|
||||
Install from a built binary:
|
||||
|
||||
```bash
|
||||
./scripts/build.sh
|
||||
sudo ./scripts/install.sh \
|
||||
--bin ./build/bin/verstak-sync-server \
|
||||
--port 47732 \
|
||||
--admin-user admin \
|
||||
--admin-pass 'change-this-password'
|
||||
```
|
||||
|
||||
The install script creates a locked-down system user, initializes the data
|
||||
directory, writes `/etc/verstak-server/env`, installs the systemd unit, and
|
||||
starts the service.
|
||||
|
||||
## Deployment
|
||||
|
||||
Run the service behind HTTPS in production. The sync server itself listens on
|
||||
plain HTTP; terminate TLS in a reverse proxy such as nginx, Caddy, or a platform
|
||||
load balancer, then forward to `127.0.0.1:47732`.
|
||||
|
||||
Basic service operations:
|
||||
|
||||
```bash
|
||||
sudo systemctl status verstak-server
|
||||
sudo journalctl -u verstak-server -f
|
||||
curl http://127.0.0.1:47732/api/v1/health
|
||||
```
|
||||
|
||||
Change the listen port:
|
||||
|
||||
```bash
|
||||
echo 'VERSTAK_PORT=47733' | sudo tee /etc/verstak-server/env
|
||||
sudo systemctl restart verstak-server
|
||||
```
|
||||
|
||||
Upgrade the binary:
|
||||
|
||||
```bash
|
||||
./scripts/build.sh
|
||||
sudo systemctl stop verstak-server
|
||||
sudo install -m 755 ./build/bin/verstak-sync-server /opt/verstak-sync-server/verstak-sync-server
|
||||
sudo systemctl start verstak-server
|
||||
```
|
||||
|
||||
Keep `--data` stable across upgrades. The data directory is the server's source
|
||||
of truth.
|
||||
|
||||
## Backup And Restore
|
||||
|
||||
Back up the full data directory while the service is stopped. It contains:
|
||||
|
||||
- `server.db` - SQLite database with users, devices, operations, SMTP settings,
|
||||
and blob metadata;
|
||||
- `config.yml` - admin user configuration;
|
||||
- `blobs/` - content-addressed blob files.
|
||||
|
||||
Create a backup:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop verstak-server
|
||||
sudo tar --xattrs --acls -czf verstak-sync-backup-$(date +%Y%m%d-%H%M%S).tar.gz \
|
||||
-C /var/lib verstak-sync-server
|
||||
sudo systemctl start verstak-server
|
||||
```
|
||||
|
||||
Restore onto a fresh host or after data loss:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop verstak-server
|
||||
sudo mv /var/lib/verstak-sync-server /var/lib/verstak-sync-server.broken.$(date +%Y%m%d-%H%M%S) 2>/dev/null || true
|
||||
sudo tar --xattrs --acls -xzf verstak-sync-backup-YYYYMMDD-HHMMSS.tar.gz -C /var/lib
|
||||
sudo chown -R verstak:verstak /var/lib/verstak-sync-server
|
||||
sudo chmod 750 /var/lib/verstak-sync-server
|
||||
sudo systemctl start verstak-server
|
||||
curl http://127.0.0.1:${VERSTAK_PORT:-47732}/api/v1/health
|
||||
```
|
||||
|
||||
After restore, connected desktop clients keep their existing device tokens.
|
||||
If a backup is older than some client changes, those clients may need to run
|
||||
sync again so unpushed local operations are re-sent.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
cmd/server/ - Entry point
|
||||
internal/server/ - Server implementation
|
||||
- server.go - Core server logic
|
||||
- routes.go - HTTP routing
|
||||
- handlers_api.go - Sync, client, health, and blob handlers
|
||||
- handlers_auth.go - User auth API handlers
|
||||
- handlers_admin.go - Admin web/API handlers
|
||||
- schema.go - Database schema
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Desktop sync client:
|
||||
|
||||
- `POST /api/client/pair` - Pair a desktop client with username/password and its persistent `vault_id`, then return a device token
|
||||
- `POST /api/auth/test` - Validate username/password from the desktop client
|
||||
- `GET /api/client/me` - Return current authenticated client/device details
|
||||
- `POST /api/client/revoke-current` - Revoke the current desktop device token
|
||||
- `POST /api/client/revoke-device` - Revoke another device owned by the same user
|
||||
- `POST /api/v1/sync/push` - Push local operations to the server operation log
|
||||
- `POST /api/v1/sync/pull` - Pull operations since a server sequence number
|
||||
- `POST /api/v1/blobs/` - Store a multipart `file` blob and return its SHA-256 hash
|
||||
- `GET /api/v1/blobs/{sha256}` - Download a stored blob by SHA-256 hash
|
||||
|
||||
User API:
|
||||
|
||||
- `POST /api/v1/auth/register` - Register a user
|
||||
- `GET /api/v1/auth/confirm?token=...` - Confirm email
|
||||
- `POST /api/v1/auth/login` - User login
|
||||
- `POST /api/v1/auth/forgot` - Request password reset
|
||||
- `POST /api/v1/auth/reset` - Reset password
|
||||
- `GET /api/v1/user/devices` - List devices for the current user session
|
||||
|
||||
Operational endpoints:
|
||||
|
||||
- `GET /api/v1/health` - Server health and basic storage status
|
||||
- `/admin/...` - Admin web UI and admin JSON endpoints
|
||||
- `/register`, `/login`, `/dashboard`, `/forgot`, `/reset`, `/logout` - User web UI
|
||||
|
||||
Sync operations are generic records with `entity_type`, `entity_id`, `op_type`,
|
||||
`payload_json`, `device_id`, and sequencing metadata. A pairing token is bound
|
||||
to one user and vault. The server derives the stored device ID and operation
|
||||
scope from that token, ignores a caller-supplied `device_id` for authorization,
|
||||
and returns only operations and cursors from the authenticated user/vault.
|
||||
Verstak desktop owns the v2 payload semantics.
|
||||
|
||||
New device enrollment requires a non-empty `vault_id`. The `legacy:` prefix is
|
||||
reserved for server-side migration of older records and cannot be selected by
|
||||
new clients.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
go test ./...
|
||||
|
||||
# Build for production
|
||||
CGO_ENABLED=1 go build -o verstak-sync-server ./cmd/server
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Copyright © 2026 Verstak contributors. Licensed under
|
||||
[GNU AGPLv3 or later](LICENSE).
|
||||
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/verstak/verstak-sync-server/internal/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := flag.Int("port", 47732, "HTTP port")
|
||||
dataDir := flag.String("data", "./server-data", "Data directory (db, blobs, config)")
|
||||
adminUser := flag.String("admin-user", "", "Create admin user (first run)")
|
||||
adminPass := flag.String("admin-pass", "", "Admin password (first run)")
|
||||
flag.Parse()
|
||||
|
||||
absData, err := filepath.Abs(*dataDir)
|
||||
if err != nil {
|
||||
log.Fatalf("data dir: %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(absData, 0750); err != nil {
|
||||
log.Fatalf("create data dir: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := server.LoadConfig(absData)
|
||||
if err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
|
||||
if *adminUser != "" && *adminPass != "" {
|
||||
if err := cfg.SetAdmin(*adminUser, *adminPass); err != nil {
|
||||
log.Fatalf("set admin: %v", err)
|
||||
}
|
||||
fmt.Printf("Admin user %q created.\n", *adminUser)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(absData, "server.db")
|
||||
srv, err := server.NewServer(dbPath, absData, cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("server: %v", err)
|
||||
}
|
||||
defer srv.Close()
|
||||
|
||||
srv.SetupRoutes()
|
||||
|
||||
addr := fmt.Sprintf(":%d", *port)
|
||||
log.Printf("Verstak Sync Server starting on %s (data: %s)", addr, absData)
|
||||
if err := srv.ListenAndServe(addr); err != nil {
|
||||
log.Fatalf("serve: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
# Sync Tenant Isolation Implementation Plan
|
||||
|
||||
> **For agentic workers:** execute this plan task by task with focused tests
|
||||
> before each implementation change.
|
||||
|
||||
**Goal:** prevent cross-user and cross-vault operation visibility while binding
|
||||
the persisted source device to the authenticated token.
|
||||
|
||||
**Architecture:** the server derives user, device, and vault scope from the
|
||||
bearer token. SQLite rows carry that scope; desktop sends the immutable vault
|
||||
ID only while creating a pairing. Existing unscoped devices and operations are
|
||||
migrated into a deterministic legacy scope.
|
||||
|
||||
**Tech stack:** Go, `database/sql`, SQLite, `net/http`, desktop Go sync client.
|
||||
|
||||
## Task 1: Establish server behaviour tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/server/server_test.go`
|
||||
|
||||
- [x] Add helpers that create confirmed users and token-authenticated devices
|
||||
with a specified vault ID.
|
||||
- [x] Add a failing test where separate users push and pull from the same
|
||||
vault ID; each pull must contain only its own operation and cursor.
|
||||
- [x] Add a failing test where one user owns devices in two vaults; pulls must
|
||||
remain vault-local.
|
||||
- [x] Add a failing test that sends another device's ID in `push`; assert the
|
||||
stored and returned operation uses the authenticated device ID.
|
||||
- [x] Add a failing test for identical idempotency keys in different scopes.
|
||||
- [x] Run: `go test ./internal/server -run 'TestSync.*Isolation|TestSyncPush'`
|
||||
and confirm the new assertions fail for the intended missing behaviour.
|
||||
|
||||
## Task 2: Add idempotent SQLite scope migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/server/schema.go`
|
||||
- Modify: `internal/server/server.go`
|
||||
- Test: `internal/server/server_test.go`
|
||||
|
||||
- [x] Define `vault_id` on new devices and `user_id`/`vault_id` on new
|
||||
operations; define scoped tombstone and idempotency primary keys.
|
||||
- [x] Add startup migration helpers that inspect columns, add compatible
|
||||
columns, backfill owner IDs, assign `legacy:<user_id>` to old scopes, and
|
||||
rebuild the two tables whose primary keys change.
|
||||
- [x] Add a failing legacy-schema fixture test, then make it pass by opening
|
||||
the database through `NewServer` and asserting its operation has the
|
||||
expected owner and legacy scope.
|
||||
- [x] Run: `go test ./internal/server -run 'Test.*Migration|TestSync.*'`.
|
||||
|
||||
## Task 3: Apply authenticated scope to sync handlers
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/server/middleware.go`
|
||||
- Modify: `internal/server/handlers_api.go`
|
||||
- Test: `internal/server/server_test.go`
|
||||
|
||||
- [x] Extend authenticated device lookup to provide the effective vault scope;
|
||||
missing user ownership must not authorize sync operations.
|
||||
- [x] Require `vault_id` when creating a new client pairing and store it with
|
||||
the device.
|
||||
- [x] Make push use authenticated device/user/vault values for inserts,
|
||||
conflicts, revisions, tombstones, and idempotency lookup/storage.
|
||||
- [x] Make pull filter operations and its reported cursor by authenticated
|
||||
user/vault.
|
||||
- [x] Run the focused tests from Task 1 until green, then
|
||||
`go test ./internal/server`.
|
||||
|
||||
## Task 4: Send the current vault ID while pairing
|
||||
|
||||
**Files:**
|
||||
- Modify: `../verstak-desktop/internal/core/sync/client.go`
|
||||
- Modify: `../verstak-desktop/internal/core/sync/client_test.go`
|
||||
- Modify: `../verstak-desktop/internal/api/app.go`
|
||||
- Modify: `../verstak-desktop/internal/api/app_test.go`
|
||||
|
||||
- [x] Add `vault_id` to the pair request and expose it in the pairing client
|
||||
method without changing push/pull wire compatibility.
|
||||
- [x] Read the open vault metadata in `syncConfigure`; reject configuration if
|
||||
the vault ID is absent.
|
||||
- [x] Add a failing client/API test that captures the pair request and asserts
|
||||
the persistent vault ID is sent.
|
||||
- [x] Rebind desktop sync state, cursor, and persisted device identity whenever
|
||||
a vault is created, opened, or switched.
|
||||
- [x] Hydrate missing legacy vault device IDs from the authenticated sync
|
||||
server before a token-based sync can use a global fallback.
|
||||
- [x] Run: `go test ./internal/core/sync ./internal/api`.
|
||||
|
||||
## Task 5: Document, verify, and publish
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/superpowers/specs/2026-07-10-sync-tenant-isolation-design.md`
|
||||
|
||||
- [x] Document that pairing is vault-bound and that sync cursors are scoped.
|
||||
- [x] Run `gofmt` on all changed Go files.
|
||||
- [x] Run `go test ./...` in both `verstak-sync-server` and
|
||||
`verstak-desktop`, then `git diff --check` in both repositories.
|
||||
- [x] Commit and push the sync-server and desktop changes as coordinated
|
||||
security commits.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Sync Tenant Isolation Design
|
||||
|
||||
**Status:** approved for implementation by the project owner on 2026-07-10.
|
||||
|
||||
## Goal
|
||||
|
||||
Make sync operations private to the authenticated user and vault, and make the
|
||||
server, not the request body, the authority for the originating device.
|
||||
|
||||
## Scope
|
||||
|
||||
- Bind new desktop pairings to the current vault ID.
|
||||
- Persist `user_id` and `vault_id` with every sync operation.
|
||||
- Scope push conflict detection, pull cursors, tombstones, and idempotency
|
||||
responses to that pair of identifiers.
|
||||
- Ignore the legacy `device_id` field in a push body for authorization and
|
||||
storage; retain it in the wire format for backward-compatible decoding.
|
||||
- Upgrade existing SQLite databases without deleting data.
|
||||
|
||||
Blob ownership, API-key retirement, reset-token handling, HTML escaping, and
|
||||
upload limits are separate security slices and are deliberately not included
|
||||
in this change.
|
||||
|
||||
## Data model
|
||||
|
||||
`server_devices` gains a nullable `vault_id`. A device created through either
|
||||
enrollment endpoint must have a non-empty vault ID that does not use the
|
||||
reserved `legacy:` prefix. The authenticated device therefore identifies one
|
||||
user and one vault.
|
||||
|
||||
`server_ops` gains `user_id` and `vault_id`. New writes always set both from
|
||||
the authenticated device. Pull and conflict queries filter both fields.
|
||||
|
||||
`server_tombstones` is rebuilt with a composite key of
|
||||
`(user_id, vault_id, entity_type, entity_id)`. `server_idempotency_keys` is
|
||||
rebuilt with a composite key of `(user_id, vault_id, idempotency_key)`.
|
||||
|
||||
Existing devices without `vault_id` use the explicit effective scope
|
||||
`legacy:<user_id>`. Existing operations inherit their device owner and that
|
||||
legacy scope during startup migration. This preserves existing single-vault
|
||||
accounts while preventing data from crossing account boundaries. New pairings
|
||||
never use the legacy scope.
|
||||
|
||||
Desktop sync state, operation queues, cursors, and persisted device IDs are
|
||||
vault-local. The desktop recreates its sync service whenever the active vault
|
||||
is created, opened, or switched. When it opens a legacy vault state without a
|
||||
stored device ID, it obtains the authenticated ID from `/api/client/me` before
|
||||
syncing and persists it locally.
|
||||
|
||||
## API contract
|
||||
|
||||
`POST /api/client/pair` accepts a required `vault_id`. The desktop gets it
|
||||
from `.verstak/vault.json` and sends it while pairing.
|
||||
|
||||
`POST /api/v1/sync/push` keeps accepting `device_id` for old clients, but the
|
||||
server ignores it. The stored operation device ID is always the authenticated
|
||||
device. A request whose token is not associated with a user and effective
|
||||
vault returns a client error.
|
||||
|
||||
`POST /api/v1/sync/pull` returns only operations from the authenticated
|
||||
user/vault scope. `server_sequence` is the highest sequence in that scope;
|
||||
global sequence gaps are not exposed as the caller's cursor.
|
||||
|
||||
## Migration and failure handling
|
||||
|
||||
Startup migration is idempotent. It checks SQLite table columns before adding
|
||||
new operation/device fields, backfills `user_id` from each operation's device,
|
||||
and assigns the explicit legacy scope when an old device has no vault ID.
|
||||
Tables whose primary key must change are rebuilt transactionally.
|
||||
|
||||
The prior global idempotency cache is intentionally discarded during migration:
|
||||
it is only a replay cache and retaining it could replay one tenant's response
|
||||
for another tenant.
|
||||
|
||||
If an operation cannot be associated with a user, it remains unscoped and is
|
||||
not readable through sync APIs. The server must not guess an owner from a
|
||||
request body.
|
||||
|
||||
## Verification
|
||||
|
||||
Focused server tests must prove:
|
||||
|
||||
1. two users cannot pull each other's operations;
|
||||
2. two vaults of one user cannot pull each other's operations;
|
||||
3. a caller cannot forge another device through the push body;
|
||||
4. scoped idempotency does not replay another tenant's response;
|
||||
5. a legacy SQLite database is upgraded with its existing operation retained
|
||||
in the matching legacy scope.
|
||||
6. switching the active desktop vault rebinds the sync queue, cursor, and
|
||||
device identity to the new vault.
|
||||
|
||||
Desktop tests must prove that pairing sends the opened vault's persistent ID.
|
||||
@@ -0,0 +1,9 @@
|
||||
module github.com/verstak/verstak-sync-server
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/mattn/go-sqlite3 v1.14.46 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
github.com/mattn/go-sqlite3 v1.14.46 h1:ZfaNcYO/CGNMRxkN1vvG9qf+Y+uvXfgT9a6MlEw+HmU=
|
||||
github.com/mattn/go-sqlite3 v1.14.46/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,84 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type AdminUser struct {
|
||||
Username string `yaml:"username"`
|
||||
PasswordHash string `yaml:"password_hash"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Port int `yaml:"port"`
|
||||
Admin []AdminUser `yaml:"admin"`
|
||||
mu sync.Mutex
|
||||
path string
|
||||
}
|
||||
|
||||
func LoadConfig(dataDir string) (*Config, error) {
|
||||
path := filepath.Join(dataDir, "config.yml")
|
||||
cfg := &Config{
|
||||
Port: 47732,
|
||||
Admin: nil,
|
||||
path: path,
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) Save() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.saveLocked()
|
||||
}
|
||||
|
||||
func (c *Config) SetAdmin(username, password string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user := AdminUser{Username: username, PasswordHash: string(hash)}
|
||||
for i, u := range c.Admin {
|
||||
if u.Username == username {
|
||||
c.Admin[i] = user
|
||||
return c.saveLocked()
|
||||
}
|
||||
}
|
||||
c.Admin = append(c.Admin, user)
|
||||
return c.saveLocked()
|
||||
}
|
||||
|
||||
func (c *Config) CheckAdmin(username, password string) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, u := range c.Admin {
|
||||
if u.Username == username {
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Config) saveLocked() error {
|
||||
data, err := yaml.Marshal(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(c.path, data, 0640)
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Admin Login</title>
|
||||
<style>body{font-family:sans-serif;background:#1a1a2e;color:#e0e0f0;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
form{background:#16213e;padding:2rem;border-radius:8px;border:1px solid #0f3460;width:300px}
|
||||
h2{margin:0 0 1rem;color:#e0e0f0}label{display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem}
|
||||
input{width:100%;background:#0f3460;border:1px solid #1a3a5c;color:#e0e0f0;padding:8px 10px;border-radius:4px;font-size:0.85rem;box-sizing:border-box;margin-bottom:0.75rem}
|
||||
button{background:#4ecca3;color:#1a1a2e;border:none;padding:0.5rem 1rem;border-radius:4px;cursor:pointer;font-weight:600;width:100%}</style></head>
|
||||
<body><form method="POST"><h2>Admin Login</h2>
|
||||
<label>Username</label><input name="username" required>
|
||||
<label>Password</label><input type="password" name="password" required>
|
||||
<button type="submit">Login</button></form></body></html>`))
|
||||
case "POST":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", 400)
|
||||
return
|
||||
}
|
||||
user := r.FormValue("username")
|
||||
pass := r.FormValue("password")
|
||||
if !s.cfg.CheckAdmin(user, pass) {
|
||||
http.Error(w, "401 Unauthorized", 401)
|
||||
return
|
||||
}
|
||||
tok := s.tokens.Create()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "admin_session", Value: tok, Path: "/admin",
|
||||
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 86400,
|
||||
})
|
||||
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
|
||||
default:
|
||||
http.Error(w, "method not allowed", 405)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdminCookie(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
var userCount, deviceCount, opsCount int
|
||||
s.db.QueryRow("SELECT COUNT(*) FROM server_users").Scan(&userCount)
|
||||
s.db.QueryRow("SELECT COUNT(*) FROM server_devices").Scan(&deviceCount)
|
||||
s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&opsCount)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Admin Dashboard</title>
|
||||
<style>body{font-family:sans-serif;background:#1a1a2e;color:#e0e0f0;margin:0;padding:2rem}
|
||||
h1{color:#4ecca3}table{width:100%;border-collapse:collapse;margin:1rem 0}
|
||||
th{text-align:left;padding:0.5rem;border-bottom:1px solid #0f3460;color:#a0a0b8}
|
||||
td{padding:0.5rem;border-bottom:1px solid #0f3460}.stat{display:inline-block;background:#16213e;padding:1rem 1.5rem;border-radius:8px;margin:0.5rem;border:1px solid #0f3460}
|
||||
.stat-num{font-size:1.5rem;color:#4ecca3;font-weight:600}.stat-label{color:#a0a0b8;font-size:0.85rem}
|
||||
a{color:#4ecca3}</style></head><body>
|
||||
<h1>Verstak Sync Server — Admin</h1>
|
||||
<div class="stat"><div class="stat-num">` + intToStr(userCount) + `</div><div class="stat-label">Users</div></div>
|
||||
<div class="stat"><div class="stat-num">` + intToStr(deviceCount) + `</div><div class="stat-label">Devices</div></div>
|
||||
<div class="stat"><div class="stat-num">` + intToStr(opsCount) + `</div><div class="stat-label">Sync Ops</div></div>
|
||||
<h2><a href="/admin/users">Users</a> | <a href="/admin/devices">Devices</a> | <a href="/api/v1/health">Health</a></h2>
|
||||
</body></html>`))
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdminCookie(w, r) {
|
||||
return
|
||||
}
|
||||
rows, err := s.db.Query("SELECT id, username, email, confirmed, blocked, created_at FROM server_users ORDER BY created_at DESC")
|
||||
if err != nil {
|
||||
log.Printf("admin users: query failed: %v", err)
|
||||
http.Error(w, t(s.locale(), "server.internalError"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id, username, email, createdAt string
|
||||
var confirmed, blocked int
|
||||
rows.Scan(&id, &username, &email, &confirmed, &blocked, &createdAt)
|
||||
users = append(users, map[string]interface{}{
|
||||
"id": id, "username": username, "email": email,
|
||||
"confirmed": confirmed, "blocked": blocked, "created_at": createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Users</title>
|
||||
<style>body{font-family:sans-serif;background:#1a1a2e;color:#e0e0f0;margin:0;padding:2rem}
|
||||
h1{color:#4ecca3}table{width:100%;border-collapse:collapse}th{text-align:left;padding:0.5rem;border-bottom:1px solid #0f3460;color:#a0a0b8}
|
||||
td{padding:0.5rem;border-bottom:1px solid #0f3460}a{color:#4ecca3}</style></head><body>
|
||||
<h1>Users <a href="/admin/dashboard">← Dashboard</a></h1>
|
||||
<table><tr><th>Username</th><th>Email</th><th>Confirmed</th><th>Blocked</th><th>Created</th></tr>`))
|
||||
|
||||
for _, u := range users {
|
||||
confirmed := "✅"
|
||||
if u["confirmed"].(int) == 0 {
|
||||
confirmed = "❌"
|
||||
}
|
||||
blocked := ""
|
||||
if u["blocked"].(int) != 0 {
|
||||
blocked = "🚫"
|
||||
}
|
||||
w.Write([]byte(`<tr><td>` + html.EscapeString(u["username"].(string)) + `</td><td>` + html.EscapeString(u["email"].(string)) +
|
||||
`</td><td>` + confirmed + `</td><td>` + blocked + `</td><td>` + html.EscapeString(u["created_at"].(string)) + `</td></tr>`))
|
||||
}
|
||||
w.Write([]byte(`</table></body></html>`))
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminDevices(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdminCookie(w, r) {
|
||||
return
|
||||
}
|
||||
rows, err := s.db.Query(`SELECT d.id, d.name, d.client_version, COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at
|
||||
FROM server_devices d ORDER BY d.created_at DESC`)
|
||||
if err != nil {
|
||||
log.Printf("admin devices: query failed: %v", err)
|
||||
http.Error(w, t(s.locale(), "server.internalError"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Devices</title>
|
||||
<style>body{font-family:sans-serif;background:#1a1a2e;color:#e0e0f0;margin:0;padding:2rem}
|
||||
h1{color:#4ecca3}table{width:100%;border-collapse:collapse}th{text-align:left;padding:0.5rem;border-bottom:1px solid #0f3460;color:#a0a0b8}
|
||||
td{padding:0.5rem;border-bottom:1px solid #0f3460}a{color:#4ecca3}</style></head><body>
|
||||
<h1>Devices <a href="/admin/dashboard">← Dashboard</a></h1>
|
||||
<table><tr><th>Name</th><th>ID</th><th>Version</th><th>Last Seen</th><th>Revoked</th><th>Created</th></tr>`))
|
||||
|
||||
for rows.Next() {
|
||||
var id, name, clientVer, lastSeen, revokedAt, createdAt string
|
||||
rows.Scan(&id, &name, &clientVer, &lastSeen, &revokedAt, &createdAt)
|
||||
if lastSeen == "" {
|
||||
lastSeen = "never"
|
||||
}
|
||||
if revokedAt == "" {
|
||||
revokedAt = "-"
|
||||
}
|
||||
w.Write([]byte(`<tr><td>` + html.EscapeString(name) + `</td><td style="font-family:monospace;font-size:0.8em">` + html.EscapeString(id) +
|
||||
`</td><td>` + html.EscapeString(clientVer) + `</td><td>` + html.EscapeString(lastSeen) + `</td><td>` + html.EscapeString(revokedAt) + `</td><td>` + html.EscapeString(createdAt) + `</td></tr>`))
|
||||
}
|
||||
w.Write([]byte(`</table></body></html>`))
|
||||
}
|
||||
|
||||
func (s *Server) requireAdminCookie(w http.ResponseWriter, r *http.Request) bool {
|
||||
cookie, err := r.Cookie("admin_session")
|
||||
if err != nil || cookie.Value == "" {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
||||
return false
|
||||
}
|
||||
if !s.tokens.Check(cookie.Value) {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func intToStr(n int) string {
|
||||
b, _ := json.Marshal(n)
|
||||
return strings.Trim(string(b), "\"")
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdminCookie(w, r) {
|
||||
return
|
||||
}
|
||||
var opsCount int
|
||||
s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&opsCount)
|
||||
jsonOK(w, map[string]int{"ops": opsCount})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminSMTPTest(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdminCookie(w, r) {
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Host string `json:"smtp_host"`
|
||||
Port string `json:"smtp_port"`
|
||||
User string `json:"smtp_user"`
|
||||
Pass string `json:"smtp_pass"`
|
||||
Security string `json:"smtp_security"`
|
||||
From string `json:"smtp_from"`
|
||||
To string `json:"test_to"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "bad json")
|
||||
return
|
||||
}
|
||||
host := req.Host
|
||||
port := req.Port
|
||||
user := req.User
|
||||
pass := req.Pass
|
||||
security := req.Security
|
||||
from := req.From
|
||||
to := req.To
|
||||
if to == "" {
|
||||
to = from
|
||||
}
|
||||
if host == "" || port == "" || from == "" {
|
||||
jsonOK(w, map[string]interface{}{"ok": false, "error": t(s.locale(), "admin.smtpRequired")})
|
||||
return
|
||||
}
|
||||
if err := s.smtpTest(host, port, user, pass, security, from, to); err != nil {
|
||||
log.Printf("admin SMTP test failed: %v", err)
|
||||
jsonOK(w, map[string]interface{}{"ok": false, "error": t(s.locale(), "admin.smtpTestFailed")})
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminAPIDevices(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdminCookie(w, r) {
|
||||
return
|
||||
}
|
||||
rows, err := s.db.Query(`
|
||||
SELECT d.id, d.name, d.client_version, COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at,
|
||||
COALESCE(u.username,'')
|
||||
FROM server_devices d
|
||||
LEFT JOIN server_users u ON u.id = d.user_id
|
||||
ORDER BY d.created_at DESC`)
|
||||
if err != nil {
|
||||
jsonInternalError(w, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
type devDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ClientVersion string `json:"client_version"`
|
||||
LastSeen string `json:"last_seen"`
|
||||
RevokedAt string `json:"revoked_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
User string `json:"user"`
|
||||
}
|
||||
var out []devDTO
|
||||
for rows.Next() {
|
||||
var d devDTO
|
||||
rows.Scan(&d.ID, &d.Name, &d.ClientVersion, &d.LastSeen, &d.RevokedAt, &d.CreatedAt, &d.User)
|
||||
out = append(out, d)
|
||||
}
|
||||
jsonOK(w, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdminCookie(w, r) {
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
rows, err := s.db.Query("SELECT id, name, api_key FROM server_devices ORDER BY created_at")
|
||||
if err != nil {
|
||||
jsonInternalError(w, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []map[string]string
|
||||
for rows.Next() {
|
||||
var id, name, key string
|
||||
rows.Scan(&id, &name, &key)
|
||||
out = append(out, map[string]string{"id": id, "name": name, "api_key": key})
|
||||
}
|
||||
jsonOK(w, out)
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminAPISmtp(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdminCookie(w, r) {
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
jsonErr(w, 400, "bad form")
|
||||
return
|
||||
}
|
||||
for _, key := range []string{"smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_security", "smtp_from", "server_url"} {
|
||||
val := r.FormValue(key)
|
||||
if val != "" {
|
||||
s.smtpSet(key, val)
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminAPIKeysDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdminCookie(w, r) {
|
||||
return
|
||||
}
|
||||
if r.Method != "DELETE" {
|
||||
jsonErr(w, 405, "DELETE required")
|
||||
return
|
||||
}
|
||||
id := strings.TrimPrefix(r.URL.Path, "/admin/api/keys/")
|
||||
_, err := s.db.Exec("DELETE FROM server_devices WHERE id=?", id)
|
||||
if err != nil {
|
||||
jsonInternalError(w, err)
|
||||
return
|
||||
}
|
||||
s.db.Exec("DELETE FROM server_user_devices WHERE device_id=?", id)
|
||||
jsonOK(w, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminAPIUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdminCookie(w, r) {
|
||||
return
|
||||
}
|
||||
filter := r.URL.Query().Get("filter")
|
||||
sort := r.URL.Query().Get("sort")
|
||||
order := r.URL.Query().Get("order")
|
||||
page := 1
|
||||
perPage := 20
|
||||
if v := r.URL.Query().Get("page"); v != "" {
|
||||
fmt.Sscanf(v, "%d", &page)
|
||||
}
|
||||
if v := r.URL.Query().Get("per_page"); v != "" {
|
||||
fmt.Sscanf(v, "%d", &perPage)
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 || perPage > 100 {
|
||||
perPage = 20
|
||||
}
|
||||
where := ""
|
||||
var args []interface{}
|
||||
if filter != "" {
|
||||
where = " WHERE u.username LIKE ?"
|
||||
args = append(args, "%"+filter+"%")
|
||||
}
|
||||
validSorts := map[string]string{
|
||||
"username": "u.username",
|
||||
"email": "u.email",
|
||||
"confirmed": "u.confirmed",
|
||||
"blocked": "u.blocked",
|
||||
"created_at": "u.created_at",
|
||||
"last_seen": "u.last_seen",
|
||||
"devices": "devices",
|
||||
}
|
||||
orderClause := "u.created_at DESC"
|
||||
if col, ok := validSorts[sort]; ok {
|
||||
if order != "asc" {
|
||||
order = "desc"
|
||||
}
|
||||
orderClause = col + " " + order
|
||||
}
|
||||
var total int
|
||||
countSQL := "SELECT COUNT(*) FROM server_users u" + where
|
||||
s.db.QueryRow(countSQL, args...).Scan(&total)
|
||||
offset := (page - 1) * perPage
|
||||
sql := `SELECT u.id, u.username, u.email, u.confirmed, u.blocked, u.last_seen, u.created_at,
|
||||
COALESCE((SELECT COUNT(*) FROM server_user_devices ud JOIN server_devices d ON d.id=ud.device_id WHERE ud.user_id=u.id),0) AS devices
|
||||
FROM server_users u` + where + ` ORDER BY ` + orderClause + ` LIMIT ? OFFSET ?`
|
||||
args = append(args, perPage, offset)
|
||||
rows, err := s.db.Query(sql, args...)
|
||||
if err != nil {
|
||||
jsonInternalError(w, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
type userRow struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Confirmed int `json:"confirmed"`
|
||||
Blocked int `json:"blocked"`
|
||||
LastSeen string `json:"last_seen"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Devices int `json:"devices"`
|
||||
}
|
||||
var users []userRow
|
||||
for rows.Next() {
|
||||
var u userRow
|
||||
var lastSeen *string
|
||||
rows.Scan(&u.ID, &u.Username, &u.Email, &u.Confirmed, &u.Blocked, &lastSeen, &u.CreatedAt, &u.Devices)
|
||||
if lastSeen != nil {
|
||||
u.LastSeen = *lastSeen
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
jsonOK(w, map[string]interface{}{
|
||||
"users": users,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminAPIUserActions(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdminCookie(w, r) {
|
||||
return
|
||||
}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/admin/api/users/")
|
||||
|
||||
if strings.HasSuffix(path, "/block") && r.Method == "POST" {
|
||||
id := strings.TrimSuffix(path, "/block")
|
||||
id = strings.TrimSuffix(id, "/")
|
||||
var blocked int
|
||||
s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", id).Scan(&blocked)
|
||||
newVal := 1
|
||||
if blocked != 0 {
|
||||
newVal = 0
|
||||
}
|
||||
s.db.Exec("UPDATE server_users SET blocked=? WHERE id=?", newVal, id)
|
||||
jsonOK(w, map[string]interface{}{"status": "ok", "blocked": newVal})
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(path, "/reset-password") && r.Method == "POST" {
|
||||
id := strings.TrimSuffix(path, "/reset-password")
|
||||
id = strings.TrimSuffix(id, "/")
|
||||
b := make([]byte, 12)
|
||||
rand.Read(b)
|
||||
newPass := hex.EncodeToString(b)
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost)
|
||||
_, err := s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), id)
|
||||
if err != nil {
|
||||
jsonInternalError(w, err)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]interface{}{"status": "ok", "new_password": newPass})
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(path, "/edit") && r.Method == "POST" {
|
||||
id := strings.TrimSuffix(path, "/edit")
|
||||
id = strings.TrimSuffix(id, "/")
|
||||
var editReq struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&editReq); err != nil {
|
||||
jsonErr(w, 400, "bad json")
|
||||
return
|
||||
}
|
||||
if editReq.Username == "" || editReq.Email == "" {
|
||||
jsonErr(w, 400, "username and email required")
|
||||
return
|
||||
}
|
||||
_, err := s.db.Exec("UPDATE server_users SET username=?, email=? WHERE id=?", editReq.Username, strings.ToLower(editReq.Email), id)
|
||||
if err != nil {
|
||||
jsonInternalError(w, err)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]interface{}{"status": "ok"})
|
||||
return
|
||||
}
|
||||
if r.Method == "DELETE" {
|
||||
id := strings.TrimSuffix(path, "/")
|
||||
rows, _ := s.db.Query("SELECT device_id FROM server_user_devices WHERE user_id=?", id)
|
||||
var deviceIDs []string
|
||||
for rows.Next() {
|
||||
var did string
|
||||
rows.Scan(&did)
|
||||
deviceIDs = append(deviceIDs, did)
|
||||
}
|
||||
rows.Close()
|
||||
for _, did := range deviceIDs {
|
||||
s.db.Exec("DELETE FROM server_devices WHERE id=?", did)
|
||||
}
|
||||
s.db.Exec("DELETE FROM server_user_devices WHERE user_id=?", id)
|
||||
s.db.Exec("DELETE FROM server_email_tokens WHERE user_id=?", id)
|
||||
s.db.Exec("DELETE FROM server_users WHERE id=?", id)
|
||||
jsonOK(w, map[string]interface{}{"status": "deleted"})
|
||||
return
|
||||
}
|
||||
jsonErr(w, 404, "unknown action")
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdminCookie(w, r) {
|
||||
return
|
||||
}
|
||||
locale := s.locale()
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(adminCreateUserHTML(locale)))
|
||||
case "POST":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", 400)
|
||||
return
|
||||
}
|
||||
username := r.FormValue("username")
|
||||
email := r.FormValue("email")
|
||||
password := r.FormValue("password")
|
||||
if username == "" || email == "" || password == "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(400)
|
||||
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.allFieldsRequired"), "/admin/create-user")))
|
||||
return
|
||||
}
|
||||
if err := validatePassword(password); err != "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(400)
|
||||
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), string(err), "/admin/create-user")))
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(500)
|
||||
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), "internal error", "/admin/create-user")))
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
id := make([]byte, 12)
|
||||
rand.Read(id)
|
||||
userID := hex.EncodeToString(id)
|
||||
_, err = s.db.Exec(
|
||||
"INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 1, ?)",
|
||||
userID, username, strings.ToLower(email), string(hash), now,
|
||||
)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
w.WriteHeader(409)
|
||||
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), "Username or email already taken", "/admin/create-user")))
|
||||
} else {
|
||||
log.Printf("admin create user: failed: %v", err)
|
||||
w.WriteHeader(500)
|
||||
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "admin.createUserFailed"), "/admin/create-user")))
|
||||
}
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/users", http.StatusFound)
|
||||
default:
|
||||
http.Error(w, "method not allowed", 405)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminAPICreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdminCookie(w, r) {
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "bad json")
|
||||
return
|
||||
}
|
||||
if req.Username == "" || req.Email == "" || req.Password == "" {
|
||||
jsonErr(w, 400, "username, email and password required")
|
||||
return
|
||||
}
|
||||
if err := validatePassword(req.Password); err != "" {
|
||||
jsonErr(w, 400, string(err))
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, "internal error")
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
id := make([]byte, 12)
|
||||
rand.Read(id)
|
||||
userID := hex.EncodeToString(id)
|
||||
_, err = s.db.Exec(
|
||||
"INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 1, ?)",
|
||||
userID, req.Username, strings.ToLower(req.Email), string(hash), now,
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
jsonErr(w, 409, "username or email already taken")
|
||||
} else {
|
||||
jsonInternalError(w, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"status": "ok", "user_id": userID})
|
||||
}
|
||||
|
||||
var _ = time.Now
|
||||
var _ = rand.Read
|
||||
var _ = hex.EncodeToString
|
||||
@@ -0,0 +1,573 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Write([]byte("Verstak Sync Server\n"))
|
||||
return
|
||||
}
|
||||
jsonErr(w, 404, "not found")
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOK(w, map[string]interface{}{
|
||||
"status": "ok",
|
||||
"version": "verstak-server/v1",
|
||||
"time": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
ip := r.RemoteAddr
|
||||
if idx := strings.LastIndex(ip, ":"); idx >= 0 {
|
||||
ip = ip[:idx]
|
||||
}
|
||||
if !s.pairLimit.allow(ip) {
|
||||
s.auditLog("rate_limit_exceeded", "", "", ip, "pair rate limit exceeded")
|
||||
jsonErr(w, 429, "too many attempts")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Login string `json:"login"`
|
||||
Password string `json:"password"`
|
||||
DeviceName string `json:"device_name"`
|
||||
ClientVersion string `json:"client_version"`
|
||||
VaultID string `json:"vault_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "bad json")
|
||||
return
|
||||
}
|
||||
if req.Login == "" || req.Password == "" {
|
||||
jsonErr(w, 400, "login and password required")
|
||||
return
|
||||
}
|
||||
req.VaultID = strings.TrimSpace(req.VaultID)
|
||||
if req.VaultID == "" {
|
||||
jsonErr(w, 400, "vault_id required")
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(req.VaultID, "legacy:") {
|
||||
jsonErr(w, 400, "vault_id uses reserved prefix")
|
||||
return
|
||||
}
|
||||
if req.DeviceName == "" {
|
||||
req.DeviceName = "unknown"
|
||||
}
|
||||
var userID, hash string
|
||||
var confirmed, blocked int
|
||||
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
||||
req.Login, strings.ToLower(req.Login)).Scan(&userID, &hash, &confirmed, &blocked)
|
||||
if err != nil {
|
||||
s.auditLog("device_auth_failed", "", "", ip, "pair: user not found: "+req.Login)
|
||||
jsonErr(w, 401, "invalid credentials")
|
||||
return
|
||||
}
|
||||
if blocked != 0 {
|
||||
s.auditLog("device_auth_failed", userID, "", ip, "pair: user blocked")
|
||||
jsonErr(w, 403, "account blocked")
|
||||
return
|
||||
}
|
||||
if confirmed == 0 {
|
||||
s.auditLog("device_auth_failed", userID, "", ip, "pair: email not confirmed")
|
||||
jsonErr(w, 403, "email not confirmed")
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
|
||||
s.auditLog("device_auth_failed", userID, "", ip, "pair: wrong password")
|
||||
jsonErr(w, 401, "invalid credentials")
|
||||
return
|
||||
}
|
||||
devID := make([]byte, 12)
|
||||
rand.Read(devID)
|
||||
deviceID := "dev_" + hex.EncodeToString(devID)
|
||||
token, prefix, suffix := genDeviceToken()
|
||||
tokenHash := sha256Hex(token)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
apiKey := make([]byte, 20)
|
||||
rand.Read(apiKey)
|
||||
_, err = s.db.Exec(`INSERT INTO server_devices
|
||||
(id, name, api_key, token_hash, token_prefix, token_suffix, user_id, vault_id, client_version, last_ip, last_seen, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
deviceID, req.DeviceName, hex.EncodeToString(apiKey), tokenHash, prefix, suffix,
|
||||
userID, req.VaultID, req.ClientVersion, ip, now, now)
|
||||
if err != nil {
|
||||
jsonInternalError(w, err)
|
||||
return
|
||||
}
|
||||
s.db.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID)
|
||||
s.db.Exec("UPDATE server_users SET last_seen=? WHERE id=?", now, userID)
|
||||
s.pairLimit.reset(ip)
|
||||
s.auditLog("device_paired", userID, deviceID, ip, "device paired: "+req.DeviceName)
|
||||
jsonOK(w, map[string]interface{}{
|
||||
"user_id": userID,
|
||||
"device_id": deviceID,
|
||||
"device_token": token,
|
||||
"server_time": now,
|
||||
"initial_sync_cursor": 0,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthTest(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "bad json")
|
||||
return
|
||||
}
|
||||
if req.Username == "" || req.Password == "" {
|
||||
jsonErr(w, 400, "username and password required")
|
||||
return
|
||||
}
|
||||
var hash string
|
||||
var confirmed, blocked int
|
||||
err := s.db.QueryRow("SELECT password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
||||
req.Username, strings.ToLower(req.Username)).Scan(&hash, &confirmed, &blocked)
|
||||
if err != nil {
|
||||
jsonErr(w, 401, "invalid credentials")
|
||||
return
|
||||
}
|
||||
if blocked != 0 {
|
||||
jsonErr(w, 403, "account blocked")
|
||||
return
|
||||
}
|
||||
if confirmed == 0 {
|
||||
jsonErr(w, 403, "email not confirmed")
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
|
||||
jsonErr(w, 401, "invalid credentials")
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleClientRevoke(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if tok == "" {
|
||||
jsonErr(w, 401, "token required")
|
||||
return
|
||||
}
|
||||
hash := sha256Hex(tok)
|
||||
var deviceID, userID string
|
||||
err := s.db.QueryRow("SELECT id, user_id FROM server_devices WHERE token_hash=?", hash).Scan(&deviceID, &userID)
|
||||
if err != nil {
|
||||
jsonErr(w, 401, "invalid token")
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
s.db.Exec("UPDATE server_devices SET revoked_at=? WHERE id=?", now, deviceID)
|
||||
s.auditLog("device_revoked", userID, deviceID, r.RemoteAddr, "device revoked by user")
|
||||
jsonOK(w, map[string]string{"status": "revoked"})
|
||||
}
|
||||
|
||||
func (s *Server) handleClientRevokeDevice(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if tok == "" {
|
||||
jsonErr(w, 401, "token required")
|
||||
return
|
||||
}
|
||||
hash := sha256Hex(tok)
|
||||
var curUserID string
|
||||
err := s.db.QueryRow("SELECT user_id FROM server_devices WHERE token_hash=?", hash).Scan(&curUserID)
|
||||
if err != nil || curUserID == "" {
|
||||
jsonErr(w, 401, "invalid token")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.DeviceID == "" || req.Password == "" {
|
||||
jsonErr(w, 400, "device_id and password required")
|
||||
return
|
||||
}
|
||||
var pwHash string
|
||||
err = s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", curUserID).Scan(&pwHash)
|
||||
if err != nil {
|
||||
jsonErr(w, 403, "access denied")
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(pwHash), []byte(req.Password)) != nil {
|
||||
jsonErr(w, 403, "wrong password")
|
||||
return
|
||||
}
|
||||
var devUserID string
|
||||
err = s.db.QueryRow("SELECT user_id FROM server_devices WHERE id=?", req.DeviceID).Scan(&devUserID)
|
||||
if err != nil {
|
||||
jsonErr(w, 404, "device not found")
|
||||
return
|
||||
}
|
||||
if devUserID != curUserID {
|
||||
jsonErr(w, 403, "device does not belong to you")
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
s.db.Exec("UPDATE server_devices SET revoked_at=? WHERE id=?", now, req.DeviceID)
|
||||
s.auditLog("device_revoked", curUserID, req.DeviceID, r.RemoteAddr, "device revoked via API")
|
||||
jsonOK(w, map[string]string{"status": "revoked"})
|
||||
}
|
||||
|
||||
func (s *Server) handleClientMe(w http.ResponseWriter, r *http.Request) {
|
||||
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if tok == "" {
|
||||
jsonErr(w, 401, "token required")
|
||||
return
|
||||
}
|
||||
hash := sha256Hex(tok)
|
||||
var deviceID, userID, name, clientVer, lastSeen, revokedAt, createdAt string
|
||||
err := s.db.QueryRow(`SELECT d.id, d.user_id, d.name, COALESCE(d.client_version,''), COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at
|
||||
FROM server_devices d WHERE d.token_hash=?`, hash).
|
||||
Scan(&deviceID, &userID, &name, &clientVer, &lastSeen, &revokedAt, &createdAt)
|
||||
if err != nil {
|
||||
jsonErr(w, 401, "invalid token")
|
||||
return
|
||||
}
|
||||
var username string
|
||||
s.db.QueryRow("SELECT username FROM server_users WHERE id=?", userID).Scan(&username)
|
||||
jsonOK(w, map[string]interface{}{
|
||||
"device_id": deviceID,
|
||||
"user_id": userID,
|
||||
"username": username,
|
||||
"device_name": name,
|
||||
"client_version": clientVer,
|
||||
"last_seen": lastSeen,
|
||||
"revoked_at": revokedAt,
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
VaultID string `json:"vault_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
jsonErr(w, 400, "name required")
|
||||
return
|
||||
}
|
||||
if req.Username == "" || req.Password == "" {
|
||||
jsonErr(w, 401, "username and password required")
|
||||
return
|
||||
}
|
||||
req.VaultID = strings.TrimSpace(req.VaultID)
|
||||
if req.VaultID == "" {
|
||||
jsonErr(w, 400, "vault_id required")
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(req.VaultID, "legacy:") {
|
||||
jsonErr(w, 400, "vault_id uses reserved prefix")
|
||||
return
|
||||
}
|
||||
var userID, hash string
|
||||
var confirmed, blocked int
|
||||
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
||||
req.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed, &blocked)
|
||||
if err != nil {
|
||||
jsonErr(w, 401, "invalid credentials")
|
||||
return
|
||||
}
|
||||
if blocked != 0 {
|
||||
jsonErr(w, 403, "account blocked")
|
||||
return
|
||||
}
|
||||
if confirmed == 0 {
|
||||
jsonErr(w, 403, "email not confirmed")
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
|
||||
jsonErr(w, 401, "invalid credentials")
|
||||
return
|
||||
}
|
||||
b := make([]byte, 20)
|
||||
rand.Read(b)
|
||||
apiKey := hex.EncodeToString(b)
|
||||
deviceID := apiKey[:12]
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err = s.db.Exec(
|
||||
"INSERT INTO server_devices (id, name, api_key, user_id, vault_id, last_seen, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
deviceID, req.Name, apiKey, userID, req.VaultID, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
jsonInternalError(w, err)
|
||||
return
|
||||
}
|
||||
s.db.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID)
|
||||
jsonOK(w, map[string]interface{}{
|
||||
"device_id": deviceID,
|
||||
"api_key": apiKey,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
|
||||
scope, ok := s.requireSyncScope(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
Ops []struct {
|
||||
OpID string `json:"op_id"`
|
||||
EntityType string `json:"entity_type"`
|
||||
EntityID string `json:"entity_id"`
|
||||
OpType string `json:"op_type"`
|
||||
PayloadJSON string `json:"payload_json"`
|
||||
ClientSequence int `json:"client_sequence"`
|
||||
LastSeenServerSeq int `json:"last_seen_server_seq"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
} `json:"ops"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.IdempotencyKey != "" {
|
||||
var cachedJSON string
|
||||
err := s.db.QueryRow(`SELECT response_json FROM server_idempotency_keys
|
||||
WHERE user_id=? AND vault_id=? AND idempotency_key=?`,
|
||||
scope.UserID, scope.VaultID, req.IdempotencyKey).Scan(&cachedJSON)
|
||||
if err == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(cachedJSON))
|
||||
return
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
var accepted []string
|
||||
var conflicts []map[string]interface{}
|
||||
for _, op := range req.Ops {
|
||||
if op.OpID == "" || op.EntityType == "" || op.EntityID == "" || op.OpType == "" {
|
||||
continue
|
||||
}
|
||||
if op.LastSeenServerSeq > 0 {
|
||||
conflictRows, err := s.db.Query(`
|
||||
SELECT op_id, device_id, op_type, server_sequence FROM server_ops
|
||||
WHERE user_id=? AND vault_id=? AND entity_type=? AND entity_id=? AND device_id!=?
|
||||
AND server_sequence > ? AND op_type != 'delete'
|
||||
ORDER BY server_sequence`, scope.UserID, scope.VaultID, op.EntityType, op.EntityID, scope.DeviceID, op.LastSeenServerSeq)
|
||||
if err == nil {
|
||||
for conflictRows.Next() {
|
||||
var cOpID, cDevID, cOpType string
|
||||
var cSeq int
|
||||
conflictRows.Scan(&cOpID, &cDevID, &cOpType, &cSeq)
|
||||
conflicts = append(conflicts, map[string]interface{}{
|
||||
"op_id": cOpID,
|
||||
"device_id": cDevID,
|
||||
"op_type": cOpType,
|
||||
"server_sequence": cSeq,
|
||||
"entity_type": op.EntityType,
|
||||
"entity_id": op.EntityID,
|
||||
})
|
||||
}
|
||||
conflictRows.Close()
|
||||
}
|
||||
}
|
||||
res, err := s.db.Exec(
|
||||
`INSERT OR IGNORE INTO server_ops (op_id, server_sequence, user_id, vault_id, device_id, entity_type, entity_id, op_type, payload_json, idempotency_key, client_sequence, last_seen_server_seq, created_at, pushed_at)
|
||||
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
op.OpID, scope.UserID, scope.VaultID, scope.DeviceID, op.EntityType, op.EntityID, op.OpType, op.PayloadJSON,
|
||||
req.IdempotencyKey, op.ClientSequence, op.LastSeenServerSeq, op.CreatedAt, now,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
seqRes, err := s.db.Exec("INSERT INTO server_revisions (op_id, device_id) VALUES (?, ?)", op.OpID, scope.DeviceID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
seq, _ := seqRes.LastInsertId()
|
||||
s.db.Exec("UPDATE server_ops SET server_sequence=? WHERE op_id=?", seq, op.OpID)
|
||||
if op.OpType == "delete" {
|
||||
s.db.Exec(`INSERT OR REPLACE INTO server_tombstones
|
||||
(user_id, vault_id, entity_type, entity_id, op_id, deleted_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
scope.UserID, scope.VaultID, op.EntityType, op.EntityID, op.OpID, now)
|
||||
}
|
||||
accepted = append(accepted, op.OpID)
|
||||
}
|
||||
resp := map[string]interface{}{
|
||||
"accepted": accepted,
|
||||
"count": len(accepted),
|
||||
"conflicts": conflicts,
|
||||
}
|
||||
if req.IdempotencyKey != "" {
|
||||
if respJSON, err := json.Marshal(resp); err == nil {
|
||||
s.db.Exec(`INSERT OR IGNORE INTO server_idempotency_keys
|
||||
(user_id, vault_id, idempotency_key, response_json, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
scope.UserID, scope.VaultID, req.IdempotencyKey, string(respJSON), now)
|
||||
}
|
||||
}
|
||||
jsonOK(w, resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
|
||||
scope, ok := s.requireSyncScope(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
SinceSequence int `json:"since_sequence"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "invalid JSON")
|
||||
return
|
||||
}
|
||||
var serverSeq int
|
||||
s.db.QueryRow(`SELECT COALESCE(MAX(server_sequence), 0) FROM server_ops
|
||||
WHERE user_id=? AND vault_id=?`, scope.UserID, scope.VaultID).Scan(&serverSeq)
|
||||
rows, err := s.db.Query(`
|
||||
SELECT op_id, server_sequence, device_id, entity_type, entity_id, op_type, payload_json, created_at
|
||||
FROM server_ops
|
||||
WHERE user_id=? AND vault_id=? AND server_sequence > ? AND server_sequence IS NOT NULL
|
||||
ORDER BY server_sequence`, scope.UserID, scope.VaultID, req.SinceSequence)
|
||||
if err != nil {
|
||||
jsonInternalError(w, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
type opDTO struct {
|
||||
OpID string `json:"op_id"`
|
||||
ServerSequence int `json:"server_sequence"`
|
||||
DeviceID string `json:"device_id"`
|
||||
EntityType string `json:"entity_type"`
|
||||
EntityID string `json:"entity_id"`
|
||||
OpType string `json:"op_type"`
|
||||
PayloadJSON string `json:"payload_json"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
ops := []opDTO{}
|
||||
for rows.Next() {
|
||||
var o opDTO
|
||||
if err := rows.Scan(&o.OpID, &o.ServerSequence, &o.DeviceID, &o.EntityType, &o.EntityID, &o.OpType, &o.PayloadJSON, &o.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
ops = append(ops, o)
|
||||
}
|
||||
jsonOK(w, map[string]interface{}{
|
||||
"server_sequence": serverSeq,
|
||||
"ops": ops,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleBlobs(w http.ResponseWriter, r *http.Request) {
|
||||
_, _, ok := s.requireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case "POST":
|
||||
if err := r.ParseMultipartForm(200 << 20); err != nil {
|
||||
jsonErr(w, 400, "invalid multipart request")
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
jsonErr(w, 400, "file field required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, "read error")
|
||||
return
|
||||
}
|
||||
hash := sha256Hex(string(data))
|
||||
blobDir := filepath.Join(s.blobsDir, hash[:2], hash[2:4])
|
||||
if err := os.MkdirAll(blobDir, 0750); err != nil {
|
||||
jsonErr(w, 500, "mkdir error")
|
||||
return
|
||||
}
|
||||
blobPath := filepath.Join(blobDir, hash)
|
||||
if err := os.WriteFile(blobPath, data, 0640); err != nil {
|
||||
jsonErr(w, 500, "write error")
|
||||
return
|
||||
}
|
||||
_ = header
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
s.db.Exec("INSERT OR IGNORE INTO server_blobs (sha256, size, created_at) VALUES (?, ?, ?)",
|
||||
hash, len(data), now)
|
||||
jsonOK(w, map[string]interface{}{
|
||||
"sha256": hash,
|
||||
"size": len(data),
|
||||
})
|
||||
case "GET":
|
||||
shaHex := strings.TrimPrefix(r.URL.Path, "/api/v1/blobs/")
|
||||
if len(shaHex) != 64 {
|
||||
jsonErr(w, 400, "invalid SHA-256")
|
||||
return
|
||||
}
|
||||
blobPath := filepath.Join(s.blobsDir, shaHex[:2], shaHex[2:4], shaHex)
|
||||
if _, err := os.Stat(blobPath); os.IsNotExist(err) {
|
||||
jsonErr(w, 404, "blob not found")
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(blobPath)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, "read error")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+shaHex+"\"")
|
||||
w.Write(data)
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.Username == "" || req.Email == "" || req.Password == "" {
|
||||
jsonErr(w, 400, "username, email and password required")
|
||||
return
|
||||
}
|
||||
if err := validatePassword(req.Password); err != "" {
|
||||
jsonErr(w, 400, err)
|
||||
return
|
||||
}
|
||||
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
|
||||
jsonErr(w, 400, "invalid email")
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, "internal error")
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
id := make([]byte, 12)
|
||||
rand.Read(id)
|
||||
userID := hex.EncodeToString(id)
|
||||
_, err = s.db.Exec(
|
||||
"INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 0, ?)",
|
||||
userID, req.Username, strings.ToLower(req.Email), string(hash), now,
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
jsonErr(w, 409, "username or email already taken")
|
||||
return
|
||||
}
|
||||
jsonInternalError(w, err)
|
||||
return
|
||||
}
|
||||
tok := make([]byte, 24)
|
||||
rand.Read(tok)
|
||||
tokenStr := hex.EncodeToString(tok)
|
||||
exp := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
|
||||
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'confirm', ?, ?)",
|
||||
tokenStr, userID, exp, now)
|
||||
log.Printf("register: confirmation token=%s for user %s", tokenStr, req.Username)
|
||||
jsonOK(w, map[string]string{"status": "confirmation_sent"})
|
||||
}
|
||||
|
||||
func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
jsonErr(w, 405, "GET required")
|
||||
return
|
||||
}
|
||||
tokenStr := r.URL.Query().Get("token")
|
||||
if tokenStr == "" {
|
||||
jsonErr(w, 400, "token required")
|
||||
return
|
||||
}
|
||||
var userID, expiresAt string
|
||||
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='confirm'",
|
||||
tokenStr).Scan(&userID, &expiresAt)
|
||||
if err != nil {
|
||||
jsonErr(w, 400, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
exp, err := time.Parse(time.RFC3339, expiresAt)
|
||||
if err != nil || time.Now().After(exp) {
|
||||
jsonErr(w, 400, "token expired")
|
||||
return
|
||||
}
|
||||
s.db.Exec("UPDATE server_users SET confirmed=1 WHERE id=?", userID)
|
||||
log.Printf("confirm: user %s confirmed email", userID)
|
||||
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", tokenStr)
|
||||
jsonOK(w, map[string]string{"status": "confirmed"})
|
||||
}
|
||||
|
||||
func (s *Server) handleUserLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.Username == "" || req.Password == "" {
|
||||
jsonErr(w, 400, "username and password required")
|
||||
return
|
||||
}
|
||||
var userID, hash string
|
||||
var confirmed, blocked int
|
||||
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
||||
req.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed, &blocked)
|
||||
if err != nil {
|
||||
jsonErr(w, 401, "invalid credentials")
|
||||
return
|
||||
}
|
||||
if blocked != 0 {
|
||||
jsonErr(w, 403, "account blocked")
|
||||
return
|
||||
}
|
||||
if confirmed == 0 {
|
||||
jsonErr(w, 403, "email not confirmed")
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
|
||||
jsonErr(w, 401, "invalid credentials")
|
||||
return
|
||||
}
|
||||
s.db.Exec("UPDATE server_users SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), userID)
|
||||
tok := s.userTokens.Create(userID)
|
||||
jsonOK(w, map[string]string{"token": tok, "user_id": userID})
|
||||
}
|
||||
|
||||
func (s *Server) handleForgot(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.Email == "" {
|
||||
jsonErr(w, 400, "email required")
|
||||
return
|
||||
}
|
||||
var userID string
|
||||
err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", strings.ToLower(req.Email)).Scan(&userID)
|
||||
if err != nil {
|
||||
jsonOK(w, map[string]string{"status": "if email exists, reset link sent"})
|
||||
return
|
||||
}
|
||||
tok := make([]byte, 24)
|
||||
rand.Read(tok)
|
||||
tokenStr := hex.EncodeToString(tok)
|
||||
exp := time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'reset', ?, ?)",
|
||||
tokenStr, userID, exp, now)
|
||||
log.Printf("forgot: reset token=%s for user %s", tokenStr, userID)
|
||||
jsonOK(w, map[string]string{"status": "if email exists, reset link sent"})
|
||||
}
|
||||
|
||||
func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.Token == "" || req.NewPassword == "" {
|
||||
jsonErr(w, 400, "token and new_password required")
|
||||
return
|
||||
}
|
||||
if err := validatePassword(req.NewPassword); err != "" {
|
||||
jsonErr(w, 400, err)
|
||||
return
|
||||
}
|
||||
_, err := s.resetPasswordWithToken(req.Token, req.NewPassword)
|
||||
if err == errResetTokenInvalid {
|
||||
jsonErr(w, 400, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
if err == errResetTokenExpired {
|
||||
jsonErr(w, 400, "token expired")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
jsonErr(w, 500, "internal error")
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]string{"status": "password reset"})
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func (s *Server) requireUserWeb(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
cookie, err := r.Cookie("user_session")
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return "", false
|
||||
}
|
||||
userID, ok := s.userTokens.Check(cookie.Value)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return "", false
|
||||
}
|
||||
return userID, true
|
||||
}
|
||||
|
||||
func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) {
|
||||
locale := s.locale()
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(userRegisterHTML(locale)))
|
||||
case "POST":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(400)
|
||||
w.Write([]byte(errorPageHTML(locale, "400 Bad request", "400 Bad request", "/register")))
|
||||
return
|
||||
}
|
||||
username := r.FormValue("username")
|
||||
email := r.FormValue("email")
|
||||
password := r.FormValue("password")
|
||||
if username == "" || email == "" || password == "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(400)
|
||||
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.allFieldsRequired"), "/register")))
|
||||
return
|
||||
}
|
||||
if err := validatePassword(password); err != "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(400)
|
||||
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), string(err), "/register")))
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(500)
|
||||
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), "internal error", "/register")))
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
id := make([]byte, 12)
|
||||
rand.Read(id)
|
||||
userID := hex.EncodeToString(id)
|
||||
_, err = s.db.Exec(
|
||||
"INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 0, ?)",
|
||||
userID, username, strings.ToLower(email), string(hash), now,
|
||||
)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
w.WriteHeader(409)
|
||||
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), "Username or email already taken", "/register")))
|
||||
} else {
|
||||
log.Printf("register web: create user failed: %v", err)
|
||||
w.WriteHeader(500)
|
||||
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.registrationFailed"), "/register")))
|
||||
}
|
||||
return
|
||||
}
|
||||
tok := make([]byte, 24)
|
||||
rand.Read(tok)
|
||||
tokenStr := hex.EncodeToString(tok)
|
||||
exp := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
|
||||
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'confirm', ?, ?)",
|
||||
tokenStr, userID, exp, now)
|
||||
host := s.smtpGet("smtp_host")
|
||||
if host != "" {
|
||||
srvURL := s.smtpGet("server_url")
|
||||
var confirmURL string
|
||||
if srvURL != "" {
|
||||
confirmURL = fmt.Sprintf("%s/api/v1/auth/confirm?token=%s", srvURL, tokenStr)
|
||||
} else {
|
||||
confirmURL = fmt.Sprintf("http://%s/api/v1/auth/confirm?token=%s", r.Host, tokenStr)
|
||||
}
|
||||
body := fmt.Sprintf(t(locale, "server.emailConfirmBody"), confirmURL)
|
||||
if err := s.smtpSend(email, t(locale, "server.emailConfirmSubject"), body); err != nil {
|
||||
log.Printf("register web: failed to send confirm email: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("register web: SMTP not configured, confirmation token=%s for user %s", tokenStr, username)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
regMsg := registrationOKHTML(locale)
|
||||
if host == "" {
|
||||
regMsg = registrationAutoHTML(locale)
|
||||
}
|
||||
w.Write([]byte(regMsg))
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleUserWebForgot(w http.ResponseWriter, r *http.Request) {
|
||||
locale := s.locale()
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(forgotPasswordHTML(locale)))
|
||||
case "POST":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
jsonErr(w, 400, "bad form")
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(r.FormValue("email"))
|
||||
if email == "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.needEmail"), "/forgot")))
|
||||
return
|
||||
}
|
||||
var userID string
|
||||
err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", email).Scan(&userID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(forgotSentHTML(locale)))
|
||||
return
|
||||
}
|
||||
tok := make([]byte, 24)
|
||||
rand.Read(tok)
|
||||
tokenStr := hex.EncodeToString(tok)
|
||||
exp := time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'reset', ?, ?)",
|
||||
tokenStr, userID, exp, now)
|
||||
host := s.smtpGet("smtp_host")
|
||||
if host != "" {
|
||||
srvURL := s.smtpGet("server_url")
|
||||
resetURL := fmt.Sprintf("/reset?token=%s", tokenStr)
|
||||
if srvURL != "" {
|
||||
resetURL = fmt.Sprintf("%s/reset?token=%s", srvURL, tokenStr)
|
||||
}
|
||||
body := fmt.Sprintf(t(locale, "server.emailResetBody"), resetURL)
|
||||
if err := s.smtpSend(email, t(locale, "server.emailResetSubject"), body); err != nil {
|
||||
log.Printf("forgot web: failed to send reset email: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("forgot web: SMTP not configured, reset token=%s for email %s", tokenStr, email)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(forgotSentHTML(locale)))
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) {
|
||||
locale := s.locale()
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
http.Redirect(w, r, "/forgot", http.StatusFound)
|
||||
return
|
||||
}
|
||||
var userID, expiresAt string
|
||||
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='reset'",
|
||||
token).Scan(&userID, &expiresAt)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/forgot", http.StatusFound)
|
||||
return
|
||||
}
|
||||
exp, err := time.Parse(time.RFC3339, expiresAt)
|
||||
if err != nil || time.Now().After(exp) {
|
||||
http.Redirect(w, r, "/forgot", http.StatusFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
page := strings.ReplaceAll(resetPasswordHTML(locale), "{TOKEN}", html.EscapeString(token))
|
||||
w.Write([]byte(page))
|
||||
case "POST":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
jsonErr(w, 400, "bad form")
|
||||
return
|
||||
}
|
||||
token := r.FormValue("token")
|
||||
newPass := r.FormValue("password")
|
||||
confirm := r.FormValue("confirm")
|
||||
if token == "" || newPass == "" || confirm == "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.allFieldsRequired"), "/forgot")))
|
||||
return
|
||||
}
|
||||
if err := validatePassword(newPass); err != "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), string(err), "/reset?token="+url.QueryEscape(token))))
|
||||
return
|
||||
}
|
||||
if newPass != confirm {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.passwordsDoNotMatch"), "/reset?token="+url.QueryEscape(token))))
|
||||
return
|
||||
}
|
||||
userID, err := s.resetPasswordWithToken(token, newPass)
|
||||
if err == errResetTokenInvalid || err == errResetTokenExpired {
|
||||
http.Redirect(w, r, "/forgot", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), t(locale, "common.error"), "/forgot")))
|
||||
return
|
||||
}
|
||||
log.Printf("reset: user %s reset password", userID)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(resetDoneHTML(locale)))
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleUserWebLogin(w http.ResponseWriter, r *http.Request) {
|
||||
locale := s.locale()
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(userLoginHTML(locale)))
|
||||
case "POST":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
jsonErr(w, 400, "bad form")
|
||||
return
|
||||
}
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
var userID, hash string
|
||||
var confirmed, blocked int
|
||||
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
|
||||
username, strings.ToLower(username)).Scan(&userID, &hash, &confirmed, &blocked)
|
||||
if err != nil || blocked != 0 || confirmed == 0 || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(401)
|
||||
w.Write([]byte(errorPageHTML(locale, "401 Unauthorized", "401 Unauthorized", "/login")))
|
||||
return
|
||||
}
|
||||
tok := s.userTokens.Create(userID)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "user_session", Value: tok, Path: "/",
|
||||
HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 86400,
|
||||
})
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
locale := s.locale()
|
||||
userID, ok := s.requireUserWeb(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var username string
|
||||
s.db.QueryRow("SELECT username FROM server_users WHERE id=?", userID).Scan(&username)
|
||||
|
||||
type dev struct {
|
||||
ID, Name, LastSeen, CreatedAt, ClientVer, RevokedAt string
|
||||
}
|
||||
var devices []dev
|
||||
rows, err := s.db.Query(`
|
||||
SELECT d.id, d.name, COALESCE(d.last_seen,''), d.created_at,
|
||||
COALESCE(d.client_version,''), COALESCE(d.revoked_at,'')
|
||||
FROM server_devices d
|
||||
JOIN server_user_devices ud ON ud.device_id = d.id
|
||||
WHERE ud.user_id = ?
|
||||
ORDER BY d.created_at DESC`, userID)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var d dev
|
||||
rows.Scan(&d.ID, &d.Name, &d.LastSeen, &d.CreatedAt, &d.ClientVer, &d.RevokedAt)
|
||||
devices = append(devices, d)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
deviceRows := ""
|
||||
if len(devices) == 0 {
|
||||
deviceRows = "<tr><td colspan='5' style='color:#666;text-align:center;padding:24px'>" + t(locale, "userDashboard.noDevices") + "</td></tr>"
|
||||
} else {
|
||||
for _, d := range devices {
|
||||
ls := d.LastSeen
|
||||
if ls == "" {
|
||||
ls = "—"
|
||||
}
|
||||
created := d.CreatedAt
|
||||
if len(created) > 10 {
|
||||
created = created[:10]
|
||||
}
|
||||
status := "<span style='color:#34d399'>" + t(locale, "userDashboard.active") + "</span>"
|
||||
revokeBtn := fmt.Sprintf(`<button class="btn btn-danger btn-sm" onclick="revokeDevice(%s)">%s</button>`, html.EscapeString(strconv.Quote(d.ID)), t(locale, "userDashboard.revoke"))
|
||||
if d.RevokedAt != "" {
|
||||
status = "<span style='color:#ff6b6b'>" + t(locale, "userDashboard.revoked") + "</span>"
|
||||
revokeBtn = ""
|
||||
}
|
||||
deviceRows += fmt.Sprintf(`<tr>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>%s %s</td>
|
||||
</tr>`, html.EscapeString(d.Name), status, html.EscapeString(created), html.EscapeString(ls), html.EscapeString(d.ClientVer), revokeBtn)
|
||||
}
|
||||
}
|
||||
|
||||
w.Write([]byte(userDashboardHTML(locale, html.EscapeString(username), deviceRows)))
|
||||
}
|
||||
|
||||
func (s *Server) handleUserWebLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "user_session", Value: "", Path: "/",
|
||||
HttpOnly: true, MaxAge: -1,
|
||||
})
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleUserDevices(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := s.requireUserWeb(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(`
|
||||
SELECT d.id, d.name, COALESCE(d.client_version,''), COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at
|
||||
FROM server_devices d
|
||||
JOIN server_user_devices ud ON ud.device_id = d.id
|
||||
WHERE ud.user_id = ?
|
||||
ORDER BY d.created_at DESC`, userID)
|
||||
if err != nil {
|
||||
jsonInternalError(w, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type devDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ClientVersion string `json:"client_version"`
|
||||
LastSeen string `json:"last_seen"`
|
||||
RevokedAt string `json:"revoked_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
var devices []devDTO
|
||||
for rows.Next() {
|
||||
var d devDTO
|
||||
rows.Scan(&d.ID, &d.Name, &d.ClientVersion, &d.LastSeen, &d.RevokedAt, &d.CreatedAt)
|
||||
devices = append(devices, d)
|
||||
}
|
||||
jsonOK(w, devices)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func jsonOK(w http.ResponseWriter, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func jsonErr(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func jsonInternalError(w http.ResponseWriter, err error) {
|
||||
log.Printf("request failed: %v", err)
|
||||
jsonErr(w, http.StatusInternalServerError, "internal error")
|
||||
}
|
||||
|
||||
func sha256Hex(s string) string {
|
||||
h := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package server
|
||||
|
||||
func t(locale, key string) string {
|
||||
if translations, ok := _translations[locale]; ok {
|
||||
if v, ok := translations[key]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
if translations, ok := _translations["ru"]; ok {
|
||||
if v, ok := translations[key]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
var _translations = map[string]map[string]string{
|
||||
"ru": {
|
||||
"server.registerTitle": "Регистрация",
|
||||
"server.register": "Регистрация",
|
||||
"server.username": "Имя пользователя",
|
||||
"server.email": "Email",
|
||||
"server.password": "Пароль",
|
||||
"server.registerBtn": "Зарегистрироваться",
|
||||
"server.alreadyHaveAccount": "Уже есть аккаунт?",
|
||||
"server.loginBtn": "Войти",
|
||||
"server.loginTitle": "Вход",
|
||||
"server.usernameOrEmail": "Имя пользователя или email",
|
||||
"server.forgotPassword": "Забыли пароль?",
|
||||
"server.adminLink": "Админ",
|
||||
"server.dashboard": "Панель управления",
|
||||
"server.allFieldsRequired": "Все поля обязательны",
|
||||
"server.back": "Назад",
|
||||
"server.emailConfirmBody": "Подтвердите регистрацию: %s",
|
||||
"server.emailConfirmSubject": "Verstak — подтверждение email",
|
||||
"server.registrationSuccess": "Регистрация успешна",
|
||||
"server.registrationEmailSent": "Письмо с ссылкой подтверждения отправлено.",
|
||||
"server.registrationCheckEmail": "Проверьте почту и подтвердите аккаунт.",
|
||||
"server.registrationAutoMessage": "Email не подтверждается. Токен подтверждения выведен в лог сервера.",
|
||||
"server.resetPasswordTitle": "Сброс пароля",
|
||||
"server.resetPassword": "Сброс пароля",
|
||||
"server.resetInstruction": "Введите email для получения ссылки сброса.",
|
||||
"server.sendLink": "Отправить ссылку",
|
||||
"server.backToLogin": "Вернуться к входу",
|
||||
"server.emailSentTitle": "Письмо отправлено",
|
||||
"server.emailSent": "Письмо отправлено",
|
||||
"server.emailSentMessage": "Если аккаунт существует, ссылка для сброса пароля отправлена на почту.",
|
||||
"server.goHome": "На главную",
|
||||
"server.newPasswordTitle": "Новый пароль",
|
||||
"server.newPassword": "Новый пароль",
|
||||
"server.passwordConfirm": "Подтвердите пароль",
|
||||
"server.save": "Сохранить",
|
||||
"server.passwordChanged": "Пароль изменён",
|
||||
"server.passwordChangedMessage": "Пароль успешно изменён. Теперь можно войти.",
|
||||
"server.emailConfirmed": "Email подтверждён",
|
||||
"server.emailConfirmedMessage": "Аккаунт активирован. Теперь можно войти.",
|
||||
"server.needEmail": "Введите email",
|
||||
"server.passwordsDoNotMatch": "Пароли не совпадают",
|
||||
"server.newPasswordResult": "Новый пароль для %s:\n",
|
||||
"server.logout": "Выйти",
|
||||
"server.error": "Ошибка",
|
||||
"server.internalError": "Что-то пошло не так. Повторите попытку.",
|
||||
"server.registrationFailed": "Не удалось зарегистрировать аккаунт. Повторите попытку.",
|
||||
"userDashboard.devices": "Устройства",
|
||||
"userDashboard.device": "Устройство",
|
||||
"userDashboard.status": "Статус",
|
||||
"userDashboard.connected": "Подключено",
|
||||
"userDashboard.lastSeen": "Последний раз",
|
||||
"userDashboard.version": "Версия",
|
||||
"userDashboard.connectNew": "Подключить новое устройство",
|
||||
"userDashboard.connectNewHint": "Установите Верстак на новом устройстве и выполните регистрацию.",
|
||||
"userDashboard.revokeConfirm": "Отозвать устройство?",
|
||||
"userDashboard.revokePrompt": "Введите пароль для подтверждения:",
|
||||
"userDashboard.noDevices": "Нет устройств",
|
||||
"userDashboard.active": "Активно",
|
||||
"userDashboard.revoked": "Отозвано",
|
||||
"userDashboard.revoke": "Отозвать",
|
||||
"admin.login": "Вход администратора",
|
||||
"admin.username": "Имя пользователя",
|
||||
"admin.password": "Пароль",
|
||||
"admin.loginBtn": "Войти",
|
||||
"admin.dashboard": "Панель управления",
|
||||
"admin.deviceCount": "Устройств",
|
||||
"admin.opsCount": "Операций",
|
||||
"admin.devices": "Устройства",
|
||||
"admin.noDevices": "Нет устройств",
|
||||
"admin.device": "Устройство",
|
||||
"admin.user": "Пользователь",
|
||||
"admin.version": "Версия",
|
||||
"admin.status": "Статус",
|
||||
"admin.lastSeen": "Последний раз",
|
||||
"admin.active": "Активно",
|
||||
"admin.revoked": "Отозвано",
|
||||
"admin.revoke": "Отозвать",
|
||||
"admin.smtp": "SMTP",
|
||||
"admin.users": "Пользователи",
|
||||
"admin.usersHeading": "Пользователи",
|
||||
"admin.healthCheck": "Проверка здоровья",
|
||||
"admin.smtpServer": "SMTP сервер",
|
||||
"admin.smtpPort": "SMTP порт",
|
||||
"admin.smtpType": "Тип шифрования",
|
||||
"admin.smtpNoEncryption": "Без шифрования",
|
||||
"admin.smtpUsername": "Имя пользователя SMTP",
|
||||
"admin.smtpPassword": "Пароль SMTP",
|
||||
"admin.smtpFrom": "Отправитель",
|
||||
"admin.smtpServerURL": "URL сервера",
|
||||
"admin.smtpSave": "Сохранить",
|
||||
"admin.smtpTest": "Тест",
|
||||
"admin.smtpTitle": "Настройки SMTP",
|
||||
"admin.smtpTesting": "Проверка...",
|
||||
"admin.smtpPassed": "✓ Тест пройден",
|
||||
"admin.smtpRequired": "Укажите SMTP-сервер, порт и отправителя.",
|
||||
"admin.smtpTestFailed": "Не удалось проверить настройки SMTP. Повторите попытку.",
|
||||
"admin.revokeConfirm": "Вы уверены?",
|
||||
"common.loading": "Загрузка...",
|
||||
"common.ok": "OK",
|
||||
"common.error": "Ошибка",
|
||||
"admin.filterPlaceholder": "Поиск...",
|
||||
"admin.email": "Email",
|
||||
"admin.actions": "Действия",
|
||||
"admin.confirmTitle": "Подтверждение",
|
||||
"admin.modalCancel": "Отмена",
|
||||
"admin.modalConfirm": "Подтвердить",
|
||||
"admin.editUser": "Редактировать пользователя",
|
||||
"admin.editBtn": "Сохранить",
|
||||
"admin.resultTitle": "Результат",
|
||||
"admin.confirmed": "Подтверждён",
|
||||
"admin.unconfirmed": "Не подтверждён",
|
||||
"admin.blocked": "Заблокирован",
|
||||
"admin.unblock": "Разблокировать",
|
||||
"admin.block": "Заблокировать",
|
||||
"admin.resetPassword": "Сбросить пароль",
|
||||
"admin.noUsers": "Нет пользователей",
|
||||
"admin.resetPasswordConfirm": "Сбросить пароль",
|
||||
"admin.resetPasswordMessage": "Новый пароль: ",
|
||||
"admin.resetBtn": "Сбросить",
|
||||
"admin.deleteUser": "Удалить",
|
||||
"admin.deleteUserMessage": "Удалить пользователя %s?",
|
||||
"admin.deleteBtn": "Удалить",
|
||||
"admin.unblockUserTitle": "Разблокировать",
|
||||
"admin.blockUserTitle": "Заблокировать",
|
||||
"admin.unblockUserMessage": "Разблокировать пользователя?",
|
||||
"admin.blockUserMessage": "Заблокировать пользователя?",
|
||||
"admin.createUser": "Создать пользователя",
|
||||
"admin.createUserBtn": "Создать",
|
||||
"admin.createUserFailed": "Не удалось создать пользователя. Повторите попытку.",
|
||||
},
|
||||
"en": {
|
||||
"server.registerTitle": "Registration",
|
||||
"server.register": "Register",
|
||||
"server.username": "Username",
|
||||
"server.email": "Email",
|
||||
"server.password": "Password",
|
||||
"server.registerBtn": "Register",
|
||||
"server.alreadyHaveAccount": "Already have an account?",
|
||||
"server.loginBtn": "Login",
|
||||
"server.loginTitle": "Login",
|
||||
"server.usernameOrEmail": "Username or email",
|
||||
"server.forgotPassword": "Forgot password?",
|
||||
"server.adminLink": "Admin",
|
||||
"server.dashboard": "Dashboard",
|
||||
"server.allFieldsRequired": "All fields are required",
|
||||
"server.back": "Back",
|
||||
"server.emailConfirmBody": "Confirm registration: %s",
|
||||
"server.emailConfirmSubject": "Verstak — email confirmation",
|
||||
"server.registrationSuccess": "Registration successful",
|
||||
"server.registrationEmailSent": "Confirmation link has been sent.",
|
||||
"server.registrationCheckEmail": "Check your email and confirm your account.",
|
||||
"server.registrationAutoMessage": "Email is not confirmed. Confirmation token logged to server.",
|
||||
"server.resetPasswordTitle": "Reset Password",
|
||||
"server.resetPassword": "Reset Password",
|
||||
"server.resetInstruction": "Enter your email to receive a reset link.",
|
||||
"server.sendLink": "Send link",
|
||||
"server.backToLogin": "Back to login",
|
||||
"server.emailSentTitle": "Email sent",
|
||||
"server.emailSent": "Email sent",
|
||||
"server.emailSentMessage": "If the account exists, a reset link has been sent.",
|
||||
"server.goHome": "Go home",
|
||||
"server.newPasswordTitle": "New Password",
|
||||
"server.newPassword": "New Password",
|
||||
"server.passwordConfirm": "Confirm Password",
|
||||
"server.save": "Save",
|
||||
"server.passwordChanged": "Password changed",
|
||||
"server.passwordChangedMessage": "Password has been changed. You can now login.",
|
||||
"server.emailConfirmed": "Email confirmed",
|
||||
"server.emailConfirmedMessage": "Account activated. You can now login.",
|
||||
"server.needEmail": "Enter email",
|
||||
"server.passwordsDoNotMatch": "Passwords do not match",
|
||||
"server.newPasswordResult": "New password for %s:\n",
|
||||
"server.logout": "Logout",
|
||||
"server.error": "Error",
|
||||
"server.internalError": "Something went wrong. Please try again.",
|
||||
"server.registrationFailed": "Could not register the account. Please try again.",
|
||||
"userDashboard.devices": "Devices",
|
||||
"userDashboard.device": "Device",
|
||||
"userDashboard.status": "Status",
|
||||
"userDashboard.connected": "Connected",
|
||||
"userDashboard.lastSeen": "Last seen",
|
||||
"userDashboard.version": "Version",
|
||||
"userDashboard.connectNew": "Connect new device",
|
||||
"userDashboard.connectNewHint": "Install Verstak on a new device and register it.",
|
||||
"userDashboard.revokeConfirm": "Revoke device?",
|
||||
"userDashboard.revokePrompt": "Enter password to confirm:",
|
||||
"userDashboard.noDevices": "No devices",
|
||||
"userDashboard.active": "Active",
|
||||
"userDashboard.revoked": "Revoked",
|
||||
"userDashboard.revoke": "Revoke",
|
||||
"admin.login": "Admin Login",
|
||||
"admin.username": "Username",
|
||||
"admin.password": "Password",
|
||||
"admin.loginBtn": "Login",
|
||||
"admin.dashboard": "Admin Dashboard",
|
||||
"admin.deviceCount": "Devices",
|
||||
"admin.opsCount": "Operations",
|
||||
"admin.devices": "Devices",
|
||||
"admin.noDevices": "No devices",
|
||||
"admin.device": "Device",
|
||||
"admin.user": "User",
|
||||
"admin.version": "Version",
|
||||
"admin.status": "Status",
|
||||
"admin.lastSeen": "Last seen",
|
||||
"admin.active": "Active",
|
||||
"admin.revoked": "Revoked",
|
||||
"admin.revoke": "Revoke",
|
||||
"admin.smtp": "SMTP",
|
||||
"admin.users": "Users",
|
||||
"admin.usersHeading": "Users",
|
||||
"admin.healthCheck": "Health Check",
|
||||
"admin.smtpServer": "SMTP Server",
|
||||
"admin.smtpPort": "SMTP Port",
|
||||
"admin.smtpType": "Encryption",
|
||||
"admin.smtpNoEncryption": "None",
|
||||
"admin.smtpUsername": "SMTP Username",
|
||||
"admin.smtpPassword": "SMTP Password",
|
||||
"admin.smtpFrom": "From",
|
||||
"admin.smtpServerURL": "Server URL",
|
||||
"admin.smtpSave": "Save",
|
||||
"admin.smtpTest": "Test",
|
||||
"admin.smtpTitle": "SMTP Settings",
|
||||
"admin.smtpTesting": "Testing...",
|
||||
"admin.smtpPassed": "✓ Test passed",
|
||||
"admin.smtpRequired": "Enter the SMTP server, port, and sender.",
|
||||
"admin.smtpTestFailed": "Could not test the SMTP settings. Please try again.",
|
||||
"admin.revokeConfirm": "Are you sure?",
|
||||
"common.loading": "Loading...",
|
||||
"common.ok": "OK",
|
||||
"common.error": "Error",
|
||||
"admin.filterPlaceholder": "Search...",
|
||||
"admin.email": "Email",
|
||||
"admin.actions": "Actions",
|
||||
"admin.confirmTitle": "Confirm",
|
||||
"admin.modalCancel": "Cancel",
|
||||
"admin.modalConfirm": "Confirm",
|
||||
"admin.editUser": "Edit User",
|
||||
"admin.editBtn": "Save",
|
||||
"admin.resultTitle": "Result",
|
||||
"admin.confirmed": "Confirmed",
|
||||
"admin.unconfirmed": "Unconfirmed",
|
||||
"admin.blocked": "Blocked",
|
||||
"admin.unblock": "Unblock",
|
||||
"admin.block": "Block",
|
||||
"admin.resetPassword": "Reset Password",
|
||||
"admin.noUsers": "No users",
|
||||
"admin.resetPasswordConfirm": "Reset Password",
|
||||
"admin.resetPasswordMessage": "New password: ",
|
||||
"admin.resetBtn": "Reset",
|
||||
"admin.deleteUser": "Delete",
|
||||
"admin.deleteUserMessage": "Delete user %s?",
|
||||
"admin.deleteBtn": "Delete",
|
||||
"admin.unblockUserTitle": "Unblock",
|
||||
"admin.blockUserTitle": "Block",
|
||||
"admin.unblockUserMessage": "Unblock user?",
|
||||
"admin.blockUserMessage": "Block user?",
|
||||
"admin.createUser": "Create User",
|
||||
"admin.createUserBtn": "Create",
|
||||
"admin.createUserFailed": "Could not create the user. Please try again.",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type authenticatedDevice struct {
|
||||
DeviceID string
|
||||
UserID string
|
||||
VaultID string
|
||||
}
|
||||
|
||||
func (s *Server) requireAuth(w http.ResponseWriter, r *http.Request) (deviceID, userID string, ok bool) {
|
||||
device, ok := s.authenticateDevice(w, r)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
return device.DeviceID, device.UserID, true
|
||||
}
|
||||
|
||||
func (s *Server) requireSyncScope(w http.ResponseWriter, r *http.Request) (authenticatedDevice, bool) {
|
||||
device, ok := s.authenticateDevice(w, r)
|
||||
if !ok {
|
||||
return authenticatedDevice{}, false
|
||||
}
|
||||
if device.UserID == "" {
|
||||
jsonErr(w, http.StatusForbidden, "device is not associated with a user")
|
||||
return authenticatedDevice{}, false
|
||||
}
|
||||
device.VaultID = effectiveVaultScope(device.UserID, device.VaultID)
|
||||
if device.VaultID == "" {
|
||||
jsonErr(w, http.StatusForbidden, "device is not associated with a vault")
|
||||
return authenticatedDevice{}, false
|
||||
}
|
||||
return device, true
|
||||
}
|
||||
|
||||
func (s *Server) authenticateDevice(w http.ResponseWriter, r *http.Request) (authenticatedDevice, bool) {
|
||||
key := r.Header.Get("Authorization")
|
||||
key = strings.TrimPrefix(key, "Bearer ")
|
||||
if key == "" {
|
||||
key = r.URL.Query().Get("api_key")
|
||||
}
|
||||
if key == "" {
|
||||
jsonErr(w, 401, "API key required")
|
||||
return authenticatedDevice{}, false
|
||||
}
|
||||
|
||||
var device authenticatedDevice
|
||||
var userID, vaultID, revokedAt sql.NullString
|
||||
err := s.db.QueryRow(`SELECT id, user_id, vault_id, revoked_at
|
||||
FROM server_devices WHERE token_hash=?`, sha256Hex(key)).
|
||||
Scan(&device.DeviceID, &userID, &vaultID, &revokedAt)
|
||||
if err != nil {
|
||||
err = s.db.QueryRow(`SELECT id, user_id, vault_id, revoked_at
|
||||
FROM server_devices WHERE api_key=?`, key).
|
||||
Scan(&device.DeviceID, &userID, &vaultID, &revokedAt)
|
||||
}
|
||||
if err != nil {
|
||||
jsonErr(w, 401, "invalid API key")
|
||||
return authenticatedDevice{}, false
|
||||
}
|
||||
if revokedAt.Valid && revokedAt.String != "" {
|
||||
jsonErr(w, 401, "device revoked")
|
||||
return authenticatedDevice{}, false
|
||||
}
|
||||
if userID.Valid {
|
||||
device.UserID = userID.String
|
||||
}
|
||||
if vaultID.Valid {
|
||||
device.VaultID = vaultID.String
|
||||
}
|
||||
if device.UserID != "" {
|
||||
var blocked int
|
||||
s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", device.UserID).Scan(&blocked)
|
||||
if blocked != 0 {
|
||||
jsonErr(w, 403, "user blocked")
|
||||
return authenticatedDevice{}, false
|
||||
}
|
||||
}
|
||||
s.db.Exec("UPDATE server_devices SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), device.DeviceID)
|
||||
return device, true
|
||||
}
|
||||
|
||||
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||
cookie, err := r.Cookie("session")
|
||||
if err != nil || !s.tokens.Check(cookie.Value) {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusFound)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type PasswordError string
|
||||
|
||||
const (
|
||||
ErrPasswordTooShort PasswordError = "PASSWORD_TOO_SHORT"
|
||||
ErrPasswordTooLong PasswordError = "PASSWORD_TOO_LONG"
|
||||
)
|
||||
|
||||
func validatePassword(password string) string {
|
||||
if len(password) < 8 {
|
||||
return string(ErrPasswordTooShort)
|
||||
}
|
||||
if len(password) > 256 {
|
||||
return string(ErrPasswordTooLong)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
errResetTokenInvalid = errors.New("invalid reset token")
|
||||
errResetTokenExpired = errors.New("expired reset token")
|
||||
)
|
||||
|
||||
func (s *Server) resetPasswordWithToken(token, newPassword string) (string, error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var userID, expiresAt string
|
||||
err = tx.QueryRow(`SELECT user_id, expires_at FROM server_email_tokens
|
||||
WHERE token=? AND purpose='reset'`, token).Scan(&userID, &expiresAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", errResetTokenInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
expiresAtTime, err := time.Parse(time.RFC3339, expiresAt)
|
||||
if err != nil || !time.Now().Before(expiresAtTime) {
|
||||
return "", errResetTokenExpired
|
||||
}
|
||||
|
||||
deleted, err := tx.Exec(`DELETE FROM server_email_tokens
|
||||
WHERE token=? AND purpose='reset' AND expires_at=?`, token, expiresAt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
deletedCount, err := deleted.RowsAffected()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if deletedCount != 1 {
|
||||
return "", errResetTokenInvalid
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
updated, err := tx.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
updatedCount, err := updated.RowsAffected()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if updatedCount != 1 {
|
||||
return "", errResetTokenInvalid
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package server
|
||||
|
||||
func (s *Server) routes() {
|
||||
s.mux.HandleFunc("/api/v1/health", s.handleHealth)
|
||||
s.mux.HandleFunc("/api/v1/device/register", s.handleDeviceRegister)
|
||||
s.mux.HandleFunc("/api/v1/sync/push", s.handleSyncPush)
|
||||
s.mux.HandleFunc("/api/v1/sync/pull", s.handleSyncPull)
|
||||
s.mux.HandleFunc("/api/v1/blobs/", s.handleBlobs)
|
||||
s.mux.HandleFunc("/api/client/pair", s.handleClientPair)
|
||||
s.mux.HandleFunc("/api/auth/test", s.handleAuthTest)
|
||||
s.mux.HandleFunc("/api/client/revoke-current", s.handleClientRevoke)
|
||||
s.mux.HandleFunc("/api/client/me", s.handleClientMe)
|
||||
s.mux.HandleFunc("/api/client/revoke-device", s.handleClientRevokeDevice)
|
||||
s.mux.HandleFunc("/api/v1/auth/register", s.handleRegister)
|
||||
s.mux.HandleFunc("/api/v1/auth/confirm", s.handleConfirm)
|
||||
s.mux.HandleFunc("/api/v1/auth/login", s.handleUserLogin)
|
||||
s.mux.HandleFunc("/api/v1/auth/forgot", s.handleForgot)
|
||||
s.mux.HandleFunc("/api/v1/auth/reset", s.handleReset)
|
||||
s.mux.HandleFunc("/register", s.handleUserWebRegister)
|
||||
s.mux.HandleFunc("/login", s.handleUserWebLogin)
|
||||
s.mux.HandleFunc("/dashboard", s.handleUserDashboard)
|
||||
s.mux.HandleFunc("/forgot", s.handleUserWebForgot)
|
||||
s.mux.HandleFunc("/reset", s.handleUserWebReset)
|
||||
s.mux.HandleFunc("/logout", s.handleUserWebLogout)
|
||||
s.mux.HandleFunc("/api/v1/user/devices", s.handleUserDevices)
|
||||
s.mux.HandleFunc("/admin/login", s.handleAdminLogin)
|
||||
s.mux.HandleFunc("/admin/dashboard", s.handleAdminDashboard)
|
||||
s.mux.HandleFunc("/admin/users", s.handleAdminUsers)
|
||||
s.mux.HandleFunc("/admin/create-user", s.handleAdminCreateUser)
|
||||
s.mux.HandleFunc("/admin/api/users/create", s.handleAdminAPICreateUser)
|
||||
s.mux.HandleFunc("/admin/devices", s.handleAdminDevices)
|
||||
s.mux.HandleFunc("/admin/api/stats", s.handleAdminStats)
|
||||
s.mux.HandleFunc("/admin/api/smtp/test", s.handleAdminSMTPTest)
|
||||
s.mux.HandleFunc("/admin/api/smtp", s.handleAdminAPISmtp)
|
||||
s.mux.HandleFunc("/admin/api/devices", s.handleAdminAPIDevices)
|
||||
s.mux.HandleFunc("/admin/api/keys/", s.handleAdminAPIKeysDelete)
|
||||
s.mux.HandleFunc("/admin/api/keys", s.handleAdminAPIKeys)
|
||||
s.mux.HandleFunc("/admin/api/users/", s.handleAdminAPIUserActions)
|
||||
s.mux.HandleFunc("/admin/api/users", s.handleAdminAPIUsers)
|
||||
s.mux.HandleFunc("/", s.handleNotFound)
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const serverSchema = `
|
||||
CREATE TABLE IF NOT EXISTS server_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
confirmed INTEGER NOT NULL DEFAULT 0,
|
||||
blocked INTEGER NOT NULL DEFAULT 0,
|
||||
last_seen TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_devices (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
api_key TEXT NOT NULL UNIQUE,
|
||||
token_hash TEXT,
|
||||
token_prefix TEXT,
|
||||
token_suffix TEXT,
|
||||
user_id TEXT,
|
||||
vault_id TEXT,
|
||||
client_version TEXT,
|
||||
last_ip TEXT,
|
||||
last_seen TEXT,
|
||||
revoked_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_user_devices (
|
||||
user_id TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, device_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_ops (
|
||||
op_id TEXT PRIMARY KEY,
|
||||
server_sequence INTEGER,
|
||||
user_id TEXT NOT NULL,
|
||||
vault_id TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
op_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
idempotency_key TEXT,
|
||||
client_sequence INTEGER DEFAULT 0,
|
||||
last_seen_server_seq INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
pushed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_tombstones (
|
||||
user_id TEXT NOT NULL,
|
||||
vault_id TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
op_id TEXT NOT NULL,
|
||||
deleted_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, vault_id, entity_type, entity_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_idempotency_keys (
|
||||
user_id TEXT NOT NULL,
|
||||
vault_id TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL,
|
||||
response_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, vault_id, idempotency_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_email_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_revisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
op_id TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_blobs (
|
||||
sha256 TEXT PRIMARY KEY,
|
||||
size INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_type TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
device_id TEXT,
|
||||
ip TEXT,
|
||||
message TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_server_ops_sequence ON server_ops(server_sequence);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_ops_entity ON server_ops(entity_type, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_ops_idempotency ON server_ops(idempotency_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_devices_api_key ON server_devices(api_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_devices_user ON server_devices(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_users_username ON server_users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_users_email ON server_users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_audit_log_event ON server_audit_log(event_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_audit_log_created ON server_audit_log(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_smtp_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
type sqliteColumn struct {
|
||||
primaryKeyOrder int
|
||||
}
|
||||
|
||||
func migrateServerSchema(db *sql.DB) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, change := range []struct {
|
||||
table string
|
||||
column string
|
||||
definition string
|
||||
}{
|
||||
{"server_devices", "user_id", "TEXT"},
|
||||
{"server_devices", "vault_id", "TEXT"},
|
||||
{"server_ops", "user_id", "TEXT"},
|
||||
{"server_ops", "vault_id", "TEXT"},
|
||||
} {
|
||||
if err := ensureSQLiteColumn(tx, change.table, change.column, change.definition); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := backfillDeviceOwners(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillOperationScope(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateTombstoneScope(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateIdempotencyScope(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`CREATE INDEX IF NOT EXISTS idx_server_ops_scope_sequence
|
||||
ON server_ops(user_id, vault_id, server_sequence)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`CREATE INDEX IF NOT EXISTS idx_server_devices_scope
|
||||
ON server_devices(user_id, vault_id)`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func ensureSQLiteColumn(tx *sql.Tx, table, column, definition string) error {
|
||||
columns, err := sqliteTableColumns(tx, table)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := columns[column]; ok {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition)); err != nil {
|
||||
return fmt.Errorf("add %s.%s: %w", table, column, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteTableColumns(tx *sql.Tx, table string) (map[string]sqliteColumn, error) {
|
||||
rows, err := tx.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columns := make(map[string]sqliteColumn)
|
||||
for rows.Next() {
|
||||
var cid, notNull, primaryKeyOrder int
|
||||
var name, dataType string
|
||||
var defaultValue sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &dataType, ¬Null, &defaultValue, &primaryKeyOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columns[name] = sqliteColumn{primaryKeyOrder: primaryKeyOrder}
|
||||
}
|
||||
return columns, rows.Err()
|
||||
}
|
||||
|
||||
func backfillDeviceOwners(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`UPDATE server_devices
|
||||
SET user_id = (
|
||||
SELECT user_id FROM server_user_devices
|
||||
WHERE device_id = server_devices.id
|
||||
)
|
||||
WHERE COALESCE(user_id, '') = ''
|
||||
AND 1 = (
|
||||
SELECT COUNT(*) FROM server_user_devices
|
||||
WHERE device_id = server_devices.id
|
||||
)`)
|
||||
return err
|
||||
}
|
||||
|
||||
func backfillOperationScope(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(`UPDATE server_ops
|
||||
SET user_id = (
|
||||
SELECT user_id FROM server_devices
|
||||
WHERE id = server_ops.device_id
|
||||
)
|
||||
WHERE COALESCE(user_id, '') = ''`); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(`UPDATE server_ops
|
||||
SET vault_id = COALESCE(
|
||||
NULLIF((SELECT vault_id FROM server_devices WHERE id = server_ops.device_id), ''),
|
||||
'legacy:' || user_id
|
||||
)
|
||||
WHERE COALESCE(vault_id, '') = ''
|
||||
AND COALESCE(user_id, '') != ''`)
|
||||
return err
|
||||
}
|
||||
|
||||
func migrateTombstoneScope(tx *sql.Tx) error {
|
||||
if hasScopedPrimaryKey(tx, "server_tombstones", "user_id", "vault_id", "entity_type", "entity_id") {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec("ALTER TABLE server_tombstones RENAME TO server_tombstones_legacy"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`CREATE TABLE server_tombstones (
|
||||
user_id TEXT NOT NULL,
|
||||
vault_id TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
op_id TEXT NOT NULL,
|
||||
deleted_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, vault_id, entity_type, entity_id)
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT OR IGNORE INTO server_tombstones
|
||||
(user_id, vault_id, entity_type, entity_id, op_id, deleted_at)
|
||||
SELECT o.user_id, o.vault_id, legacy.entity_type, legacy.entity_id, legacy.op_id, legacy.deleted_at
|
||||
FROM server_tombstones_legacy AS legacy
|
||||
JOIN server_ops AS o ON o.op_id = legacy.op_id
|
||||
WHERE COALESCE(o.user_id, '') != '' AND COALESCE(o.vault_id, '') != ''`); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec("DROP TABLE server_tombstones_legacy")
|
||||
return err
|
||||
}
|
||||
|
||||
func migrateIdempotencyScope(tx *sql.Tx) error {
|
||||
if hasScopedPrimaryKey(tx, "server_idempotency_keys", "user_id", "vault_id", "idempotency_key") {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec("ALTER TABLE server_idempotency_keys RENAME TO server_idempotency_keys_legacy"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`CREATE TABLE server_idempotency_keys (
|
||||
user_id TEXT NOT NULL,
|
||||
vault_id TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL,
|
||||
response_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, vault_id, idempotency_key)
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
// The previous cache was global. Discard it rather than risk replaying a
|
||||
// response for another user or vault after the migration.
|
||||
_, err := tx.Exec("DROP TABLE server_idempotency_keys_legacy")
|
||||
return err
|
||||
}
|
||||
|
||||
func hasScopedPrimaryKey(tx *sql.Tx, table string, want ...string) bool {
|
||||
columns, err := sqliteTableColumns(tx, table)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for index, name := range want {
|
||||
column, ok := columns[name]
|
||||
if !ok || column.primaryKeyOrder != index+1 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func effectiveVaultScope(userID, vaultID string) string {
|
||||
if vaultID != "" {
|
||||
return vaultID
|
||||
}
|
||||
if userID == "" {
|
||||
return ""
|
||||
}
|
||||
return "legacy:" + userID
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
type pairRateLimit struct {
|
||||
mu sync.Mutex
|
||||
attempts map[string]int
|
||||
}
|
||||
|
||||
func (p *pairRateLimit) allow(ip string) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.attempts == nil {
|
||||
p.attempts = make(map[string]int)
|
||||
}
|
||||
p.attempts[ip]++
|
||||
return p.attempts[ip] <= 5
|
||||
}
|
||||
|
||||
func (p *pairRateLimit) reset(ip string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
delete(p.attempts, ip)
|
||||
}
|
||||
|
||||
type tokenStore struct {
|
||||
mu sync.Mutex
|
||||
tokens map[string]time.Time
|
||||
}
|
||||
|
||||
func newTokenStore() *tokenStore {
|
||||
return &tokenStore{tokens: make(map[string]time.Time)}
|
||||
}
|
||||
|
||||
func (ts *tokenStore) Create() string {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
tok := hex.EncodeToString(b)
|
||||
ts.tokens[tok] = time.Now().Add(24 * time.Hour)
|
||||
return tok
|
||||
}
|
||||
|
||||
func (ts *tokenStore) Check(tok string) bool {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
exp, ok := ts.tokens[tok]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if time.Now().After(exp) {
|
||||
delete(ts.tokens, tok)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type userTokenStore struct {
|
||||
mu sync.Mutex
|
||||
tokens map[string]userTokenEntry
|
||||
}
|
||||
|
||||
type userTokenEntry struct {
|
||||
UserID string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func newUserTokenStore() *userTokenStore {
|
||||
return &userTokenStore{tokens: make(map[string]userTokenEntry)}
|
||||
}
|
||||
|
||||
func (uts *userTokenStore) Create(userID string) string {
|
||||
uts.mu.Lock()
|
||||
defer uts.mu.Unlock()
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
tok := hex.EncodeToString(b)
|
||||
uts.tokens[tok] = userTokenEntry{UserID: userID, ExpiresAt: time.Now().Add(24 * time.Hour)}
|
||||
return tok
|
||||
}
|
||||
|
||||
func (uts *userTokenStore) Check(tok string) (string, bool) {
|
||||
uts.mu.Lock()
|
||||
defer uts.mu.Unlock()
|
||||
entry, ok := uts.tokens[tok]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if time.Now().After(entry.ExpiresAt) {
|
||||
delete(uts.tokens, tok)
|
||||
return "", false
|
||||
}
|
||||
return entry.UserID, true
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
db *sql.DB
|
||||
cfg *Config
|
||||
tokens *tokenStore
|
||||
userTokens *userTokenStore
|
||||
blobsDir string
|
||||
mux *http.ServeMux
|
||||
pairLimit *pairRateLimit
|
||||
}
|
||||
|
||||
func (s *Server) auditLog(eventType, userID, deviceID, ip, msg string) {
|
||||
s.db.Exec("INSERT INTO server_audit_log (event_type, user_id, device_id, ip, message, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
eventType, userID, deviceID, ip, msg, time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) {
|
||||
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?mode=rwc", dbPath))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open db: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
for _, stmt := range strings.Split(serverSchema, ";") {
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
if stmt == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("schema: %w", err)
|
||||
}
|
||||
}
|
||||
if err := migrateServerSchema(db); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrate schema: %w", err)
|
||||
}
|
||||
|
||||
blobsDir := filepath.Join(dataDir, "blobs")
|
||||
if err := os.MkdirAll(blobsDir, 0750); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
db: db,
|
||||
cfg: cfg,
|
||||
tokens: newTokenStore(),
|
||||
userTokens: newUserTokenStore(),
|
||||
blobsDir: blobsDir,
|
||||
pairLimit: &pairRateLimit{},
|
||||
}
|
||||
s.mux = http.NewServeMux()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) SetupRoutes() {
|
||||
s.routes()
|
||||
}
|
||||
|
||||
func (s *Server) locale() string {
|
||||
return "ru"
|
||||
}
|
||||
|
||||
func (s *Server) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *Server) ListenAndServe(addr string) error {
|
||||
return http.ListenAndServe(addr, s.mux)
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"html"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestNewServer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
dataDir := filepath.Join(dir, "data")
|
||||
if err := os.MkdirAll(dataDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := &Config{Port: 47732}
|
||||
s, err := NewServer(dbPath, dataDir, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
t.Fatal("database file was not created")
|
||||
}
|
||||
|
||||
blobsDir := filepath.Join(dataDir, "blobs")
|
||||
if _, err := os.Stat(blobsDir); os.IsNotExist(err) {
|
||||
t.Fatal("blobs directory was not created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSetAdmin(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yml")
|
||||
cfg := &Config{Port: 47732, path: cfgPath}
|
||||
|
||||
if err := cfg.SetAdmin("admin", "secret"); err != nil {
|
||||
t.Fatalf("SetAdmin: %v", err)
|
||||
}
|
||||
|
||||
if !cfg.CheckAdmin("admin", "secret") {
|
||||
t.Fatal("CheckAdmin should return true for correct password")
|
||||
}
|
||||
|
||||
if cfg.CheckAdmin("admin", "wrong") {
|
||||
t.Fatal("CheckAdmin should return false for wrong password")
|
||||
}
|
||||
|
||||
if cfg.CheckAdmin("unknown", "secret") {
|
||||
t.Fatal("CheckAdmin should return false for unknown user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminDashboardSMTPSecuritySelectUsesApplicationStyles(t *testing.T) {
|
||||
html := adminDashboardHTML("en", 0, 0, "", "", "", "", "starttls", "")
|
||||
|
||||
for _, expected := range []string{
|
||||
`<select name="smtp_security" class="form-select">`,
|
||||
`.form-select{`,
|
||||
`appearance:none`,
|
||||
`background-image:linear-gradient`,
|
||||
`.form-select option{background:#13131f;color:#e4e4ef}`,
|
||||
`.form-select:focus{outline:none;border-color:#6366f1`,
|
||||
} {
|
||||
if !strings.Contains(html, expected) {
|
||||
t.Errorf("SMTP security select is missing application styling %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserFacingServerErrorsDoNotExposeInternalDetails(t *testing.T) {
|
||||
for _, name := range []string{"handlers_auth.go", "handlers_api.go", "handlers_admin.go", "handlers_web_user.go"} {
|
||||
source, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"jsonErr(w, 500, err.Error())",
|
||||
"http.Error(w, err.Error(), 500)",
|
||||
"errorPageHTML(locale, t(locale, \"common.error\"), err.Error()",
|
||||
} {
|
||||
if strings.Contains(string(source), forbidden) {
|
||||
t.Errorf("%s exposes an internal error with %q", name, forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncPushPullStoresSequencedOps(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := NewServer(filepath.Join(dir, "test.db"), filepath.Join(dir, "data"), &Config{Port: 47732})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
s.SetupRoutes()
|
||||
|
||||
insertSyncUser(t, s, "user-a")
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if _, err := s.db.Exec(
|
||||
"INSERT INTO server_devices (id, name, api_key, user_id, vault_id, last_seen, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
"device-a", "Device A", "api-key", "user-a", "vault-a", now, now,
|
||||
); err != nil {
|
||||
t.Fatalf("insert device: %v", err)
|
||||
}
|
||||
ts := httptest.NewServer(s.mux)
|
||||
defer ts.Close()
|
||||
|
||||
pushBody := map[string]interface{}{
|
||||
"device_id": "device-a",
|
||||
"ops": []map[string]interface{}{
|
||||
{
|
||||
"op_id": "op-1",
|
||||
"entity_type": "file",
|
||||
"entity_id": "Docs/one.txt",
|
||||
"op_type": "create",
|
||||
"payload_json": `{"path":"Docs/one.txt","content":"hello"}`,
|
||||
"created_at": "2026-06-27T00:00:00Z",
|
||||
"client_sequence": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
pushResp := postJSON(t, ts.URL+"/api/v1/sync/push", "api-key", pushBody)
|
||||
if got := int(pushResp["count"].(float64)); got != 1 {
|
||||
t.Fatalf("push count = %d, want 1: %#v", got, pushResp)
|
||||
}
|
||||
accepted := pushResp["accepted"].([]interface{})
|
||||
if len(accepted) != 1 || accepted[0] != "op-1" {
|
||||
t.Fatalf("accepted = %#v", accepted)
|
||||
}
|
||||
|
||||
pullResp := postJSON(t, ts.URL+"/api/v1/sync/pull", "api-key", map[string]interface{}{
|
||||
"since_sequence": 0,
|
||||
})
|
||||
if got := int(pullResp["server_sequence"].(float64)); got != 1 {
|
||||
t.Fatalf("server_sequence = %d, want 1: %#v", got, pullResp)
|
||||
}
|
||||
ops := pullResp["ops"].([]interface{})
|
||||
if len(ops) != 1 {
|
||||
t.Fatalf("ops len = %d, want 1: %#v", len(ops), ops)
|
||||
}
|
||||
op := ops[0].(map[string]interface{})
|
||||
if op["op_id"] != "op-1" ||
|
||||
op["device_id"] != "device-a" ||
|
||||
op["entity_type"] != "file" ||
|
||||
op["entity_id"] != "Docs/one.txt" ||
|
||||
op["op_type"] != "create" ||
|
||||
op["payload_json"] != `{"path":"Docs/one.txt","content":"hello"}` ||
|
||||
int(op["server_sequence"].(float64)) != 1 {
|
||||
t.Fatalf("pulled op = %#v", op)
|
||||
}
|
||||
|
||||
pullAfterResp := postJSON(t, ts.URL+"/api/v1/sync/pull", "api-key", map[string]interface{}{
|
||||
"since_sequence": 1,
|
||||
})
|
||||
if got := int(pullAfterResp["server_sequence"].(float64)); got != 1 {
|
||||
t.Fatalf("server_sequence after = %d, want 1", got)
|
||||
}
|
||||
if ops := pullAfterResp["ops"].([]interface{}); len(ops) != 0 {
|
||||
t.Fatalf("ops after seq len = %d, want 0: %#v", len(ops), ops)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokedLegacyAPIKeyCannotPushOrPull(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := NewServer(filepath.Join(dir, "test.db"), filepath.Join(dir, "data"), &Config{Port: 47732})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
s.SetupRoutes()
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if _, err := s.db.Exec(
|
||||
"INSERT INTO server_devices (id, name, api_key, last_seen, revoked_at, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
"device-revoked", "Revoked Device", "revoked-key", now, now, now,
|
||||
); err != nil {
|
||||
t.Fatalf("insert device: %v", err)
|
||||
}
|
||||
ts := httptest.NewServer(s.mux)
|
||||
defer ts.Close()
|
||||
|
||||
pushStatus, pushResp := postJSONStatus(t, ts.URL+"/api/v1/sync/push", "revoked-key", map[string]interface{}{
|
||||
"device_id": "device-revoked",
|
||||
"ops": []map[string]interface{}{},
|
||||
})
|
||||
if pushStatus != http.StatusUnauthorized || pushResp["error"] != "device revoked" {
|
||||
t.Fatalf("push status=%d resp=%#v, want 401 device revoked", pushStatus, pushResp)
|
||||
}
|
||||
|
||||
pullStatus, pullResp := postJSONStatus(t, ts.URL+"/api/v1/sync/pull", "revoked-key", map[string]interface{}{
|
||||
"since_sequence": 0,
|
||||
})
|
||||
if pullStatus != http.StatusUnauthorized || pullResp["error"] != "device revoked" {
|
||||
t.Fatalf("pull status=%d resp=%#v, want 401 device revoked", pullStatus, pullResp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncPullDoesNotExposeOtherUserOperations(t *testing.T) {
|
||||
s, ts := newSyncHTTPServer(t)
|
||||
defer s.Close()
|
||||
defer ts.Close()
|
||||
|
||||
insertSyncUser(t, s, "user-a")
|
||||
insertSyncUser(t, s, "user-b")
|
||||
insertSyncDevice(t, s, "device-a", "user-a", "token-a")
|
||||
insertSyncDevice(t, s, "device-b", "user-b", "token-b")
|
||||
|
||||
postJSON(t, ts.URL+"/api/v1/sync/push", "token-a", syncPushBody("device-a", "op-user-a", ""))
|
||||
|
||||
pull := postJSON(t, ts.URL+"/api/v1/sync/pull", "token-b", map[string]interface{}{
|
||||
"since_sequence": 0,
|
||||
})
|
||||
if got := len(pull["ops"].([]interface{})); got != 0 {
|
||||
t.Fatalf("other user pulled %d operation(s), want 0: %#v", got, pull)
|
||||
}
|
||||
if got := int(pull["server_sequence"].(float64)); got != 0 {
|
||||
t.Fatalf("other user cursor = %d, want 0: %#v", got, pull)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncPushUsesAuthenticatedDeviceIdentity(t *testing.T) {
|
||||
s, ts := newSyncHTTPServer(t)
|
||||
defer s.Close()
|
||||
defer ts.Close()
|
||||
|
||||
insertSyncUser(t, s, "user-a")
|
||||
insertSyncUser(t, s, "user-b")
|
||||
insertSyncDevice(t, s, "device-authenticated", "user-a", "token-a")
|
||||
insertSyncDevice(t, s, "device-forged", "user-b", "token-b")
|
||||
|
||||
postJSON(t, ts.URL+"/api/v1/sync/push", "token-a", syncPushBody("device-forged", "op-auth-device", ""))
|
||||
|
||||
var storedDeviceID string
|
||||
if err := s.db.QueryRow("SELECT device_id FROM server_ops WHERE op_id=?", "op-auth-device").Scan(&storedDeviceID); err != nil {
|
||||
t.Fatalf("read stored operation: %v", err)
|
||||
}
|
||||
if storedDeviceID != "device-authenticated" {
|
||||
t.Fatalf("stored device = %q, want authenticated device", storedDeviceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncPushScopesIdempotencyResponsesByUser(t *testing.T) {
|
||||
s, ts := newSyncHTTPServer(t)
|
||||
defer s.Close()
|
||||
defer ts.Close()
|
||||
|
||||
insertSyncUser(t, s, "user-a")
|
||||
insertSyncUser(t, s, "user-b")
|
||||
insertSyncDevice(t, s, "device-a", "user-a", "token-a")
|
||||
insertSyncDevice(t, s, "device-b", "user-b", "token-b")
|
||||
|
||||
postJSON(t, ts.URL+"/api/v1/sync/push", "token-a", syncPushBody("device-a", "op-user-a", "same-key"))
|
||||
response := postJSON(t, ts.URL+"/api/v1/sync/push", "token-b", syncPushBody("device-b", "op-user-b", "same-key"))
|
||||
|
||||
accepted := response["accepted"].([]interface{})
|
||||
if len(accepted) != 1 || accepted[0] != "op-user-b" {
|
||||
t.Fatalf("user-b received idempotency response %#v, want only op-user-b", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncPushDoesNotReportOtherTenantConflicts(t *testing.T) {
|
||||
s, ts := newSyncHTTPServer(t)
|
||||
defer s.Close()
|
||||
defer ts.Close()
|
||||
|
||||
insertSyncUser(t, s, "user-a")
|
||||
insertSyncUser(t, s, "user-b")
|
||||
insertSyncDevice(t, s, "device-a", "user-a", "token-a")
|
||||
insertSyncDevice(t, s, "device-b", "user-b", "token-b")
|
||||
|
||||
postJSON(t, ts.URL+"/api/v1/sync/push", "token-a", syncPushBody("device-a", "op-user-a-1", ""))
|
||||
postJSON(t, ts.URL+"/api/v1/sync/push", "token-a", syncPushBody("device-a", "op-user-a-2", ""))
|
||||
|
||||
body := syncPushBody("device-b", "op-user-b", "")
|
||||
body["ops"].([]map[string]interface{})[0]["last_seen_server_seq"] = 1
|
||||
response := postJSON(t, ts.URL+"/api/v1/sync/push", "token-b", body)
|
||||
if response["conflicts"] != nil {
|
||||
t.Fatalf("other tenant conflict leaked into response: %#v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncPullDoesNotExposeOtherVaultOperations(t *testing.T) {
|
||||
s, ts := newSyncHTTPServer(t)
|
||||
defer s.Close()
|
||||
defer ts.Close()
|
||||
|
||||
password := "correct horse battery staple"
|
||||
insertPairableUser(t, s, "user-a", "alice", password)
|
||||
|
||||
deviceA := pairSyncDevice(t, ts.URL, "alice", password, "vault-a")
|
||||
deviceB := pairSyncDevice(t, ts.URL, "alice", password, "vault-b")
|
||||
|
||||
postJSON(t, ts.URL+"/api/v1/sync/push", deviceA.token, syncPushBody(deviceA.id, "op-vault-a", ""))
|
||||
postJSON(t, ts.URL+"/api/v1/sync/push", deviceB.token, syncPushBody(deviceB.id, "op-vault-b", ""))
|
||||
|
||||
pull := postJSON(t, ts.URL+"/api/v1/sync/pull", deviceA.token, map[string]interface{}{
|
||||
"since_sequence": 0,
|
||||
})
|
||||
ops := pull["ops"].([]interface{})
|
||||
if len(ops) != 1 || ops[0].(map[string]interface{})["op_id"] != "op-vault-a" {
|
||||
t.Fatalf("vault-a pulled %#v, want only op-vault-a", pull)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPairRequiresVaultID(t *testing.T) {
|
||||
s, ts := newSyncHTTPServer(t)
|
||||
defer s.Close()
|
||||
defer ts.Close()
|
||||
|
||||
password := "correct horse battery staple"
|
||||
insertPairableUser(t, s, "user-a", "alice", password)
|
||||
|
||||
status, response := postJSONStatus(t, ts.URL+"/api/client/pair", "", map[string]interface{}{
|
||||
"login": "alice",
|
||||
"password": password,
|
||||
"device_name": "Desktop",
|
||||
})
|
||||
if status != http.StatusBadRequest || response["error"] != "vault_id required" {
|
||||
t.Fatalf("pair without vault ID status=%d response=%#v, want 400 vault_id required", status, response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPairRejectsReservedLegacyVaultID(t *testing.T) {
|
||||
s, ts := newSyncHTTPServer(t)
|
||||
defer s.Close()
|
||||
defer ts.Close()
|
||||
|
||||
password := "correct horse battery staple"
|
||||
insertPairableUser(t, s, "user-a", "alice", password)
|
||||
|
||||
status, response := postJSONStatus(t, ts.URL+"/api/client/pair", "", map[string]interface{}{
|
||||
"login": "alice",
|
||||
"password": password,
|
||||
"device_name": "Desktop",
|
||||
"vault_id": "legacy:user-a",
|
||||
})
|
||||
if status != http.StatusBadRequest || response["error"] != "vault_id uses reserved prefix" {
|
||||
t.Fatalf("pair with reserved vault ID status=%d response=%#v, want 400 reserved prefix", status, response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceRegisterRequiresValidVaultID(t *testing.T) {
|
||||
s, ts := newSyncHTTPServer(t)
|
||||
defer s.Close()
|
||||
defer ts.Close()
|
||||
|
||||
password := "correct horse battery staple"
|
||||
insertPairableUser(t, s, "user-a", "alice", password)
|
||||
|
||||
status, response := postJSONStatus(t, ts.URL+"/api/v1/device/register", "", map[string]interface{}{
|
||||
"name": "Desktop",
|
||||
"username": "alice",
|
||||
"password": password,
|
||||
})
|
||||
if status != http.StatusBadRequest || response["error"] != "vault_id required" {
|
||||
t.Fatalf("register without vault ID status=%d response=%#v, want 400 vault_id required", status, response)
|
||||
}
|
||||
|
||||
status, response = postJSONStatus(t, ts.URL+"/api/v1/device/register", "", map[string]interface{}{
|
||||
"name": "Desktop",
|
||||
"username": "alice",
|
||||
"password": password,
|
||||
"vault_id": "legacy:user-a",
|
||||
})
|
||||
if status != http.StatusBadRequest || response["error"] != "vault_id uses reserved prefix" {
|
||||
t.Fatalf("register with reserved vault ID status=%d response=%#v, want 400 reserved prefix", status, response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebResetRejectsExpiredToken(t *testing.T) {
|
||||
s, _ := newSyncHTTPServer(t)
|
||||
defer s.Close()
|
||||
|
||||
oldPassword := "correct horse battery staple"
|
||||
insertPairableUser(t, s, "user-a", "alice", oldPassword)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if _, err := s.db.Exec(`INSERT INTO server_email_tokens
|
||||
(token, user_id, purpose, expires_at, created_at)
|
||||
VALUES (?, ?, 'reset', ?, ?)`, "expired-reset-token", "user-a", time.Now().Add(-time.Hour).UTC().Format(time.RFC3339), now); err != nil {
|
||||
t.Fatalf("insert reset token: %v", err)
|
||||
}
|
||||
|
||||
response := postWebReset(t, s, "expired-reset-token", "new secret password")
|
||||
if response.Code != http.StatusFound || response.Header().Get("Location") != "/forgot" {
|
||||
t.Fatalf("expired reset response status=%d location=%q, want 302 /forgot", response.Code, response.Header().Get("Location"))
|
||||
}
|
||||
|
||||
var passwordHash string
|
||||
if err := s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", "user-a").Scan(&passwordHash); err != nil {
|
||||
t.Fatalf("read password hash: %v", err)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(oldPassword)) != nil {
|
||||
t.Fatal("expired reset changed the password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerRenderedPagesEscapeStoredValues(t *testing.T) {
|
||||
s, _ := newSyncHTTPServer(t)
|
||||
defer s.Close()
|
||||
|
||||
const maliciousText = `<img src=x onerror=alert(1)>`
|
||||
const maliciousDeviceID = `device');alert(1);//`
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if _, err := s.db.Exec(`INSERT INTO server_users
|
||||
(id, username, email, password_hash, confirmed, created_at)
|
||||
VALUES (?, ?, ?, ?, 1, ?)`, "user-a", maliciousText, maliciousText+"@example.test", "unused", now); err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
if _, err := s.db.Exec(`INSERT INTO server_devices
|
||||
(id, name, api_key, user_id, vault_id, client_version, last_seen, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, maliciousDeviceID, maliciousText, "device-key", "user-a", "vault-a", maliciousText, now, now); err != nil {
|
||||
t.Fatalf("insert device: %v", err)
|
||||
}
|
||||
if _, err := s.db.Exec("INSERT INTO server_user_devices (user_id, device_id) VALUES (?, ?)", "user-a", maliciousDeviceID); err != nil {
|
||||
t.Fatalf("link user device: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
cookie *http.Cookie
|
||||
containsDevID bool
|
||||
}{
|
||||
{
|
||||
name: "user dashboard",
|
||||
path: "/dashboard",
|
||||
cookie: &http.Cookie{Name: "user_session", Value: s.userTokens.Create("user-a")},
|
||||
containsDevID: true,
|
||||
},
|
||||
{
|
||||
name: "admin users",
|
||||
path: "/admin/users",
|
||||
cookie: &http.Cookie{Name: "admin_session", Value: s.tokens.Create()},
|
||||
},
|
||||
{
|
||||
name: "admin devices",
|
||||
path: "/admin/devices",
|
||||
cookie: &http.Cookie{Name: "admin_session", Value: s.tokens.Create()},
|
||||
containsDevID: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
|
||||
req.AddCookie(tt.cookie)
|
||||
res := httptest.NewRecorder()
|
||||
s.mux.ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", res.Code)
|
||||
}
|
||||
body := res.Body.String()
|
||||
if strings.Contains(body, maliciousText) {
|
||||
t.Fatalf("page contains unescaped stored text: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, html.EscapeString(maliciousText)) {
|
||||
t.Fatalf("page does not contain escaped stored text: %s", body)
|
||||
}
|
||||
if tt.containsDevID && strings.Contains(body, maliciousDeviceID) {
|
||||
t.Fatalf("page contains unescaped device ID: %s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerMigratesLegacyOperationScope(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "legacy.db")
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy db: %v", err)
|
||||
}
|
||||
|
||||
legacySchema := []string{
|
||||
`CREATE TABLE server_devices (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, api_key TEXT NOT NULL UNIQUE,
|
||||
token_hash TEXT, token_prefix TEXT, token_suffix TEXT, user_id TEXT,
|
||||
client_version TEXT, last_ip TEXT, last_seen TEXT, revoked_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE server_user_devices (
|
||||
user_id TEXT NOT NULL, device_id TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, device_id)
|
||||
)`,
|
||||
`CREATE TABLE server_ops (
|
||||
op_id TEXT PRIMARY KEY, server_sequence INTEGER, device_id TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, op_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL, idempotency_key TEXT, client_sequence INTEGER DEFAULT 0,
|
||||
last_seen_server_seq INTEGER DEFAULT 0, created_at TEXT NOT NULL,
|
||||
pushed_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE server_tombstones (
|
||||
entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, op_id TEXT NOT NULL,
|
||||
deleted_at TEXT NOT NULL, PRIMARY KEY (entity_type, entity_id)
|
||||
)`,
|
||||
`CREATE TABLE server_idempotency_keys (
|
||||
idempotency_key TEXT PRIMARY KEY, response_json TEXT NOT NULL, created_at TEXT NOT NULL
|
||||
)`,
|
||||
}
|
||||
for _, stmt := range legacySchema {
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
t.Fatalf("create legacy schema: %v", err)
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if _, err := db.Exec(`INSERT INTO server_devices (id, name, api_key, last_seen, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`, "legacy-device", "Legacy", "legacy-key", now, now); err != nil {
|
||||
t.Fatalf("insert legacy device: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO server_user_devices (user_id, device_id) VALUES (?, ?)`, "legacy-user", "legacy-device"); err != nil {
|
||||
t.Fatalf("insert legacy device owner: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO server_ops
|
||||
(op_id, server_sequence, device_id, entity_type, entity_id, op_type, payload_json, created_at, pushed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, "legacy-op", 1, "legacy-device", "file", "Docs/one.txt", "create", `{}`, now, now); err != nil {
|
||||
t.Fatalf("insert legacy operation: %v", err)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatalf("close legacy db: %v", err)
|
||||
}
|
||||
|
||||
s, err := NewServer(dbPath, filepath.Join(dir, "data"), &Config{Port: 47732})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer migration: %v", err)
|
||||
}
|
||||
assertLegacyOperationScope(t, s)
|
||||
if err := s.Close(); err != nil {
|
||||
t.Fatalf("close migrated db: %v", err)
|
||||
}
|
||||
|
||||
s, err = NewServer(dbPath, filepath.Join(dir, "data"), &Config{Port: 47732})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer repeated migration: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
assertLegacyOperationScope(t, s)
|
||||
}
|
||||
|
||||
func assertLegacyOperationScope(t *testing.T, s *Server) {
|
||||
t.Helper()
|
||||
var userID, vaultID string
|
||||
if err := s.db.QueryRow("SELECT user_id, vault_id FROM server_ops WHERE op_id=?", "legacy-op").Scan(&userID, &vaultID); err != nil {
|
||||
t.Fatalf("read migrated operation: %v", err)
|
||||
}
|
||||
if userID != "legacy-user" || vaultID != "legacy:legacy-user" {
|
||||
t.Fatalf("migrated scope = %q/%q, want legacy-user/legacy:legacy-user", userID, vaultID)
|
||||
}
|
||||
}
|
||||
|
||||
func newSyncHTTPServer(t *testing.T) (*Server, *httptest.Server) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
s, err := NewServer(filepath.Join(dir, "test.db"), filepath.Join(dir, "data"), &Config{Port: 47732})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
s.SetupRoutes()
|
||||
return s, httptest.NewServer(s.mux)
|
||||
}
|
||||
|
||||
func insertSyncUser(t *testing.T, s *Server, userID string) {
|
||||
t.Helper()
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if _, err := s.db.Exec(`INSERT INTO server_users
|
||||
(id, username, email, password_hash, confirmed, created_at)
|
||||
VALUES (?, ?, ?, ?, 1, ?)`, userID, userID, userID+"@example.test", "unused", now); err != nil {
|
||||
t.Fatalf("insert sync user %s: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertPairableUser(t *testing.T, s *Server, userID, username, password string) {
|
||||
t.Helper()
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("hash password: %v", err)
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if _, err := s.db.Exec(`INSERT INTO server_users
|
||||
(id, username, email, password_hash, confirmed, created_at)
|
||||
VALUES (?, ?, ?, ?, 1, ?)`, userID, username, username+"@example.test", string(passwordHash), now); err != nil {
|
||||
t.Fatalf("insert pairable user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertSyncDevice(t *testing.T, s *Server, deviceID, userID, token string) {
|
||||
t.Helper()
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if _, err := s.db.Exec(`INSERT INTO server_devices
|
||||
(id, name, api_key, token_hash, user_id, last_seen, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`, deviceID, deviceID, "legacy-"+deviceID, sha256Hex(token), userID, now, now); err != nil {
|
||||
t.Fatalf("insert sync device %s: %v", deviceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func syncPushBody(deviceID, opID, idempotencyKey string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"device_id": deviceID,
|
||||
"idempotency_key": idempotencyKey,
|
||||
"ops": []map[string]interface{}{
|
||||
{
|
||||
"op_id": opID,
|
||||
"entity_type": "file",
|
||||
"entity_id": "Docs/one.txt",
|
||||
"op_type": "create",
|
||||
"payload_json": `{"path":"Docs/one.txt","content":"hello"}`,
|
||||
"created_at": "2026-07-10T00:00:00Z",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type pairedSyncDevice struct {
|
||||
id string
|
||||
token string
|
||||
}
|
||||
|
||||
func pairSyncDevice(t *testing.T, serverURL, username, password, vaultID string) pairedSyncDevice {
|
||||
t.Helper()
|
||||
response := postJSON(t, serverURL+"/api/client/pair", "", map[string]interface{}{
|
||||
"login": username,
|
||||
"password": password,
|
||||
"device_name": "Desktop " + vaultID,
|
||||
"vault_id": vaultID,
|
||||
})
|
||||
return pairedSyncDevice{
|
||||
id: response["device_id"].(string),
|
||||
token: response["device_token"].(string),
|
||||
}
|
||||
}
|
||||
|
||||
func postWebReset(t *testing.T, s *Server, token, password string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
form := url.Values{
|
||||
"token": {token},
|
||||
"password": {password},
|
||||
"confirm": {password},
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/reset", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
response := httptest.NewRecorder()
|
||||
s.mux.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func postJSON(t *testing.T, url, token string, body interface{}) map[string]interface{} {
|
||||
t.Helper()
|
||||
status, out := postJSONStatus(t, url, token, body)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("post %s status = %d", url, status)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func postJSONStatus(t *testing.T, url, token string, body interface{}) (int, map[string]interface{}) {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
if err := json.NewEncoder(&b).Encode(body); err != nil {
|
||||
t.Fatalf("encode request: %v", err)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, url, &b)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("post %s: %v", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
return resp.StatusCode, out
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Server) smtpGet(key string) string {
|
||||
var val string
|
||||
s.db.QueryRow("SELECT value FROM server_smtp_config WHERE key=?", key).Scan(&val)
|
||||
return val
|
||||
}
|
||||
|
||||
func (s *Server) smtpSet(key, val string) error {
|
||||
_, err := s.db.Exec("INSERT OR REPLACE INTO server_smtp_config (key, value) VALUES (?, ?)", key, val)
|
||||
return err
|
||||
}
|
||||
|
||||
func sel(v, want string) string {
|
||||
if v == want {
|
||||
return " selected"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) smtpConnect(host, port, user, pass, security string) (*smtp.Client, error) {
|
||||
addr := net.JoinHostPort(host, port)
|
||||
switch security {
|
||||
case "tls":
|
||||
tlsCfg := &tls.Config{ServerName: host}
|
||||
conn, err := tls.Dial("tcp", addr, tlsCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tls dial: %w", err)
|
||||
}
|
||||
cl, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("smtp client: %w", err)
|
||||
}
|
||||
return cl, nil
|
||||
default:
|
||||
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
cl, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("smtp client: %w", err)
|
||||
}
|
||||
if security != "none" {
|
||||
if ok, _ := cl.Extension("STARTTLS"); ok {
|
||||
tlsCfg := &tls.Config{ServerName: host}
|
||||
if err := cl.StartTLS(tlsCfg); err != nil {
|
||||
cl.Close()
|
||||
return nil, fmt.Errorf("starttls: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cl, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) smtpSendMsg(cl *smtp.Client, user, pass, host, from, to string, msg []byte) error {
|
||||
if user != "" {
|
||||
auth := smtp.PlainAuth("", user, pass, host)
|
||||
if err := cl.Auth(auth); err != nil {
|
||||
return fmt.Errorf("auth: %w", err)
|
||||
}
|
||||
}
|
||||
if err := cl.Mail(from); err != nil {
|
||||
return fmt.Errorf("mail from: %w", err)
|
||||
}
|
||||
if err := cl.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("rcpt: %w", err)
|
||||
}
|
||||
w, err := cl.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("data: %w", err)
|
||||
}
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
w.Close()
|
||||
return fmt.Errorf("write: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("send: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) smtpSend(to, subject, body string) error {
|
||||
host := s.smtpGet("smtp_host")
|
||||
port := s.smtpGet("smtp_port")
|
||||
user := s.smtpGet("smtp_user")
|
||||
pass := s.smtpGet("smtp_pass")
|
||||
from := s.smtpGet("smtp_from")
|
||||
security := s.smtpGet("smtp_security")
|
||||
if host == "" || port == "" || from == "" {
|
||||
err := fmt.Errorf("SMTP not configured")
|
||||
log.Printf("smtp: %v (to=%s)", err, to)
|
||||
return err
|
||||
}
|
||||
log.Printf("smtp: sending to %s via %s:%s (security=%s)", to, host, port, security)
|
||||
msg := []byte("From: " + from + "\r\n" +
|
||||
"To: " + to + "\r\n" +
|
||||
"Subject: " + subject + "\r\n" +
|
||||
"MIME-Version: 1.0\r\n" +
|
||||
"Content-Type: text/plain; charset=UTF-8\r\n" +
|
||||
"\r\n" + body + "\r\n")
|
||||
cl, err := s.smtpConnect(host, port, user, pass, security)
|
||||
if err != nil {
|
||||
log.Printf("smtp: connect error: %v", err)
|
||||
return err
|
||||
}
|
||||
defer cl.Close()
|
||||
if err := s.smtpSendMsg(cl, user, pass, host, from, to, msg); err != nil {
|
||||
log.Printf("smtp: send error: %v", err)
|
||||
return err
|
||||
}
|
||||
log.Printf("smtp: sent OK to %s", to)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) smtpTest(host, port, user, pass, security, from, to string) error {
|
||||
if host == "" || port == "" || from == "" {
|
||||
return fmt.Errorf("SMTP not configured")
|
||||
}
|
||||
msg := []byte("From: " + from + "\r\nTo: " + to + "\r\nSubject: Test from Verstak Sync\r\n\r\nThis is a test email from Verstak Sync Server.\r\n")
|
||||
cl, err := s.smtpConnect(host, port, user, pass, security)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cl.Close()
|
||||
return s.smtpSendMsg(cl, user, pass, host, from, to, msg)
|
||||
}
|
||||
@@ -0,0 +1,823 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func userRegisterHTML(locale string) string {
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — %s</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
|
||||
h1{font-size:20px;margin:0 0 20px;text-align:center}
|
||||
p{text-align:center;font-size:12px;color:#666;margin-top:16px}
|
||||
a{color:#6366f1}
|
||||
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
|
||||
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
|
||||
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
|
||||
button:hover{background:#4f46e5}
|
||||
.hint{font-size:11px;color:#666;margin-top:-12px;margin-bottom:16px;text-align:center}
|
||||
</style>
|
||||
</head><body>
|
||||
<form method="POST">
|
||||
<h1>%s</h1>
|
||||
<label>%s</label>
|
||||
<input type="text" name="username" autofocus required>
|
||||
<label>%s</label>
|
||||
<input type="email" name="email" required>
|
||||
<label>%s</label>
|
||||
<input type="password" name="password" required minlength="8" maxlength="256">
|
||||
<button>%s</button>
|
||||
<p>%s <a href="/login">%s</a></p>
|
||||
</form>
|
||||
</body></html>`,
|
||||
t(locale, "server.registerTitle"),
|
||||
t(locale, "server.register"),
|
||||
t(locale, "server.username"),
|
||||
t(locale, "server.email"),
|
||||
t(locale, "server.password"),
|
||||
t(locale, "server.registerBtn"),
|
||||
t(locale, "server.alreadyHaveAccount"),
|
||||
t(locale, "server.loginBtn"),
|
||||
)
|
||||
}
|
||||
|
||||
func userLoginHTML(locale string) string {
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — %s</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
|
||||
h1{font-size:20px;margin:0 0 20px;text-align:center}
|
||||
p{text-align:center;font-size:12px;color:#666;margin-top:16px}
|
||||
a{color:#6366f1}
|
||||
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
|
||||
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
|
||||
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
|
||||
button:hover{background:#4f46e5}
|
||||
.links{margin-top:16px;text-align:center;font-size:12px;color:#666;line-height:1.8}
|
||||
.links a{color:#6366f1;text-decoration:none}
|
||||
.links a:hover{text-decoration:underline}</style>
|
||||
</head><body>
|
||||
<form method="POST">
|
||||
<h1>Verstak Sync</h1>
|
||||
<label>%s</label>
|
||||
<input type="text" name="username" autofocus required>
|
||||
<label>%s</label>
|
||||
<input type="password" name="password" required>
|
||||
<button>%s</button>
|
||||
<div class="links">
|
||||
<a href="/forgot">%s</a><br>
|
||||
<a href="/register">%s</a> · <a href="/admin/login">%s</a>
|
||||
</div>
|
||||
</form>
|
||||
</body></html>`,
|
||||
t(locale, "server.loginTitle"),
|
||||
t(locale, "server.usernameOrEmail"),
|
||||
t(locale, "server.password"),
|
||||
t(locale, "server.loginBtn"),
|
||||
t(locale, "server.forgotPassword"),
|
||||
t(locale, "server.registerBtn"),
|
||||
t(locale, "server.adminLink"),
|
||||
)
|
||||
}
|
||||
|
||||
func confirmedHTML(locale string) string {
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — %s</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px;text-align:center}
|
||||
h1{font-size:20px;margin:0 0 12px;color:#34d399}
|
||||
p{font-size:13px;color:#b0b0c0;margin:0 0 20px}
|
||||
a{color:#6366f1;text-decoration:none}
|
||||
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none}
|
||||
.btn:hover{background:#4f46e5}</style>
|
||||
</head><body>
|
||||
<div class="box">
|
||||
<h1>%s</h1>
|
||||
<p>%s</p>
|
||||
<a href="/login" class="btn">%s</a>
|
||||
</div>
|
||||
</body></html>`,
|
||||
t(locale, "server.emailConfirmed"),
|
||||
t(locale, "server.emailConfirmed"),
|
||||
t(locale, "server.emailConfirmedMessage"),
|
||||
t(locale, "server.loginBtn"),
|
||||
)
|
||||
}
|
||||
|
||||
func registrationOKHTML(locale string) string {
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — %s</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:360px;text-align:center}
|
||||
h1{font-size:20px;margin:0 0 12px;color:#34d399}
|
||||
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
|
||||
a{color:#6366f1;text-decoration:none}
|
||||
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none;margin-top:16px}
|
||||
.btn:hover{background:#4f46e5}</style>
|
||||
</head><body>
|
||||
<div class="box">
|
||||
<h1>%s</h1>
|
||||
<p>%s</p>
|
||||
<p>%s</p>
|
||||
<a href="/login" class="btn">%s</a>
|
||||
</div>
|
||||
</body></html>`,
|
||||
t(locale, "server.registerTitle"),
|
||||
t(locale, "server.registrationSuccess"),
|
||||
t(locale, "server.registrationEmailSent"),
|
||||
t(locale, "server.registrationCheckEmail"),
|
||||
t(locale, "server.loginBtn"),
|
||||
)
|
||||
}
|
||||
|
||||
func registrationAutoHTML(locale string) string {
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — %s</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:360px;text-align:center}
|
||||
h1{font-size:20px;margin:0 0 12px;color:#34d399}
|
||||
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
|
||||
a{color:#6366f1;text-decoration:none}
|
||||
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none;margin-top:16px}
|
||||
.btn:hover{background:#4f46e5}</style>
|
||||
</head><body>
|
||||
<div class="box">
|
||||
<h1>%s</h1>
|
||||
<p>%s</p>
|
||||
<a href="/login" class="btn">%s</a>
|
||||
</div>
|
||||
</body></html>`,
|
||||
t(locale, "server.registerTitle"),
|
||||
t(locale, "server.registrationSuccess"),
|
||||
t(locale, "server.registrationAutoMessage"),
|
||||
t(locale, "server.loginBtn"),
|
||||
)
|
||||
}
|
||||
|
||||
func forgotPasswordHTML(locale string) string {
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>%s</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
|
||||
h1{font-size:18px;margin:0 0 8px;text-align:center}
|
||||
p{font-size:12px;color:#888;text-align:center;margin:0 0 20px}
|
||||
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
|
||||
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
|
||||
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
|
||||
button:hover{background:#4f46e5}
|
||||
.links{text-align:center;font-size:12px;color:#666;margin-top:16px}
|
||||
.links a{color:#6366f1;text-decoration:none}
|
||||
.links a:hover{text-decoration:underline}</style>
|
||||
</head><body>
|
||||
<form method="POST">
|
||||
<h1>%s</h1>
|
||||
<p>%s</p>
|
||||
<label>%s</label>
|
||||
<input type="email" name="email" autofocus required>
|
||||
<button>%s</button>
|
||||
<div class="links"><a href="/login">%s</a></div>
|
||||
</form>
|
||||
</body></html>`,
|
||||
t(locale, "server.resetPasswordTitle"),
|
||||
t(locale, "server.resetPassword"),
|
||||
t(locale, "server.resetInstruction"),
|
||||
t(locale, "server.email"),
|
||||
t(locale, "server.sendLink"),
|
||||
t(locale, "server.backToLogin"),
|
||||
)
|
||||
}
|
||||
|
||||
func forgotSentHTML(locale string) string {
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>%s</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:360px;text-align:center}
|
||||
h1{font-size:18px;margin:0 0 12px;color:#34d399}
|
||||
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
|
||||
a{color:#6366f1;text-decoration:none}
|
||||
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none;margin-top:16px}
|
||||
.btn:hover{background:#4f46e5}</style>
|
||||
</head><body>
|
||||
<div class="box">
|
||||
<h1>%s</h1>
|
||||
<p>%s</p>
|
||||
<a href="/login" class="btn">%s</a>
|
||||
</div>
|
||||
</body></html>`,
|
||||
t(locale, "server.emailSentTitle"),
|
||||
t(locale, "server.emailSent"),
|
||||
t(locale, "server.emailSentMessage"),
|
||||
t(locale, "server.goHome"),
|
||||
)
|
||||
}
|
||||
|
||||
func resetPasswordHTML(locale string) string {
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>%s</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
|
||||
h1{font-size:18px;margin:0 0 20px;text-align:center}
|
||||
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
|
||||
input{width:100%%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
|
||||
button{width:100%%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
|
||||
button:hover{background:#4f46e5}
|
||||
.hint{font-size:11px;color:#666;text-align:center;margin-top:12px}</style>
|
||||
</head><body>
|
||||
<form method="POST">
|
||||
<h1>%s</h1>
|
||||
<input type="hidden" name="token" value="{TOKEN}">
|
||||
<label>%s</label>
|
||||
<input type="password" name="password" minlength="8" maxlength="256" required autofocus>
|
||||
<label>%s</label>
|
||||
<input type="password" name="confirm" minlength="8" maxlength="256" required>
|
||||
<button style="margin-top:8px">%s</button>
|
||||
</form>
|
||||
</body></html>`,
|
||||
t(locale, "server.newPasswordTitle"),
|
||||
t(locale, "server.newPassword"),
|
||||
t(locale, "server.password"),
|
||||
t(locale, "server.passwordConfirm"),
|
||||
t(locale, "server.save"),
|
||||
)
|
||||
}
|
||||
|
||||
func resetDoneHTML(locale string) string {
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — %s</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:360px;text-align:center}
|
||||
h1{font-size:18px;margin:0 0 12px;color:#34d399}
|
||||
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
|
||||
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none;margin-top:16px}
|
||||
.btn:hover{background:#4f46e5}</style>
|
||||
</head><body>
|
||||
<div class="box">
|
||||
<h1>%s</h1>
|
||||
<p>%s</p>
|
||||
<a href="/login" class="btn">%s</a>
|
||||
</div>
|
||||
</body></html>`,
|
||||
t(locale, "server.passwordChanged"),
|
||||
t(locale, "server.passwordChanged"),
|
||||
t(locale, "server.passwordChangedMessage"),
|
||||
t(locale, "server.loginBtn"),
|
||||
)
|
||||
}
|
||||
|
||||
func adminDashboardHTML(locale string, deviceCount, opsCount int, smtpHost, smtpPort, smtpUser, smtpFrom, smtpSecurity, srvURL string) string {
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>%[1]s</title>
|
||||
<style>
|
||||
body{font-family:sans-serif;background:#13131f;color:#e4e4ef;padding:24px;max-width:860px;margin:0 auto}
|
||||
a{color:#6366f1}
|
||||
h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}
|
||||
h2{margin-top:24px;font-size:16px}
|
||||
.stat{background:#1a1a28;border:1px solid #2a2a3c;padding:12px 16px;border-radius:8px;margin:8px 0}
|
||||
table{width:100%%;border-collapse:collapse;margin-top:8px}
|
||||
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c}
|
||||
th{font-size:12px;color:#888;text-transform:uppercase}
|
||||
.key-cell{max-width:360px;overflow:hidden;text-overflow:ellipsis;font-family:monospace;font-size:12px;color:#b0b0c0}
|
||||
.btn{font-family:inherit;font-size:12px;padding:6px 12px;border-radius:6px;border:1px solid #2a2a3c;background:#1a1a28;color:#ccc;cursor:pointer;display:inline-flex;align-items:center;gap:4px}
|
||||
.btn:hover{background:#222233}
|
||||
.btn-primary{background:#6366f1;border-color:#6366f1;color:#fff}
|
||||
.btn-primary:hover{background:#4f46e5}
|
||||
.btn-danger{color:#ff6b6b;border-color:#4a2222}
|
||||
.btn-danger:hover{background:#3a2222}
|
||||
.copy-btn{padding:2px 8px;font-size:11px;margin-left:6px}
|
||||
input{font-family:inherit;font-size:14px;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;margin:0;box-sizing:border-box}
|
||||
input:focus{outline:none;border-color:#6366f1}
|
||||
.form-row{display:flex;gap:8px;margin-bottom:8px;align-items:center}
|
||||
.form-row label{font-size:12px;color:#888;min-width:80px;flex-shrink:0}
|
||||
.form-row input{flex:1}
|
||||
.form-select{font-family:inherit;font-size:14px;padding:8px 32px 8px 12px;border:1px solid #2a2a3c;background-color:#13131f;color:#e4e4ef;border-radius:6px;flex:1;box-sizing:border-box;appearance:none;background-image:linear-gradient(45deg,transparent 50%%,#8b93aa 50%%),linear-gradient(135deg,#8b93aa 50%%,transparent 50%%);background-position:calc(100%% - 16px) 50%%,calc(100%% - 11px) 50%%;background-size:5px 5px,5px 5px;background-repeat:no-repeat}
|
||||
.form-select option{background:#13131f;color:#e4e4ef}
|
||||
.form-select:focus{outline:none;border-color:#6366f1;box-shadow:0 0 0 2px rgba(99,102,241,.24)}
|
||||
.toolbar{display:flex;gap:8px;margin:16px 0;flex-wrap:wrap}
|
||||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100}
|
||||
.modal{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:24px;width:420px;max-width:90vw;position:relative;max-height:80vh;overflow-y:auto}
|
||||
.modal h2{margin-top:0}
|
||||
.modal-close{position:absolute;top:10px;right:14px;font-size:20px;cursor:pointer;background:none;border:none;color:#888}
|
||||
.modal-close:hover{color:#e4e4ef}
|
||||
pre{background:#13131f;border:1px solid #2a2a3c;border-radius:8px;padding:12px;overflow-x:auto;white-space:pre-wrap}
|
||||
</style>
|
||||
</head><body>
|
||||
<h1>Verstak Sync Server</h1>
|
||||
<div style="display:flex;gap:20px;flex-wrap:wrap">
|
||||
<div class="stat" style="margin:0"><strong>%[2]s</strong> <span id="dev-count">%[40]d</span></div>
|
||||
<div class="stat" style="margin:0"><strong>%[3]s</strong> <span id="op-count">%[41]d</span></div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-primary" onclick="openSMTP()">%[15]s</button>
|
||||
<a href="/admin/users" style="text-decoration:none"><button class="btn" type="button">%[16]s</button></a>
|
||||
<button class="btn" onclick="openHealth()">%[17]s</button>
|
||||
</div>
|
||||
|
||||
<h2>%[4]s</h2>
|
||||
<div id="devices"></div>
|
||||
<script>
|
||||
fetch('/admin/api/devices').then(r=>r.json()).then(devices=>{
|
||||
const div=document.getElementById('devices')
|
||||
if(!devices.length){div.innerHTML='<p>%[5]s</p>';return}
|
||||
div.innerHTML='<table><tr><th>%[6]s</th><th>%[7]s</th><th>%[8]s</th><th>%[9]s</th><th>%[10]s</th><th></th></tr>'+
|
||||
devices.map(d=>{
|
||||
var status=d.revoked_at?'<span style="color:#ff6b6b">%[12]s</span>':'<span style="color:#34d399">%[11]s</span>'
|
||||
var ls=d.last_seen||'\u2014'
|
||||
var revBtn=''
|
||||
if(!d.revoked_at) revBtn='<button class="btn btn-danger" onclick="revokeDevice(\''+d.id+'\')">%[13]s</button>'
|
||||
return '<tr><td>'+d.name+'</td><td>'+(d.user||'\u2014')+'</td><td>'+(d.client_version||'\u2014')+'</td><td>'+status+'</td><td>'+ls+'</td><td>'+revBtn+'</td></tr>'
|
||||
}).join('')+'</table>'
|
||||
document.getElementById('dev-count').textContent=devices.length
|
||||
})
|
||||
fetch('/admin/api/stats').then(r=>r.json()).then(stats=>{
|
||||
document.getElementById('op-count').textContent=stats.ops||'0'
|
||||
})
|
||||
function revokeDevice(id){
|
||||
if(!confirm('%[31]s'))return
|
||||
fetch('/admin/api/keys/'+id,{method:'DELETE'}).then(()=>location.reload())
|
||||
}
|
||||
function openSMTP(){document.getElementById('smtp-modal').style.display='flex';document.getElementById('smtp-test-result').textContent=''}
|
||||
function closeSMTP(e){if(!e||e.target.id==='smtp-modal')document.getElementById('smtp-modal').style.display='none'}
|
||||
function openHealth(){var m=document.getElementById('health-modal');m.style.display='flex';document.getElementById('health-result').textContent='%[14]s';fetch('/api/v1/health').then(function(r){return r.text()}).then(function(t){document.getElementById('health-result').textContent=t})}
|
||||
function closeHealth(e){if(!e||e.target.id==='health-modal')document.getElementById('health-modal').style.display='none'}
|
||||
function testSMTP(){
|
||||
var f=document.querySelector('#smtp-modal form')
|
||||
var fd=new FormData(f)
|
||||
var obj={};for(var e of fd.entries()){obj[e[0]]=e[1]}
|
||||
var r=document.getElementById('smtp-test-result')
|
||||
r.textContent='%[29]s';r.style.color='#888'
|
||||
fetch('/admin/api/smtp/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(obj)}).then(function(r2){return r2.json()}).then(function(d){
|
||||
r.textContent=d.ok?'%[30]s':'\u2717 '+d.error
|
||||
r.style.color=d.ok?'#4ade80':'#ff6b6b'
|
||||
}).catch(function(e){r.textContent='\u2717 '+e;r.style.color='#ff6b6b'})
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="smtp-modal" class="modal-overlay" style="display:none" onclick="closeSMTP(event)">
|
||||
<div class="modal">
|
||||
<button class="modal-close" onclick="closeSMTP()">×</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,15 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
func genDeviceToken() (token, prefix, suffix string) {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
token = hex.EncodeToString(b)
|
||||
prefix = token[:8]
|
||||
suffix = token[len(token)-4:]
|
||||
return
|
||||
}
|
||||
+31
-15
@@ -1,21 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
OUTPUT_DIR="$REPO_ROOT/build/bin"
|
||||
BINARY="$OUTPUT_DIR/verstak-sync-server"
|
||||
|
||||
echo "=== verstak-sync-server build ==="
|
||||
if [ -f "$ROOT/go.mod" ]; then
|
||||
echo "[go build]"
|
||||
(cd "$ROOT" && go build ./...)
|
||||
echo " ✅ go build"
|
||||
if go test -list . ./... &>/dev/null 2>&1; then
|
||||
(cd "$ROOT" && go test -count=1 ./... 2>&1 || true)
|
||||
else
|
||||
echo " ℹ️ no tests"
|
||||
fi
|
||||
else
|
||||
echo " ℹ️ repository empty — no build target yet"
|
||||
echo " 📝 This repo will hold the Verstak sync server (CRDT-based)"
|
||||
echo "=== Verstak Sync Server Build ==="
|
||||
|
||||
# Check dependencies
|
||||
if ! command -v go &>/dev/null; then
|
||||
echo "❌ go not found"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ go $(go version | awk '{print $3}')"
|
||||
|
||||
# Clean
|
||||
rm -rf "$OUTPUT_DIR"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
# Build
|
||||
echo "→ Building server binary..."
|
||||
cd "$REPO_ROOT"
|
||||
go build -o "$BINARY" ./cmd/server
|
||||
echo "✅ Binary built: $BINARY"
|
||||
ls -lh "$BINARY"
|
||||
|
||||
# Tests
|
||||
echo "→ Running tests..."
|
||||
go test ./...
|
||||
echo "✅ Tests passed"
|
||||
|
||||
echo ""
|
||||
echo "✅ build passed (no-op)"
|
||||
echo "=== Build Complete ==="
|
||||
echo "Binary: $BINARY"
|
||||
echo "Run: $BINARY --help"
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# install.sh — установка Verstak Sync Server
|
||||
#
|
||||
# Использование:
|
||||
# sudo ./install.sh --port 47732 --user verstak --admin-user admin --admin-pass secret
|
||||
#
|
||||
# Флаги:
|
||||
# --port Порт сервера (по умолчанию: 47732)
|
||||
# --user Системный пользователь (по умолчанию: verstak)
|
||||
# --admin-user Логин администратора (обязательный)
|
||||
# --admin-pass Пароль администратора (обязательный)
|
||||
# --bin Путь к бинарнику (по умолчанию: ./verstak-sync-server)
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Defaults
|
||||
PORT="${VERSTAK_PORT:-47732}"
|
||||
USER="verstak"
|
||||
ADMIN_USER=""
|
||||
ADMIN_PASS=""
|
||||
BIN="./verstak-sync-server"
|
||||
|
||||
# Parse flags
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--port) PORT="$2"; shift 2 ;;
|
||||
--user) USER="$2"; shift 2 ;;
|
||||
--admin-user) ADMIN_USER="$2"; shift 2 ;;
|
||||
--admin-pass) ADMIN_PASS="$2"; shift 2 ;;
|
||||
--bin) BIN="$2"; shift 2 ;;
|
||||
*) echo "Unknown: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$ADMIN_USER" ] || [ -z "$ADMIN_PASS" ]; then
|
||||
echo "Usage: $0 --admin-user USER --admin-pass PASS [--port PORT] [--user USER]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "This script must be run as root (sudo)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Verstak Sync Server Installation ==="
|
||||
echo "Port: $PORT"
|
||||
echo "User: $USER"
|
||||
echo "Admin: $ADMIN_USER"
|
||||
echo "Binary: $BIN"
|
||||
echo ""
|
||||
|
||||
# 1. Create system user if not exists.
|
||||
if ! id -u "$USER" >/dev/null 2>&1; then
|
||||
echo "Creating user: $USER"
|
||||
useradd --system --no-create-home --shell /usr/sbin/nologin "$USER"
|
||||
fi
|
||||
|
||||
# 2. Install binary.
|
||||
INSTALL_DIR="/opt/verstak-sync-server"
|
||||
if [ ! -f "$BIN" ]; then
|
||||
echo "Binary not found: $BIN. Build it first: go build -o $BIN ./cmd/server/"
|
||||
exit 1
|
||||
fi
|
||||
echo "Installing binary to $INSTALL_DIR"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
cp "$BIN" "$INSTALL_DIR/verstak-sync-server"
|
||||
chmod 755 "$INSTALL_DIR/verstak-sync-server"
|
||||
|
||||
# 3. Create data directory.
|
||||
DATA_DIR="/var/lib/verstak-sync-server"
|
||||
echo "Creating $DATA_DIR"
|
||||
mkdir -p "$DATA_DIR"
|
||||
chown "$USER:$USER" "$DATA_DIR"
|
||||
chmod 750 "$DATA_DIR"
|
||||
|
||||
# 4. Set up admin user (first run).
|
||||
echo "Setting up admin user"
|
||||
"$INSTALL_DIR/verstak-sync-server" \
|
||||
--port "$PORT" \
|
||||
--data "$DATA_DIR" \
|
||||
--admin-user "$ADMIN_USER" \
|
||||
--admin-pass "$ADMIN_PASS" &
|
||||
SERVER_PID=$!
|
||||
sleep 2
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
|
||||
# 5. Install systemd unit.
|
||||
echo "Installing systemd unit"
|
||||
SERVICE_FILE="/etc/systemd/system/verstak-server.service"
|
||||
cp "$(dirname "$0")/../verstak-server.service" "$SERVICE_FILE"
|
||||
chmod 644 "$SERVICE_FILE"
|
||||
|
||||
# Set port in environment file.
|
||||
mkdir -p /etc/verstak-server
|
||||
echo "VERSTAK_PORT=$PORT" > /etc/verstak-server/env
|
||||
|
||||
# 6. Enable and start.
|
||||
echo "Enabling and starting service"
|
||||
systemctl daemon-reload
|
||||
systemctl enable verstak-server
|
||||
systemctl start verstak-server
|
||||
|
||||
echo ""
|
||||
echo "=== Installation complete ==="
|
||||
echo "Service: verstak-server"
|
||||
echo "Port: $PORT"
|
||||
echo "Admin: http://localhost:$PORT/admin/login"
|
||||
echo ""
|
||||
echo "Check status: systemctl status verstak-server"
|
||||
echo "View logs: journalctl -u verstak-server -f"
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
VERSION="${1:-}"
|
||||
REPOSITORY="mirivlad/verstak-sync-server"
|
||||
RELEASE_SCRIPT="${VERSTAK_RELEASE_SCRIPT:-$ROOT/scripts/release.sh}"
|
||||
RELEASE_DIR="${VERSTAK_RELEASE_DIR:-$ROOT/release}"
|
||||
GIT_BIN="${GIT_BIN:-git}"
|
||||
GH_BIN="${GH_BIN:-gh}"
|
||||
|
||||
cd "$ROOT"
|
||||
|
||||
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
|
||||
echo "usage: $0 <version>" >&2
|
||||
echo "example: $0 v0.1.0-alpha.1" >&2
|
||||
exit 2
|
||||
fi
|
||||
if ! command -v "$GH_BIN" >/dev/null; then
|
||||
echo "gh CLI is required to publish a GitHub Release" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$("$GIT_BIN" branch --show-current)" != "main" ]]; then
|
||||
echo "GitHub releases must be published from main" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$("$GIT_BIN" status --porcelain)" ]]; then
|
||||
echo "working tree must be clean before publishing a release" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$GH_BIN" auth status
|
||||
"$GIT_BIN" fetch origin main --tags
|
||||
HEAD="$("$GIT_BIN" rev-parse HEAD)"
|
||||
if [[ "$HEAD" != "$("$GIT_BIN" rev-parse origin/main)" ]]; then
|
||||
echo "local main must match origin/main before publishing a release" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$RELEASE_SCRIPT" "$VERSION"
|
||||
|
||||
ARCHIVE="$RELEASE_DIR/verstak-sync-server-linux-amd64-$VERSION.tar.gz"
|
||||
if [[ ! -f "$ARCHIVE" ]]; then
|
||||
echo "release archive not found: $ARCHIVE" >&2
|
||||
exit 1
|
||||
fi
|
||||
ASSETS=("$ARCHIVE")
|
||||
if [[ -f "$RELEASE_DIR/SHA256SUMS" ]]; then
|
||||
ASSETS+=("$RELEASE_DIR/SHA256SUMS")
|
||||
fi
|
||||
|
||||
if "$GIT_BIN" rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
|
||||
if [[ "$("$GIT_BIN" rev-parse "${VERSION}^{commit}")" != "$HEAD" ]]; then
|
||||
echo "existing tag $VERSION does not point at HEAD" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
"$GIT_BIN" tag -a "$VERSION" -m "Release $VERSION"
|
||||
"$GIT_BIN" push origin "refs/tags/$VERSION"
|
||||
fi
|
||||
|
||||
if "$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" >/dev/null 2>&1; then
|
||||
"$GH_BIN" release upload "$VERSION" "${ASSETS[@]}" --repo "$REPOSITORY" --clobber
|
||||
else
|
||||
RELEASE_OPTIONS=(--generate-notes --verify-tag)
|
||||
if [[ "$VERSION" == *-* ]]; then
|
||||
RELEASE_OPTIONS+=(--prerelease)
|
||||
else
|
||||
RELEASE_OPTIONS+=(--latest)
|
||||
fi
|
||||
"$GH_BIN" release create "$VERSION" "${ASSETS[@]}" \
|
||||
--repo "$REPOSITORY" \
|
||||
--title "Verstak Sync Server $VERSION" \
|
||||
"${RELEASE_OPTIONS[@]}"
|
||||
fi
|
||||
|
||||
echo "GitHub release:"
|
||||
"$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" --json url --jq .url
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
VERSION="${1:-}"
|
||||
|
||||
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
|
||||
echo "usage: $0 <version>" >&2
|
||||
echo "example: $0 v0.1.0-alpha.1" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ "$(go env GOOS)" != "linux" || "$(go env GOARCH)" != "amd64" ]]; then
|
||||
echo "sync-server release artifacts are currently built for linux/amd64" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== verstak sync server release $VERSION ==="
|
||||
"$ROOT/scripts/build.sh"
|
||||
|
||||
RELEASE_ROOT="$ROOT/release"
|
||||
STAGING="$RELEASE_ROOT/verstak-sync-server-$VERSION-linux-amd64"
|
||||
ARCHIVE="$RELEASE_ROOT/verstak-sync-server-linux-amd64-$VERSION.tar.gz"
|
||||
rm -rf "$STAGING" "$ARCHIVE"
|
||||
mkdir -p "$STAGING/scripts"
|
||||
|
||||
cp "$ROOT/build/bin/verstak-sync-server" "$STAGING/verstak-sync-server"
|
||||
cp "$ROOT/README.md" "$ROOT/LICENSE" "$ROOT/verstak-server.service" "$STAGING/"
|
||||
cp "$ROOT/scripts/install.sh" "$STAGING/scripts/install.sh"
|
||||
tar -C "$RELEASE_ROOT" -czf "$ARCHIVE" "$(basename "$STAGING")"
|
||||
(cd "$RELEASE_ROOT" && sha256sum "$(basename "$ARCHIVE")" > SHA256SUMS)
|
||||
|
||||
echo "release archive: $ARCHIVE"
|
||||
echo "checksums: $RELEASE_ROOT/SHA256SUMS"
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PUBLISHER="$ROOT/scripts/publish-github-release.sh"
|
||||
VERSION="v0.0.0-test"
|
||||
REPOSITORY="mirivlad/verstak-sync-server"
|
||||
ASSET_NAME="verstak-sync-server-linux-amd64-${VERSION}.tar.gz"
|
||||
STALE_ASSET="verstak-sync-server-linux-amd64-v0.0.0-stale.tar.gz"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
if [[ ! -x "$PUBLISHER" ]]; then
|
||||
echo "publisher is missing or not executable: $PUBLISHER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$WORK/bin" "$WORK/release" "$WORK/state"
|
||||
LOG="$WORK/log"
|
||||
export LOG
|
||||
|
||||
cat > "$WORK/release.sh" <<'SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'release:%s\n' "$1" >> "$LOG"
|
||||
printf 'archive\n' > "$VERSTAK_RELEASE_DIR/verstak-sync-server-linux-amd64-$1.tar.gz"
|
||||
printf 'checksum\n' > "$VERSTAK_RELEASE_DIR/SHA256SUMS"
|
||||
SCRIPT
|
||||
chmod +x "$WORK/release.sh"
|
||||
|
||||
printf 'stale archive\n' > "$WORK/release/$STALE_ASSET"
|
||||
|
||||
cat > "$WORK/bin/git" <<'SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$PWD" != "${EXPECTED_ROOT:?}" ]]; then
|
||||
echo "publisher did not enter repository root: $PWD" >&2
|
||||
exit 1
|
||||
fi
|
||||
case "${1:-}" in
|
||||
status) exit 0 ;;
|
||||
branch) echo main ;;
|
||||
fetch) printf 'fetch\n' >> "$LOG" ;;
|
||||
rev-parse)
|
||||
if [[ "${2:-}" == "-q" ]]; then
|
||||
if [[ -f "$TEST_STATE/tag" ]]; then echo test-commit; exit 0; fi
|
||||
exit 1
|
||||
fi
|
||||
echo test-commit
|
||||
;;
|
||||
tag) touch "$TEST_STATE/tag"; printf 'tag:%s\n' "${3:-}" >> "$LOG" ;;
|
||||
push) printf 'push:%s:%s\n' "${2:-}" "${3:-}" >> "$LOG" ;;
|
||||
*) echo "unexpected git invocation: $*" >&2; exit 1 ;;
|
||||
esac
|
||||
SCRIPT
|
||||
chmod +x "$WORK/bin/git"
|
||||
|
||||
cat > "$WORK/bin/gh" <<'SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
case "${1:-}:${2:-}" in
|
||||
auth:status) printf 'auth\n' >> "$LOG" ;;
|
||||
release:view)
|
||||
if [[ -f "$TEST_STATE/release" ]]; then echo https://github.example/release; else exit 1; fi
|
||||
;;
|
||||
release:create) touch "$TEST_STATE/release"; printf 'create:%s\n' "$*" >> "$LOG" ;;
|
||||
release:upload) printf 'upload:%s\n' "$*" >> "$LOG" ;;
|
||||
*) echo "unexpected gh invocation: $*" >&2; exit 1 ;;
|
||||
esac
|
||||
SCRIPT
|
||||
chmod +x "$WORK/bin/gh"
|
||||
|
||||
run_publisher() {
|
||||
VERSTAK_RELEASE_SCRIPT="$WORK/release.sh" \
|
||||
VERSTAK_RELEASE_DIR="$WORK/release" \
|
||||
GIT_BIN="$WORK/bin/git" \
|
||||
GH_BIN="$WORK/bin/gh" \
|
||||
EXPECTED_ROOT="$ROOT" \
|
||||
TEST_STATE="$WORK/state" \
|
||||
"$PUBLISHER" "$VERSION"
|
||||
}
|
||||
|
||||
run_publisher
|
||||
grep -Fqx "release:$VERSION" "$LOG"
|
||||
grep -Fqx "tag:$VERSION" "$LOG"
|
||||
grep -Fqx "push:origin:refs/tags/$VERSION" "$LOG"
|
||||
grep -F "release create $VERSION" "$LOG" >/dev/null
|
||||
grep -F "$ASSET_NAME" "$LOG" >/dev/null
|
||||
grep -F "SHA256SUMS" "$LOG" >/dev/null
|
||||
grep -F -- "--prerelease" "$LOG" >/dev/null
|
||||
if grep -F -- "--latest" "$LOG" >/dev/null; then
|
||||
echo "alpha release was incorrectly marked latest" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -F "$STALE_ASSET" "$LOG" >/dev/null; then
|
||||
echo "publisher uploaded an archive from a different release version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_publisher
|
||||
grep -F "release upload $VERSION" "$LOG" >/dev/null
|
||||
|
||||
echo "GitHub release publisher test passed"
|
||||
@@ -0,0 +1,24 @@
|
||||
[Unit]
|
||||
Description=Verstak Sync Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=verstak
|
||||
Group=verstak
|
||||
WorkingDirectory=/opt/verstak-sync-server
|
||||
Environment=VERSTAK_PORT=47732
|
||||
EnvironmentFile=-/etc/verstak-server/env
|
||||
ExecStart=/opt/verstak-sync-server/verstak-sync-server --port ${VERSTAK_PORT} --data /var/lib/verstak-sync-server
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
StateDirectory=verstak-sync-server
|
||||
RuntimeDirectory=verstak-sync-server
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user