stabilization: server.go split + i18n templates + frontend localization

cmd/verstak-server/server.go (2838→127 строк): разделён на 12 файлов
- config.go, tokens.go, schema.go
- server.go (только struct + NewServer + ListenAndServe)
- routes.go, middleware.go, smtp.go
- handlers_api.go, handlers_user.go, handlers_web_user.go, handlers_admin.go
- templates.go (конвертирован в функции с i18n.T())

frontend: все русские строки заменены на t() вызовы
- App.svelte, FileTreeRow.svelte, ConfirmModal.svelte
- FilePreviewModal.svelte, fileUtils.js

core: gofmt по всему проекту

Все сборки (CLI, server, gui, frontend), go vet, go test проходят.
check-i18n.sh: frontend чист, Go-файлы с кириллицей — только тесты/легаси.
This commit is contained in:
mirivlad 2026-06-02 11:08:29 +08:00
parent 3089d777a8
commit 2fa583d157
33 changed files with 3276 additions and 3067 deletions

View File

@ -0,0 +1,92 @@
package main
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sync"
"golang.org/x/crypto/bcrypt"
"gopkg.in/yaml.v3"
)
var passwordRE = regexp.MustCompile(`^[A-Za-z0-9]+$`)
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()
data, err := yaml.Marshal(c)
if err != nil {
return err
}
return os.WriteFile(c.path, data, 0640)
}
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)}
// Replace existing or append.
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)
}

View File

@ -0,0 +1,501 @@
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
"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(adminLoginHTML("ru")))
case "POST":
if err := r.ParseForm(); err != nil {
jsonErr(w, 400, "bad form")
return
}
user := r.FormValue("username")
pass := r.FormValue("password")
if !s.cfg.CheckAdmin(user, pass) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(401)
w.Write([]byte("<html><body><h1>401 Unauthorized</h1><a href='/admin/login'>Try again</a></body></html>"))
return
}
tok := s.tokens.Create()
http.SetCookie(w, &http.Cookie{
Name: "session", Value: tok, Path: "/admin",
HttpOnly: true, SameSite: http.SameSiteLaxMode,
MaxAge: 86400,
})
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
default:
jsonErr(w, 405, "method not allowed")
}
}
func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Fetch data for dashboard.
var deviceCount, opsCount int
s.db.QueryRow("SELECT COUNT(*) FROM server_devices").Scan(&deviceCount)
s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&opsCount)
// Load SMTP config for display.
smtpHost := s.smtpGet("smtp_host")
smtpPort := s.smtpGet("smtp_port")
smtpUser := s.smtpGet("smtp_user")
smtpFrom := s.smtpGet("smtp_from")
smtpSecurity := s.smtpGet("smtp_security")
srvURL := s.smtpGet("server_url")
html := `<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Verstak Sync Admin</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}
.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>Устройств:</strong> <span id="dev-count">0</span></div>
<div class="stat" style="margin:0"><strong>Операций:</strong> <span id="op-count">0</span></div>
</div>
<div class="toolbar">
<button class="btn btn-primary" onclick="openSMTP()">Настройка SMTP</button>
<a href="/admin/users" style="text-decoration:none"><button class="btn" type="button">Пользователи</button></a>
<button class="btn" onclick="openHealth()">Health check</button>
</div>
<h2>Устройства</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>Нет устройств</p>';return}
div.innerHTML='<table><tr><th>Устройство</th><th>Пользователь</th><th>Версия</th><th>Статус</th><th>Активность</th><th></th></tr>'+
devices.map(d=>{
var status=d.revoked_at?'<span style="color:#ff6b6b">Отозвано</span>':'<span style="color:#34d399">Активно</span>'
var ls=d.last_seen||'—'
var revBtn=''
if(!d.revoked_at) revBtn='<button class="btn btn-danger" onclick="revokeDevice(\''+d.id+'\')">Отозвать</button>'
return '<tr><td>'+d.name+'</td><td>'+(d.user||'—')+'</td><td>'+(d.client_version||'—')+'</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('Отозвать устройство?'))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='Загрузка...';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=' Тестируем...';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?' Тест пройден':' '+d.error
r.style.color=d.ok?'#4ade80':'#ff6b6b'
}).catch(function(e){r.textContent=' '+e;r.style.color='#ff6b6b'})
}
</script>
<div id="smtp-modal" class="modal-overlay" style="display:none" onclick="closeSMTP(event)">
<div class="modal">
<button class="modal-close" onclick="closeSMTP()">&times;</button>
<h2>SMTP (для писем)</h2>
<form action="/admin/api/smtp" method="POST">
<div class="form-row"><label>Сервер</label><input name="smtp_host" value="` + smtpHost + `" placeholder="smtp.example.com"></div>
<div class="form-row"><label>Порт</label><input name="smtp_port" value="` + smtpPort + `" placeholder="587"></div>
<div class="form-row"><label>Тип</label><select name="smtp_security" style="font-family:inherit;font-size:14px;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;flex:1;box-sizing:border-box">
<option value="starttls"` + sel(smtpSecurity, "starttls") + `>STARTTLS</option>
<option value="tls"` + sel(smtpSecurity, "tls") + `>TLS</option>
<option value="none"` + sel(smtpSecurity, "none") + `>Без шифрования</option>
</select></div>
<div class="form-row"><label>Логин</label><input name="smtp_user" value="` + smtpUser + `" placeholder="user@example.com"></div>
<div class="form-row"><label>Пароль</label><input type="password" name="smtp_pass" placeholder="••••••••"></div>
<div class="form-row"><label>От кого</label><input name="smtp_from" value="` + smtpFrom + `" placeholder="noreply@example.com"></div>
<div class="form-row"><label>URL сервера</label><input name="server_url" value="` + srvURL + `" placeholder="https://example.com:47732"></div>
<div style="margin-top:12px;display:flex;gap:8px;align-items:center">
<button class="btn btn-primary">Сохранить SMTP</button>
<button class="btn" type="button" onclick="testSMTP()">Test</button>
<span id="smtp-test-result" style="font-size:12px"></span>
</div>
</form>
</div>
</div>
<div id="health-modal" class="modal-overlay" style="display:none" onclick="closeHealth(event)">
<div class="modal">
<button class="modal-close" onclick="closeHealth()">&times;</button>
<h2>Health check</h2>
<pre id="health-result">Загрузка...</pre>
</div>
</div>
_ = smtpURL
_ = smtpUser
_ = smtpFrom
_ = smtpSecurity
_ = smtpHost
_ = smtpPort
</body></html>`
w.Write([]byte(html))
}
func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(adminUsersHTML("ru")))
}
func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(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.requireAdmin(w, r) {
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": "host, port and from required"})
return
}
if err := s.smtpTest(host, port, user, pass, security, from, to); err != nil {
jsonOK(w, map[string]interface{}{"ok": false, "error": err.Error()})
return
}
jsonOK(w, map[string]interface{}{"ok": true})
}
func (s *Server) handleAdminAPI(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
path := strings.TrimPrefix(r.URL.Path, "/admin")
switch {
case path == "/api/devices" && r.Method == "GET":
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 {
jsonErr(w, 500, err.Error())
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)
case path == "/api/keys" && r.Method == "GET":
rows, err := s.db.Query("SELECT id, name, api_key FROM server_devices ORDER BY created_at")
if err != nil {
jsonErr(w, 500, err.Error())
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)
case path == "/api/keys" && r.Method == "POST":
if err := r.ParseForm(); err != nil {
jsonErr(w, 400, "bad form")
return
}
name := r.FormValue("name")
if name == "" {
jsonErr(w, 400, "name required")
return
}
b := make([]byte, 20)
rand.Read(b)
apiKey := hex.EncodeToString(b)
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(
"INSERT INTO server_devices (id, name, api_key, last_seen, created_at) VALUES (?, ?, ?, ?, ?)",
apiKey[:12], name, apiKey, now, now,
)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
case strings.HasPrefix(path, "/api/keys/") && r.Method == "DELETE":
id := strings.TrimPrefix(path, "/api/keys/")
_, err := s.db.Exec("DELETE FROM server_devices WHERE id=?", id)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
s.db.Exec("DELETE FROM server_user_devices WHERE device_id=?", id)
jsonOK(w, map[string]string{"status": "deleted"})
case path == "/api/smtp" && r.Method == "POST":
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)
case path == "/api/users" && r.Method == "GET":
filter := r.URL.Query().Get("filter")
sort := r.URL.Query().Get("sort")
order := r.URL.Query().Get("order")
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
perPage, _ := strconv.Atoi(r.URL.Query().Get("per_page"))
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
}
// Count total.
var total int
countSQL := "SELECT COUNT(*) FROM server_users u" + where
s.db.QueryRow(countSQL, args...).Scan(&total)
// Fetch page.
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 {
jsonErr(w, 500, err.Error())
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,
})
case strings.HasPrefix(path, "/api/users/") && r.Method == "POST":
sub := strings.TrimPrefix(path, "/api/users/")
if strings.HasSuffix(sub, "/block") {
id := strings.TrimSuffix(sub, "/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(sub, "/reset-password") {
id := strings.TrimSuffix(sub, "/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 {
jsonErr(w, 500, err.Error())
return
}
jsonOK(w, map[string]interface{}{"status": "ok", "new_password": newPass})
return
}
if strings.HasSuffix(sub, "/edit") {
id := strings.TrimSuffix(sub, "/edit")
id = strings.TrimSuffix(id, "/")
var req struct {
Username string `json:"username"`
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "bad json")
return
}
if req.Username == "" || req.Email == "" {
jsonErr(w, 400, "username and email required")
return
}
_, err := s.db.Exec("UPDATE server_users SET username=?, email=? WHERE id=?", req.Username, strings.ToLower(req.Email), id)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonOK(w, map[string]interface{}{"status": "ok"})
return
}
jsonErr(w, 404, "unknown action")
case strings.HasPrefix(path, "/api/users/") && r.Method == "DELETE":
id := strings.TrimPrefix(path, "/api/users/")
id = strings.TrimSuffix(id, "/")
// Get user devices to delete.
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"})
default:
jsonErr(w, 404, "not found")
}
}

View File

@ -0,0 +1,325 @@
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"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"`
}
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
}
if req.DeviceName == "" {
req.DeviceName = "unknown"
}
// Look up user.
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
}
// Generate device.
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, client_version, last_ip, last_seen, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
deviceID, req.DeviceName, hex.EncodeToString(apiKey), tokenHash, prefix, suffix,
userID, req.ClientVersion, ip, now, now)
if err != nil {
jsonErr(w, 500, err.Error())
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
}
userID, ok := s.requireUserWeb(w, r)
if !ok {
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
}
// Verify password.
var pwHash string
err := s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", userID).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
}
// Verify device belongs to user.
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 != userID {
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", userID, req.DeviceID, r.RemoteAddr, "device revoked via web dashboard")
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"`
}
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
}
// Look up user by username or email.
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, last_seen, created_at) VALUES (?, ?, ?, ?, ?)",
deviceID, req.Name, apiKey, now, now,
)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
// Link device to user.
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,
})
}

View File

@ -0,0 +1,539 @@
package main
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"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
}
jsonErr(w, 500, err.Error())
return
}
// Confirmation token.
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)
// Try to send email.
host := s.smtpGet("smtp_host")
if host != "" {
srvURL := s.smtpGet("server_url")
var confirmURL string
if srvURL != "" {
confirmURL = fmt.Sprintf("%s/confirm?token=%s", srvURL, tokenStr)
} else {
confirmURL = fmt.Sprintf("/api/v1/auth/confirm?token=%s", tokenStr)
}
body := fmt.Sprintf("Welcome to Verstak Sync!\n\nPlease confirm your email by clicking:\n%s\n\nIf you did not register, ignore this message.", confirmURL)
if err := s.smtpSend(req.Email, "Confirm your Verstak Sync account", body); err != nil {
log.Printf("register: failed to send confirm email: %v", err)
}
} else {
log.Printf("register: SMTP not configured, 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)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(confirmedHTML("ru")))
}
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)
host := s.smtpGet("smtp_host")
if host != "" {
srvURL := s.smtpGet("server_url")
resetURL := fmt.Sprintf("/api/v1/auth/reset?token=%s", tokenStr)
if srvURL != "" {
resetURL = fmt.Sprintf("%s/api/v1/auth/reset?token=%s", srvURL, tokenStr)
}
body := fmt.Sprintf("Reset your Verstak Sync password:\n\n%s\n\nThis link expires in 1 hour.", resetURL)
s.smtpSend(req.Email, "Verstak Sync password reset", body)
}
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
}
var userID, expiresAt string
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='reset'",
req.Token).Scan(&userID, &expiresAt)
if err != nil {
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
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
jsonErr(w, 500, "internal error")
return
}
s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), userID)
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", req.Token)
jsonOK(w, map[string]string{"status": "password reset"})
}
func (s *Server) handleUserDevices(w http.ResponseWriter, r *http.Request) {
userID, ok := s.requireUser(w, r)
if !ok {
return
}
if r.Method != "GET" {
jsonErr(w, 405, "GET required")
return
}
rows, err := s.db.Query(`
SELECT d.id, d.name, d.last_seen, 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`, userID)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
defer rows.Close()
type deviceDTO struct {
ID string `json:"id"`
Name string `json:"name"`
LastSeen string `json:"last_seen"`
CreatedAt string `json:"created_at"`
}
var devices []deviceDTO
for rows.Next() {
var d deviceDTO
var lastSeen sql.NullString
if err := rows.Scan(&d.ID, &d.Name, &lastSeen, &d.CreatedAt); err != nil {
continue
}
d.LastSeen = lastSeen.String
devices = append(devices, d)
}
if devices == nil {
devices = []deviceDTO{}
}
jsonOK(w, map[string]interface{}{"devices": devices})
}
func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
if !s.requireAPIKey(w, r) {
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: "+err.Error())
return
}
// Idempotency: if request-level key provided, check for cached response.
if req.IdempotencyKey != "" {
var cachedJSON string
err := s.db.QueryRow("SELECT response_json FROM server_idempotency_keys WHERE idempotency_key=?", 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
}
// Conflict detection: check if another device already created ops for this entity
// with a server_sequence higher than what this client last saw.
if op.LastSeenServerSeq > 0 {
conflictRows, err := s.db.Query(`
SELECT op_id, device_id, op_type, server_sequence FROM server_ops
WHERE entity_type=? AND entity_id=? AND device_id!=?
AND server_sequence > ? AND op_type != 'delete'
ORDER BY server_sequence`, op.EntityType, op.EntityID, req.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, device_id, entity_type, entity_id, op_type, payload_json, idempotency_key, client_sequence, last_seen_server_seq, created_at, pushed_at)
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
op.OpID, req.DeviceID, op.EntityType, op.EntityID, op.OpType, op.PayloadJSON,
req.IdempotencyKey, op.ClientSequence, op.LastSeenServerSeq, op.CreatedAt, now,
)
if err != nil {
continue
}
n, _ := res.RowsAffected()
if n == 0 {
continue // duplicate op_id
}
seqRes, err := s.db.Exec("INSERT INTO server_revisions (op_id, device_id) VALUES (?, ?)", op.OpID, req.DeviceID)
if err != nil {
continue
}
seq, _ := seqRes.LastInsertId()
s.db.Exec("UPDATE server_ops SET server_sequence=? WHERE op_id=?", seq, op.OpID)
if op.OpType == "delete" {
s.db.Exec(`INSERT OR REPLACE INTO server_tombstones (entity_type, entity_id, op_id, deleted_at) VALUES (?, ?, ?, ?)`,
op.EntityType, op.EntityID, op.OpID, now)
}
accepted = append(accepted, op.OpID)
}
resp := map[string]interface{}{
"accepted": accepted,
"count": len(accepted),
"conflicts": conflicts,
}
// Cache response for idempotency.
if req.IdempotencyKey != "" {
if respJSON, err := json.Marshal(resp); err == nil {
s.db.Exec("INSERT OR IGNORE INTO server_idempotency_keys (idempotency_key, response_json, created_at) VALUES (?, ?, ?)",
req.IdempotencyKey, string(respJSON), now)
}
}
jsonOK(w, resp)
}
func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
if !s.requireAPIKey(w, r) {
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").Scan(&serverSeq)
rows, err := s.db.Query(`
SELECT op_id, server_sequence, device_id, entity_type, entity_id, op_type, payload_json, created_at
FROM server_ops
WHERE server_sequence > ? AND server_sequence IS NOT NULL
ORDER BY server_sequence`, req.SinceSequence)
if err != nil {
jsonErr(w, 500, err.Error())
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"`
}
var 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) {
if !s.requireAPIKey(w, r) {
return
}
switch r.Method {
case "POST":
// Upload: accept multipart file, store by SHA-256.
if err := r.ParseMultipartForm(200 << 20); err != nil {
jsonErr(w, 400, "multipart error: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
jsonErr(w, 400, "file field required")
return
}
defer file.Close()
// Read content and compute SHA-256.
data, err := io.ReadAll(file)
if err != nil {
jsonErr(w, 500, "read error")
return
}
hash := sha256.Sum256(data)
shaHex := hex.EncodeToString(hash[:])
// Store at blobs/ab/cd/sha256.
blobDir := filepath.Join(s.blobsDir, shaHex[:2], shaHex[2:4])
if err := os.MkdirAll(blobDir, 0750); err != nil {
jsonErr(w, 500, "mkdir error")
return
}
blobPath := filepath.Join(blobDir, shaHex)
if err := os.WriteFile(blobPath, data, 0640); err != nil {
jsonErr(w, 500, "write error")
return
}
_ = header
// Record in blobs table.
now := time.Now().UTC().Format(time.RFC3339)
s.db.Exec("INSERT OR IGNORE INTO server_blobs (sha256, size, created_at) VALUES (?, ?, ?)",
shaHex, len(data), now)
jsonOK(w, map[string]interface{}{
"sha256": shaHex,
"size": len(data),
})
case "GET":
// Download: GET /api/v1/blobs/{sha256}
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")
}
}

View File

@ -0,0 +1,380 @@
package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"net/http"
"strings"
"time"
"verstak/internal/i18n"
"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) {
switch r.Method {
case "GET":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(userRegisterHTML("ru")))
case "POST":
if err := r.ParseForm(); err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(400)
w.Write([]byte("<html><body><h1>400 Bad request</h1><a href='/register'>Back</a></body></html>"))
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("<html><body><h1>All fields required</h1><a href='/register'>Back</a></body></html>"))
return
}
if err := validatePassword(password); err != "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(400)
w.Write([]byte("<html><body><h1>" + err + "</h1><a href='/register'>Back</a></body></html>"))
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
w.WriteHeader(500)
w.Write([]byte("<html><body><h1>Internal error</h1><a href='/register'>Back</a></body></html>"))
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("<html><body><h1>Username or email already taken</h1><a href='/register'>Back</a></body></html>"))
} else {
w.WriteHeader(500)
w.Write([]byte("<html><body><h1>" + err.Error() + "</h1><a href='/register'>Back</a></body></html>"))
}
return
}
// Confirmation token.
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)
// Try to send email.
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("Welcome to Verstak Sync!\n\nPlease confirm your email by clicking:\n%s\n\nIf you did not register, ignore this message.", confirmURL)
if err := s.smtpSend(email, "Confirm your Verstak Sync account", 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("ru")
if host == "" {
regMsg = registrationAutoHTML("ru")
}
w.Write([]byte(regMsg))
default:
jsonErr(w, 405, "method not allowed")
}
}
func (s *Server) handleUserWebForgot(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(forgotPasswordHTML("ru")))
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("ru", i18n.T("ru", "common.error"), i18n.T("ru", "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("ru")))
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("Reset your Verstak Sync password:\n\n%s\n\nThis link expires in 1 hour.", resetURL)
if err := s.smtpSend(email, "Verstak Sync password reset", 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("ru")))
default:
jsonErr(w, 405, "method not allowed")
}
}
func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
token := r.URL.Query().Get("token")
if token == "" {
http.Redirect(w, r, "/forgot", http.StatusFound)
return
}
// Validate token exists and not expired before showing form.
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")
html := strings.ReplaceAll(resetPasswordHTML("ru"), "{TOKEN}", token)
w.Write([]byte(html))
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("ru", i18n.T("ru", "common.error"), i18n.T("ru", "server.allFieldsRequired"), "/forgot")))
return
}
if err := validatePassword(newPass); err != "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(errorPageHTML("ru", i18n.T("ru", "common.error"), err, "/reset?token="+token)))
return
}
if newPass != confirm {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(errorPageHTML("ru", i18n.T("ru", "common.error"), i18n.T("ru", "server.passwordsDoNotMatch"), "/reset?token="+token)))
return
}
var userID string
err := s.db.QueryRow("SELECT user_id FROM server_email_tokens WHERE token=? AND purpose='reset'", token).Scan(&userID)
if err != nil {
http.Redirect(w, r, "/forgot", http.StatusFound)
return
}
hash, _ := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost)
s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), userID)
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", token)
log.Printf("reset: user %s reset password", userID)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(resetDoneHTML("ru")))
default:
jsonErr(w, 405, "method not allowed")
}
}
func (s *Server) handleUserWebLogin(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(userLoginHTML("ru")))
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("<html><body><h1>401 Unauthorized</h1><a href='/login'>Try again</a></body></html>"))
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) {
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)
// Get devices with status info.
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'>Нет подключённых устройств.<br>Подключите устройство из desktop-клиента Verstak.</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'>Активно</span>"
revokeBtn := fmt.Sprintf(`<button class="btn btn-danger btn-sm" onclick="revokeDevice('%s')">Отозвать</button>`, d.ID)
if d.RevokedAt != "" {
status = "<span style='color:#ff6b6b'>Отозвано</span>"
revokeBtn = ""
}
deviceRows += fmt.Sprintf(`<tr>
<td>%s</td>
<td>%s</td>
<td>%s</td>
<td>%s</td>
<td>%s %s</td>
</tr>`, d.Name, status, created, ls, d.ClientVer, revokeBtn)
}
}
html := 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;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>%s · <a href="/logout">Выйти</a></span>
</div>
<h2>Устройства</h2>
<table><tr><th>Устройство</th><th>Статус</th><th>Подключено</th><th>Активность</th><th>Версия</th></tr>%s</table>
<div style="margin-top:24px;padding:16px;background:#1a1a28;border:1px solid #2a2a3c;border-radius:8px">
<h2 style="margin-top:0">Подключить новое устройство</h2>
<p style="font-size:13px;color:#888">Откройте desktop-клиент Verstak, перейдите в настройки синхронизации и введите URL сервера, логин и пароль.</p>
</div>
<script>
function revokeDevice(id){
if(!confirm('Отозвать устройство? Оно перестанет синхронизироваться.'))return
var pw=prompt('Введите ваш пароль для подтверждения:')
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, username, deviceRows)
w.Write([]byte(html))
}
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)
}

View File

@ -0,0 +1,110 @@
package main
import (
"database/sql"
"encoding/json"
"net/http"
"strings"
"time"
)
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 (s *Server) requireAPIKey(w http.ResponseWriter, r *http.Request) 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 false
}
// First try device token (hashed).
hash := sha256Hex(key)
var deviceID, userID, revokedAt sql.NullString
err := s.db.QueryRow("SELECT id, user_id, revoked_at FROM server_devices WHERE token_hash=?", hash).Scan(&deviceID, &userID, &revokedAt)
if err == nil {
if revokedAt.Valid && revokedAt.String != "" {
jsonErr(w, 401, "device revoked")
return false
}
// Check user not blocked.
var blocked int
if userID.Valid && userID.String != "" {
s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", userID.String).Scan(&blocked)
if blocked != 0 {
jsonErr(w, 403, "user blocked")
return false
}
}
r.Header.Set("X-Device-ID", deviceID.String)
r.Header.Set("X-User-ID", userID.String)
s.db.Exec("UPDATE server_devices SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), deviceID.String)
return true
}
// Fallback to plain api_key (legacy).
var count int
err = s.db.QueryRow("SELECT COUNT(*) FROM server_devices WHERE api_key=?", key).Scan(&count)
if err != nil || count == 0 {
jsonErr(w, 401, "invalid API key")
return false
}
return true
}
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
}
func validatePassword(password string) string {
if len(password) < 8 {
return "Password must be at least 8 characters"
}
if !passwordRE.MatchString(password) {
return "Password must contain only Latin letters and digits"
}
hasLetter := false
hasDigit := false
for _, ch := range password {
if ch >= 'A' && ch <= 'Z' || ch >= 'a' && ch <= 'z' {
hasLetter = true
}
if ch >= '0' && ch <= '9' {
hasDigit = true
}
}
if !hasLetter || !hasDigit {
return "Password must contain both letters and digits"
}
return ""
}
func (s *Server) requireUser(w http.ResponseWriter, r *http.Request) (string, bool) {
key := r.Header.Get("Authorization")
key = strings.TrimPrefix(key, "Bearer ")
if key == "" {
jsonErr(w, 401, "authorization required")
return "", false
}
userID, ok := s.userTokens.Check(key)
if !ok {
jsonErr(w, 401, "invalid or expired token")
return "", false
}
return userID, true
}

View File

@ -0,0 +1,37 @@
package main
import "net/http"
func (s *Server) routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/health", s.handleHealth)
mux.HandleFunc("/api/v1/device/register", s.handleDeviceRegister)
mux.HandleFunc("/api/v1/sync/push", s.handleSyncPush)
mux.HandleFunc("/api/v1/sync/pull", s.handleSyncPull)
mux.HandleFunc("/api/v1/blobs/", s.handleBlobs)
mux.HandleFunc("/api/client/pair", s.handleClientPair)
mux.HandleFunc("/api/auth/test", s.handleAuthTest)
mux.HandleFunc("/api/client/revoke-current", s.handleClientRevoke)
mux.HandleFunc("/api/client/me", s.handleClientMe)
mux.HandleFunc("/api/client/revoke-device", s.handleClientRevokeDevice)
mux.HandleFunc("/api/v1/auth/register", s.handleRegister)
mux.HandleFunc("/api/v1/auth/confirm", s.handleConfirm)
mux.HandleFunc("/api/v1/auth/login", s.handleUserLogin)
mux.HandleFunc("/api/v1/auth/forgot", s.handleForgot)
mux.HandleFunc("/api/v1/auth/reset", s.handleReset)
mux.HandleFunc("/forgot", s.handleUserWebForgot)
mux.HandleFunc("/reset", s.handleUserWebReset)
mux.HandleFunc("/api/v1/user/devices", s.handleUserDevices)
mux.HandleFunc("/register", s.handleUserWebRegister)
mux.HandleFunc("/login", s.handleUserWebLogin)
mux.HandleFunc("/dashboard", s.handleUserDashboard)
mux.HandleFunc("/logout", s.handleUserWebLogout)
mux.HandleFunc("/admin/login", s.handleAdminLogin)
mux.HandleFunc("/admin/dashboard", s.handleAdminDashboard)
mux.HandleFunc("/admin/users", s.handleAdminUsers)
mux.HandleFunc("/admin/api/stats", s.handleAdminStats)
mux.HandleFunc("/admin/api/smtp/test", s.handleAdminSMTPTest)
mux.HandleFunc("/admin/", s.handleAdminAPI)
mux.HandleFunc("/", s.handleNotFound)
return mux
}

View File

@ -0,0 +1,100 @@
package main
const serverSchema = `
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,
client_version TEXT,
last_ip TEXT,
last_seen TEXT,
revoked_at TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_revisions (
rev INTEGER PRIMARY KEY AUTOINCREMENT,
op_id TEXT NOT NULL,
device_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS 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 DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS server_idempotency_keys (
idempotency_key TEXT PRIMARY KEY,
response_json TEXT NOT NULL,
created_at 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_smtp_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
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_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_user_devices (
user_id TEXT NOT NULL,
device_id TEXT NOT NULL,
PRIMARY KEY (user_id, device_id)
);
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'))
);
`

File diff suppressed because it is too large Load Diff

156
cmd/verstak-server/smtp.go Normal file
View File

@ -0,0 +1,156 @@
package main
import (
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"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 sha256Hex(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:])
}
func genDeviceToken() (token, prefix, suffix string) {
b := make([]byte, 32)
rand.Read(b)
token = "vs_dev_" + hex.EncodeToString(b)
prefix = token[:16]
suffix = token[len(token)-8:]
return
}
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)
}

View File

@ -0,0 +1,576 @@
package main
import (
"fmt"
"strings"
"verstak/internal/i18n"
)
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">
<div class="hint">%s</div>
<button>%s</button>
<p>%s <a href="/login">%s</a></p>
</form>
</body></html>`,
i18n.T(locale, "server.registerTitle"),
i18n.T(locale, "server.register"),
i18n.T(locale, "server.username"),
i18n.T(locale, "server.email"),
i18n.T(locale, "server.password"),
i18n.T(locale, "server.passwordHint"),
i18n.T(locale, "server.registerBtn"),
i18n.T(locale, "server.alreadyHaveAccount"),
i18n.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>`,
i18n.T(locale, "server.loginTitle"),
i18n.T(locale, "server.usernameOrEmail"),
i18n.T(locale, "server.password"),
i18n.T(locale, "server.loginBtn"),
i18n.T(locale, "server.forgotPassword"),
i18n.T(locale, "server.registerBtn"),
i18n.T(locale, "server.adminLink"),
)
}
func adminLoginHTML(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:20px;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}</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>
</form>
</body></html>`,
i18n.T(locale, "admin.login"),
i18n.T(locale, "admin.username"),
i18n.T(locale, "admin.password"),
i18n.T(locale, "admin.loginBtn"),
)
}
func adminUsersHTML(locale string) string {
newPassResult := i18n.T(locale, "server.newPasswordResult")
newPassParts := strings.SplitN(newPassResult, "%s", 2)
newPassPrefix := newPassParts[0]
newPassSuffix := strings.ReplaceAll(newPassParts[1], "\n", "\\n")
deleteMsg := i18n.T(locale, "admin.deleteUserMessage")
deleteMsgParts := strings.SplitN(deleteMsg, "%s", 2)
delMsgPrefix := deleteMsgParts[0]
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()">
</div>
<table>
<thead><tr>
<th onclick="sortBy('username')">%[5]s <span id="s-username"></span></th>
<th onclick="sortBy('email')">%[6]s <span id="s-email"></span></th>
<th onclick="sortBy('confirmed')">%[7]s <span id="s-confirmed"></span></th>
<th onclick="sortBy('devices')">%[8]s <span id="s-devices"></span></th>
<th onclick="sortBy('last_seen')">%[9]s <span id="s-last_seen"></span></th>
<th>%[10]s</th>
</tr></thead>
<tbody id="users-tbody"></tbody>
</table>
<div class="pagination" id="pagination"></div>
<div id="confirm-modal" class="modal-overlay" style="display:none">
<div class="modal">
<button class="modal-close" onclick="closeConfirm()">&times;</button>
<h2 id="confirm-title">%[11]s</h2>
<p id="confirm-text"></p>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
<button class="btn" onclick="closeConfirm()">%[12]s</button>
<button class="btn btn-danger" id="confirm-btn" onclick="confirmAction()">%[13]s</button>
</div>
</div>
</div>
<div id="edit-modal" class="modal-overlay" style="display:none">
<div class="modal">
<button class="modal-close" onclick="closeEdit()">&times;</button>
<h2>%[14]s</h2>
<div class="form-row"><label>%[15]s</label><input id="edit-username"></div>
<div class="form-row"><label>%[16]s</label><input id="edit-email" type="email"></div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
<button class="btn" onclick="closeEdit()">%[17]s</button>
<button class="btn btn-primary" onclick="saveEdit()">%[18]s</button>
</div>
</div>
</div>
<div id="result-modal" class="modal-overlay" style="display:none">
<div class="modal" style="width:320px">
<button class="modal-close" onclick="closeResult()">&times;</button>
<h2 id="result-title">%[19]s</h2>
<p id="result-text" style="white-space:pre-wrap"></p>
<button class="btn btn-primary" onclick="closeResult()" style="margin-top:8px">%[20]s</button>
</div>
</div>
<script>
var currentPage=1,currentSort='',currentOrder='',editUserId='',pendingAction=''
function loadUsers(){
var f=document.getElementById('filter-input').value
var u='/admin/api/users?page='+currentPage+'&per_page=20&filter='+encodeURIComponent(f)
if(currentSort){u+='&sort='+currentSort+'&order='+currentOrder}
fetch(u).then(function(r){return r.json()}).then(function(d){
var tbody=document.getElementById('users-tbody')
tbody.innerHTML=''
d.users.forEach(function(u){
var status=u.confirmed?'<span class="badge badge-green">%[21]s</span>':'<span class="badge badge-yellow">%[22]s</span>'
if(u.blocked){status='<span class="badge badge-red">%[23]s</span>'}
var lastSeen=u.last_seen?new Date(u.last_seen).toLocaleString():'-'
var blockText=u.blocked?'%[24]s':'%[25]s'
var tr=document.createElement('tr')
tr.innerHTML='<td>'+esc(u.username)+'</td><td>'+esc(u.email)+'</td><td>'+status+'</td><td>'+u.devices+'</td><td>'+lastSeen+'</td>'+
'<td><button class="btn btn-sm" onclick="editUser(\''+u.id+'\',\''+escJS(u.username)+'\',\''+escJS(u.email)+'\')"></button> '+
'<button class="btn btn-sm" onclick="askBlock(\''+u.id+'\','+u.blocked+')">'+blockText+'</button> '+
'<button class="btn btn-sm" onclick="askReset(\''+u.id+'\')">%[26]s</button> '+
'<button class="btn btn-sm btn-danger" onclick="askDelete(\''+u.id+'\',\''+escJS(u.username)+'\')"></button></td>'
tbody.appendChild(tr)
})
if(!d.users.length){tbody.innerHTML='<tr><td colspan="6" style="text-align:center;color:#666">%[27]s</td></tr>'}
var totalPages=Math.ceil(d.total/d.per_page)
var pag=document.getElementById('pagination')
pag.innerHTML=''
if(totalPages>1){
var prev=document.createElement('button')
prev.className='btn btn-sm';prev.textContent='←';prev.onclick=function(){if(currentPage>1){currentPage--;loadUsers()}}
pag.appendChild(prev)
var s=document.createElement('span')
s.textContent=d.page+' / '+totalPages
pag.appendChild(s)
var next=document.createElement('button')
next.className='btn btn-sm';next.textContent='→';next.onclick=function(){if(currentPage<totalPages){currentPage++;loadUsers()}}
pag.appendChild(next)
}
})
}
function sortBy(col){
if(currentSort===col){currentOrder=currentOrder==='asc'?'desc':'asc'}
else{currentSort=col;currentOrder='asc'}
document.querySelectorAll('th').forEach(function(th){th.classList.remove('sorted')})
var el=document.getElementById('s-'+col)
if(el){el.parentElement.classList.add('sorted');el.textContent=currentOrder==='asc'?' ':' '}
loadUsers()
}
function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
function escJS(s){return s.replace(/'/g,"\\'").replace(/"/g,'&quot;')}
function editUser(id,username,email){
editUserId=id;document.getElementById('edit-username').value=username;document.getElementById('edit-email').value=email;document.getElementById('edit-modal').style.display='flex'}
function closeEdit(){document.getElementById('edit-modal').style.display='none'}
function saveEdit(){
var un=document.getElementById('edit-username').value,em=document.getElementById('edit-email').value
if(!un||!em)return
fetch('/admin/api/users/'+editUserId+'/edit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:un,email:em})}).then(function(r){return r.json()}).then(function(d){closeEdit();if(d.status==='ok')loadUsers()})
}
function askBlock(id,blocked){
pendingAction=function(){fetch('/admin/api/users/'+id+'/block',{method:'POST'}).then(function(r){return r.json()}).then(function(d){loadUsers()})}
document.getElementById('confirm-title').textContent=blocked?'%[35]s':'%[36]s'
document.getElementById('confirm-text').textContent=blocked?'%[37]s':'%[38]s'
document.getElementById('confirm-btn').textContent=blocked?'%[24]s':'%[25]s'
document.getElementById('confirm-modal').style.display='flex'}
function askReset(id){
pendingAction=function(){
fetch('/admin/api/users/'+id+'/reset-password',{method:'POST'}).then(function(r){return r.json()}).then(function(d){
document.getElementById('confirm-modal').style.display='none'
document.getElementById('result-title').textContent='%[28]s'
document.getElementById('result-text').textContent='%[29]s' + d.new_password + '%[30]s'
document.getElementById('result-modal').style.display='flex'})}
document.getElementById('confirm-title').textContent='%[31]s'
document.getElementById('confirm-text').textContent='%[32]s'
document.getElementById('confirm-btn').textContent='%[33]s'
document.getElementById('confirm-modal').style.display='flex'}
function askDelete(id,username){
pendingAction=function(){fetch('/admin/api/users/'+id,{method:'DELETE'}).then(function(r){return r.json()}).then(function(d){loadUsers()})}
document.getElementById('confirm-title').textContent='%[34]s'
document.getElementById('confirm-text').textContent='%[35]s' + username + '%[36]s'
document.getElementById('confirm-btn').textContent='%[37]s'
document.getElementById('confirm-modal').style.display='flex'}
function closeConfirm(){document.getElementById('confirm-modal').style.display='none';pendingAction=''}
function confirmAction(){if(pendingAction){pendingAction();pendingAction=''}}
function closeResult(){document.getElementById('result-modal').style.display='none'}
loadUsers()
</script>
</body></html>`,
i18n.T(locale, "admin.users"),
i18n.T(locale, "admin.usersHeading"),
i18n.T(locale, "server.dashboard"),
i18n.T(locale, "admin.filterPlaceholder"),
i18n.T(locale, "admin.username"),
i18n.T(locale, "admin.email"),
i18n.T(locale, "admin.status"),
i18n.T(locale, "admin.devices"),
i18n.T(locale, "admin.lastSeen"),
i18n.T(locale, "admin.actions"),
i18n.T(locale, "admin.confirmTitle"),
i18n.T(locale, "admin.modalCancel"),
i18n.T(locale, "admin.modalConfirm"),
i18n.T(locale, "admin.editUser"),
i18n.T(locale, "admin.username"),
i18n.T(locale, "admin.email"),
i18n.T(locale, "admin.modalCancel"),
i18n.T(locale, "admin.editBtn"),
i18n.T(locale, "admin.resultTitle"),
i18n.T(locale, "common.ok"),
i18n.T(locale, "admin.confirmed"),
i18n.T(locale, "admin.unconfirmed"),
i18n.T(locale, "admin.blocked"),
i18n.T(locale, "admin.unblock"),
i18n.T(locale, "admin.block"),
i18n.T(locale, "admin.resetPassword"),
i18n.T(locale, "admin.noUsers"),
i18n.T(locale, "server.newPassword"),
newPassPrefix,
newPassSuffix,
i18n.T(locale, "admin.resetPasswordConfirm"),
i18n.T(locale, "admin.resetPasswordMessage"),
i18n.T(locale, "admin.resetBtn"),
i18n.T(locale, "admin.deleteUser"),
delMsgPrefix,
delMsgSuffix,
i18n.T(locale, "admin.deleteBtn"),
i18n.T(locale, "admin.unblockUserTitle"),
i18n.T(locale, "admin.blockUserTitle"),
i18n.T(locale, "admin.unblockUserMessage"),
i18n.T(locale, "admin.blockUserMessage"),
)
}
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>`,
i18n.T(locale, "server.emailConfirmed"),
i18n.T(locale, "server.emailConfirmed"),
i18n.T(locale, "server.emailConfirmedMessage"),
i18n.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>`,
i18n.T(locale, "server.registerTitle"),
i18n.T(locale, "server.registrationSuccess"),
i18n.T(locale, "server.registrationEmailSent"),
i18n.T(locale, "server.registrationCheckEmail"),
i18n.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>`,
i18n.T(locale, "server.registerTitle"),
i18n.T(locale, "server.registrationSuccess"),
i18n.T(locale, "server.registrationAutoMessage"),
i18n.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>`,
i18n.T(locale, "server.resetPasswordTitle"),
i18n.T(locale, "server.resetPassword"),
i18n.T(locale, "server.resetInstruction"),
i18n.T(locale, "server.email"),
i18n.T(locale, "server.sendLink"),
i18n.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>`,
i18n.T(locale, "server.emailSentTitle"),
i18n.T(locale, "server.emailSent"),
i18n.T(locale, "server.emailSentMessage"),
i18n.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" required autofocus>
<label>%s</label>
<input type="password" name="confirm" minlength="8" required>
<div class="hint">%s</div>
<button style="margin-top:8px">%s</button>
</form>
</body></html>`,
i18n.T(locale, "server.newPasswordTitle"),
i18n.T(locale, "server.newPassword"),
i18n.T(locale, "server.password"),
i18n.T(locale, "server.passwordConfirm"),
i18n.T(locale, "server.adminPwdHint"),
i18n.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>`,
i18n.T(locale, "server.passwordChanged"),
i18n.T(locale, "server.passwordChanged"),
i18n.T(locale, "server.passwordChangedMessage"),
i18n.T(locale, "server.loginBtn"),
)
}
func errorPageHTML(locale, title, msg, backURL 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;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, i18n.T(locale, "server.back"))
}

View File

@ -0,0 +1,80 @@
package main
import (
"crypto/rand"
"encoding/hex"
"sync"
"time"
)
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
}
// userTokenStore embeds tokenStore but also tracks the user_id per token.
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
}

View File

@ -5,6 +5,7 @@
import ConfirmModal from './lib/ConfirmModal.svelte' import ConfirmModal from './lib/ConfirmModal.svelte'
import { onMount, onDestroy } from 'svelte' import { onMount, onDestroy } from 'svelte'
import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js' import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js'
import { t } from './lib/i18n'
// ===== Wails v2 API call helper ===== // ===== Wails v2 API call helper =====
// In production: window['go']['main']['App']['MethodName'](...args) // In production: window['go']['main']['App']['MethodName'](...args)
@ -56,13 +57,13 @@
let newActionKind = 'open_url' let newActionKind = 'open_url'
let newActionData = '' let newActionData = ''
let actionKinds = [ let actionKinds = [
{ id: 'open_url', label: 'Открыть URL' }, { id: 'open_url', label: t('action.openUrl') },
{ id: 'open_file', label: 'Открыть файл' }, { id: 'open_file', label: t('action.openFile') },
{ id: 'open_folder', label: 'Открыть папку' }, { id: 'open_folder', label: t('action.openFolder') },
{ id: 'run_command', label: 'Запустить команду' }, { id: 'run_command', label: t('action.runCommand') },
{ id: 'run_script', label: 'Запустить скрипт' }, { id: 'run_script', label: t('action.runScript') },
{ id: 'open_terminal', label: 'Открыть терминал' }, { id: 'open_terminal', label: t('action.openTerminal') },
{ id: 'launch_app', label: 'Запустить приложение' }, { id: 'launch_app', label: t('action.launchApp') },
] ]
let loading = true let loading = true
let importing = false let importing = false
@ -89,7 +90,7 @@
let confirmTitle = '' let confirmTitle = ''
let confirmMessage = '' let confirmMessage = ''
let confirmDanger = false let confirmDanger = false
let confirmText = 'Удалить' let confirmText = t('common.delete')
let confirmAction = null let confirmAction = null
let cancelAction = null let cancelAction = null
@ -109,12 +110,12 @@
let syncResult = '' let syncResult = ''
const tabs = [ const tabs = [
{ id: 'overview', label: 'Обзор' }, { id: 'overview', label: t('tab.overview') },
{ id: 'notes', label: 'Заметки' }, { id: 'notes', label: t('tab.notes') },
{ id: 'files', label: 'Файлы' }, { id: 'files', label: t('tab.files') },
{ id: 'actions', label: 'Действия' }, { id: 'actions', label: t('tab.actions') },
{ id: 'worklog', label: 'Журнал' }, { id: 'worklog', label: t('tab.worklog') },
{ id: 'activity', label: 'Активность' }, { id: 'activity', label: t('tab.activity') },
] ]
let unlistenDrop = null let unlistenDrop = null
@ -128,14 +129,14 @@
error = String(e) error = String(e)
// Fallback: show sections from known list // Fallback: show sections from known list
sections = [ sections = [
{ id: 'today', label: 'Сегодня' }, { id: 'today', label: t('nav.today') },
{ id: 'inbox', label: 'Неразобранное' }, { id: 'inbox', label: t('nav.inbox') },
{ id: 'activity', label: 'Активность' }, { id: 'activity', label: t('nav.activity') },
{ id: 'clients', label: 'Клиенты' }, { id: 'clients', label: t('nav.clients') },
{ id: 'projects', label: 'Проекты' }, { id: 'projects', label: t('nav.projects') },
{ id: 'recipes', label: 'Рецепты' }, { id: 'recipes', label: t('nav.recipes') },
{ id: 'documents', label: 'Документы' }, { id: 'documents', label: t('nav.documents') },
{ id: 'archive', label: 'Архив' }, { id: 'archive', label: t('nav.archive') },
] ]
} }
@ -315,7 +316,7 @@
// ===== File operations ===== // ===== File operations =====
async function createFile() { async function createFile() {
const name = prompt('Введите имя файла:') const name = prompt(t('file.namePrompt'))
if (!name || !name.trim()) return if (!name || !name.trim()) return
try { try {
const parentId = currentFolderId || selectedNode.id const parentId = currentFolderId || selectedNode.id
@ -411,11 +412,19 @@
async function deleteSelected() { async function deleteSelected() {
const ids = getTargetIds(selectedIds) const ids = getTargetIds(selectedIds)
const label = ids.length === 1 && fileItems.find(x => x.id === ids[0])?.type === 'folder' ? 'папку' : `файлов (${ids.length})` const item = fileItems.find(x => x.id === ids[0])
let label
if (ids.length === 1 && item?.type === 'folder') {
label = t('delete.folder')
} else if (ids.length === 1) {
label = t('delete.file')
} else {
label = t('delete.files', { count: ids.length })
}
openConfirm({ openConfirm({
title: 'Удаление', title: t('delete.confirmTitle'),
message: `Удалить ${label}?`, message: t('delete.confirmMessage') + ' ' + label + '?',
confirmText: 'Удалить', confirmText: t('common.delete'),
danger: true, danger: true,
onConfirm: async () => { onConfirm: async () => {
for (const id of ids) { for (const id of ids) {
@ -548,12 +557,11 @@
async function submitRename() { async function submitRename() {
const name = renameValue.trim() const name = renameValue.trim()
if (!name) { renameError = 'Имя не может быть пустым'; return } if (!name) { renameError = t('rename.emptyError'); return }
// Validate name via backend
try { try {
await wailsCall('ValidateName', name) await wailsCall('ValidateName', name)
} catch (e) { } catch (e) {
renameError = 'Недопустимое имя' renameError = t('rename.invalidError')
return return
} }
showRename = false showRename = false
@ -582,10 +590,10 @@
// ===== Confirm modal ===== // ===== Confirm modal =====
function openConfirm(opts) { function openConfirm(opts) {
confirmTitle = opts.title || 'Подтверждение' confirmTitle = opts.title || t('common.confirm')
confirmMessage = opts.message || '' confirmMessage = opts.message || ''
confirmDanger = opts.danger !== undefined ? opts.danger : true confirmDanger = opts.danger !== undefined ? opts.danger : true
confirmText = opts.confirmText || 'Удалить' confirmText = opts.confirmText || t('common.delete')
confirmAction = opts.onConfirm || null confirmAction = opts.onConfirm || null
cancelAction = opts.onCancel || null cancelAction = opts.onCancel || null
showConfirm = true showConfirm = true
@ -654,9 +662,9 @@
async function openNote(note) { async function openNote(note) {
if (noteEditor && noteEditor.dirty) { if (noteEditor && noteEditor.dirty) {
openConfirm({ openConfirm({
title: 'Несохранённые изменения', title: t('note.unsavedTitle'),
message: 'Закрыть редактор? Все несохранённые изменения будут потеряны.', message: t('note.unsavedMessage'),
confirmText: 'Закрыть', confirmText: t('note.unsavedClose'),
danger: false, danger: false,
onConfirm: async () => { onConfirm: async () => {
await doOpenNote(note) await doOpenNote(note)
@ -679,9 +687,9 @@
function closeNoteEditor() { function closeNoteEditor() {
if (noteEditor && noteEditor.dirty) { if (noteEditor && noteEditor.dirty) {
openConfirm({ openConfirm({
title: 'Несохранённые изменения', title: t('note.unsavedTitle'),
message: 'Закрыть редактор? Все несохранённые изменения будут потеряны.', message: t('note.unsavedMessage'),
confirmText: 'Закрыть', confirmText: t('note.unsavedClose'),
danger: false, danger: false,
onConfirm: () => { noteEditor = null } onConfirm: () => { noteEditor = null }
}) })
@ -769,11 +777,11 @@
} }
async function deleteFile({ id, type }) { async function deleteFile({ id, type }) {
const label = type === 'folder' ? 'папку' : 'файл' const label = type === 'folder' ? t('delete.folder') : t('delete.file')
openConfirm({ openConfirm({
title: 'Удаление', title: t('delete.confirmTitle'),
message: `Удалить ${label}?`, message: t('delete.confirmMessage') + ' ' + label + '?',
confirmText: 'Удалить', confirmText: t('common.delete'),
danger: true, danger: true,
onConfirm: async () => { onConfirm: async () => {
try { try {
@ -800,7 +808,7 @@
async function onFilesDropped(paths) { async function onFilesDropped(paths) {
if (!paths || paths.length === 0) return if (!paths || paths.length === 0) return
if (!selectedNode) { if (!selectedNode) {
error = 'Сначала выберите дело для добавления файлов' error = t('error.selectCaseFirst')
return return
} }
const path = paths[0] const path = paths[0]
@ -811,18 +819,18 @@
function tabClass(id) { return activeTab === id ? 'tab active' : 'tab' } function tabClass(id) { return activeTab === id ? 'tab active' : 'tab' }
function eventLabel(type) { function eventLabel(type) {
const labels = { const labels = {
'note_created': 'Заметка создана', 'note_created': t('event.noteCreated'),
'note_updated': 'Заметка изменена', 'note_updated': t('event.noteUpdated'),
'file_added': 'Файл добавлен', 'file_added': t('event.fileAdded'),
'file_deleted': 'Файл удалён', 'file_deleted': t('event.fileDeleted'),
'file_renamed': 'Файл переименован', 'file_renamed': t('event.fileRenamed'),
'file_copied': 'Файл скопирован', 'file_copied': t('event.fileCopied'),
'file_moved': 'Файл перемещён', 'file_moved': t('event.fileMoved'),
'folder_added': 'Папка добавлена', 'folder_added': t('event.folderAdded'),
'folder_deleted': 'Папка удалена', 'folder_deleted': t('event.folderDeleted'),
'folder_renamed': 'Папка переименована', 'folder_renamed': t('event.folderRenamed'),
'node_created': 'Дело создано', 'node_created': t('event.caseCreated'),
'node_updated': 'Дело изменено', 'node_updated': t('event.caseUpdated'),
} }
return labels[type] || type return labels[type] || type
} }
@ -843,8 +851,8 @@
try { return new Date(str).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) } catch (e) { return str } try { return new Date(str).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) } catch (e) { return str }
} }
function nodeKindLabel(kind) { function nodeKindLabel(kind) {
const labels = { 'project': 'Проект', 'client': 'Клиент', 'document': 'Документ', 'recipe': 'Рецепт', 'archive': 'Архив', 'case': 'Дело' } const labels = { 'project': t('kind.project'), 'client': t('kind.client'), 'document': t('kind.document'), 'recipe': t('kind.recipe'), 'archive': t('kind.archive'), 'case': t('kind.case') }
return labels[kind] || kind || 'Дело' return labels[kind] || kind || t('kind.case')
} }
function pluralize(n, one, few, many) { function pluralize(n, one, few, many) {
n = Math.abs(n) % 100 n = Math.abs(n) % 100
@ -940,7 +948,7 @@
syncResult = '' syncResult = ''
try { try {
await wailsCall('SyncSetInterval', syncInterval) await wailsCall('SyncSetInterval', syncInterval)
syncResult = 'интервал сохранён' syncResult = t('sync.settingsSaved')
await loadSyncStatus() await loadSyncStatus()
} catch (e) { } catch (e) {
syncResult = 'err: ' + String(e) syncResult = 'err: ' + String(e)
@ -992,11 +1000,11 @@
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-brand"> <div class="sidebar-brand">
<span class="logo">&#9874;</span> <span class="logo">&#9874;</span>
<span class="brand-name">Верстак</span> <span class="brand-name">{t('nav.brand')}</span>
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<div class="nav-group"> <div class="nav-group">
<div class="nav-label">Разделы</div> <div class="nav-label">{t('nav.sections')}</div>
{#each sections as section} {#each sections as section}
<button class="nav-item {selectedSection === section.id ? 'selected' : ''}" <button class="nav-item {selectedSection === section.id ? 'selected' : ''}"
on:click={() => selectSection(section.id)}> on:click={() => selectSection(section.id)}>
@ -1006,22 +1014,22 @@
</div> </div>
{#if selectedSection && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'} {#if selectedSection && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'}
<div class="nav-group"> <div class="nav-group">
<div class="nav-label">Дела {#if nodes.length > 0}({nodes.length}){/if}</div> <div class="nav-label">{t('nav.cases')} {#if nodes.length > 0}({nodes.length}){/if}</div>
{#each nodes as node} {#each nodes as node}
<button class="nav-item {selectedNode && selectedNode.id === node.id ? 'selected' : ''}" <button class="nav-item {selectedNode && selectedNode.id === node.id ? 'selected' : ''}"
on:click={() => selectNode(node)}> on:click={() => selectNode(node)}>
{node.title} {node.title}
</button> </button>
{/each} {/each}
{#if nodes.length === 0}<div class="nav-empty">Нет дел</div>{/if} {#if nodes.length === 0}<div class="nav-empty">{t('nav.noCases')}</div>{/if}
</div> </div>
{/if} {/if}
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button class="sidebar-sync-btn" on:click={openSettings} title="Настройки синхронизации"> <button class="sidebar-sync-btn" on:click={openSettings} title={t('nav.syncSettings')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
<span class="sync-dot" class:active={syncStatus?.configured}></span> <span class="sync-dot" class:active={syncStatus?.configured}></span>
<span class="sidebar-sync-label">Синхронизация</span> <span class="sidebar-sync-label">{t('nav.sync')}</span>
</button> </button>
<span class="version">{version}</span> <span class="version">{version}</span>
</div> </div>
@ -1037,12 +1045,12 @@
{:else if selectedSection} {:else if selectedSection}
<span class="crumb">{#each sections as s}{s.id === selectedSection ? s.label : ''}{/each}</span> <span class="crumb">{#each sections as s}{s.id === selectedSection ? s.label : ''}{/each}</span>
{:else} {:else}
<span class="crumb placeholder">Выберите раздел или дело</span> <span class="crumb placeholder">{t('nav.selectPrompt')}</span>
{/if} {/if}
</div> </div>
<div class="header-right"> <div class="header-right">
{#if syncStatus?.configured} {#if syncStatus?.configured}
<button class="header-sync-btn" on:click={runSyncNow} disabled={syncLoading} title="Синхронизировать"> <button class="header-sync-btn" on:click={runSyncNow} disabled={syncLoading} title={t('nav.syncNow')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
{#if syncStatus.unpushedOps > 0} {#if syncStatus.unpushedOps > 0}
<span class="sync-badge">{syncStatus.unpushedOps}</span> <span class="sync-badge">{syncStatus.unpushedOps}</span>
@ -1070,12 +1078,12 @@
<span class="note-title">{noteEditor.title}</span> <span class="note-title">{noteEditor.title}</span>
{#if noteEditor.dirty}<span class="dirty-mark"></span>{/if} {#if noteEditor.dirty}<span class="dirty-mark"></span>{/if}
<div class="note-editor-actions"> <div class="note-editor-actions">
<button class="btn btn-primary" on:click={saveCurrentNote}>Сохранить</button> <button class="btn btn-primary" on:click={saveCurrentNote}>{t('common.save')}</button>
<button class="btn" on:click={closeNoteEditor}>Закрыть</button> <button class="btn" on:click={closeNoteEditor}>{t('common.close')}</button>
</div> </div>
</div> </div>
<textarea class="note-textarea" bind:value={noteEditor.content} <textarea class="note-textarea" bind:value={noteEditor.content}
on:input={updateNoteContent} placeholder="Начните писать..."></textarea> on:input={updateNoteContent} placeholder={t('note.placeholder')}></textarea>
</div> </div>
{:else if selectedNode} {:else if selectedNode}
@ -1090,31 +1098,31 @@
<div class="overview"> <div class="overview">
<h2>{selectedNode.title}</h2> <h2>{selectedNode.title}</h2>
<div class="meta-grid"> <div class="meta-grid">
<div class="meta-item"><span class="meta-label">Тип</span><span>{selectedNode.type}</span></div> <div class="meta-item"><span class="meta-label">{t('overview.type')}</span><span>{selectedNode.type}</span></div>
<div class="meta-item"><span class="meta-label">Раздел</span><span>{selectedNode.section || '—'}</span></div> <div class="meta-item"><span class="meta-label">{t('overview.section')}</span><span>{selectedNode.section || '—'}</span></div>
<div class="meta-item"><span class="meta-label">Создано</span><span>{formatDate(selectedNode.createdAt)}</span></div> <div class="meta-item"><span class="meta-label">{t('overview.created')}</span><span>{formatDate(selectedNode.createdAt)}</span></div>
</div> </div>
<div class="quick-actions"> <div class="quick-actions">
<button class="qa-btn" on:click={() => { activeTab = 'notes'; openCreateNote() }}> <button class="qa-btn" on:click={() => { activeTab = 'notes'; openCreateNote() }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Новая заметка {t('overview.newNote')}
</button> </button>
<button class="qa-btn" on:click={() => { activeTab = 'files'; addFile() }}> <button class="qa-btn" on:click={() => { activeTab = 'files'; addFile() }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
Добавить файл {t('overview.addFile')}
</button> </button>
<button class="qa-btn" on:click={openCreateAction}> <button class="qa-btn" on:click={openCreateAction}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
Добавить действие {t('overview.addAction')}
</button> </button>
<button class="qa-btn" on:click={() => activeTab = 'worklog'}> <button class="qa-btn" on:click={() => activeTab = 'worklog'}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Записать время {t('overview.logTime')}
</button> </button>
</div> </div>
{#if notes.length > 0} {#if notes.length > 0}
<div class="recent-section"> <div class="recent-section">
<h3>Последние заметки</h3> <h3>{t('overview.recentNotes')}</h3>
{#each notes.slice(0, 5) as note} {#each notes.slice(0, 5) as note}
<div class="recent-note" on:click={() => openNote(note)}> <div class="recent-note" on:click={() => openNote(note)}>
<span>{note.title}</span><span class="recent-date">{formatDate(note.createdAt)}</span> <span>{note.title}</span><span class="recent-date">{formatDate(note.createdAt)}</span>
@ -1124,9 +1132,9 @@
{/if} {/if}
{#if worklog.length > 0} {#if worklog.length > 0}
<div class="recent-section"> <div class="recent-section">
<h3>Последние записи</h3> <h3>{t('overview.recentEntries')}</h3>
{#each worklog.slice(0, 3) as e} {#each worklog.slice(0, 3) as e}
<div class="recent-entry">{e.summary} ({e.minutes} мин)</div> <div class="recent-entry">{e.summary} ({e.minutes} {t('worklog.min')})</div>
{/each} {/each}
</div> </div>
{/if} {/if}
@ -1135,20 +1143,20 @@
{:else if activeTab === 'notes'} {:else if activeTab === 'notes'}
<div class="notes-tab"> <div class="notes-tab">
<div class="tab-toolbar"> <div class="tab-toolbar">
<button class="btn btn-primary" on:click={openCreateNote}>+ Добавить заметку</button> <button class="btn btn-primary" on:click={openCreateNote}>{t('note.add')}</button>
</div> </div>
{#if showCreateNote} {#if showCreateNote}
<div class="create-form"> <div class="create-form">
<input type="text" placeholder="Название заметки" bind:value={newNoteTitle} <input type="text" placeholder={t('note.title')} bind:value={newNoteTitle}
on:keydown={(e) => e.key === 'Enter' && submitCreateNote()} /> on:keydown={(e) => e.key === 'Enter' && submitCreateNote()} />
<div class="form-actions"> <div class="form-actions">
<button class="btn btn-primary" on:click={submitCreateNote}>Создать</button> <button class="btn btn-primary" on:click={submitCreateNote}>{t('common.create')}</button>
<button class="btn" on:click={cancelCreateNote}>Отмена</button> <button class="btn" on:click={cancelCreateNote}>{t('common.cancel')}</button>
</div> </div>
</div> </div>
{/if} {/if}
{#if notes.length === 0 && !showCreateNote} {#if notes.length === 0 && !showCreateNote}
<div class="empty-state"><p>Нет заметок</p><p class="hint">Создайте первую заметку для этого дела.</p></div> <div class="empty-state"><p>{t('note.noNotes')}</p><p class="hint">{t('note.createFirst')}</p></div>
{:else} {:else}
<div class="notes-list"> <div class="notes-list">
{#each notes as note} {#each notes as note}
@ -1164,21 +1172,21 @@
{:else if activeTab === 'files'} {:else if activeTab === 'files'}
<div class="files-tab"> <div class="files-tab">
<div class="tab-toolbar"> <div class="tab-toolbar">
<button class="btn btn-primary" on:click={addFile} disabled={importing}>+ Добавить файл</button> <button class="btn btn-primary" on:click={addFile} disabled={importing}>{t('file.addFile')}</button>
<button class="btn" on:click={addFolder} disabled={importing}>+ Добавить папку</button> <button class="btn" on:click={addFolder} disabled={importing}>{t('file.addFolder')}</button>
<button class="btn" on:click={createFile}>+ Новый файл</button> <button class="btn" on:click={createFile}>{t('file.newFile')}</button>
{#if clipboard.items.length > 0} {#if clipboard.items.length > 0}
<button class="btn" on:click={pasteItem}>Вставить {clipboard.items.length}</button> <button class="btn" on:click={pasteItem}>{t('common.paste')} {clipboard.items.length}</button>
{/if} {/if}
</div> </div>
{#if loadingFiles} {#if loadingFiles}
<div class="empty-state"> <div class="empty-state">
<p>Загрузка...</p> <p>{t('common.loading')}</p>
</div> </div>
{:else} {:else}
{#if folderStack.length > 0} {#if folderStack.length > 0}
<FileBreadcrumbs crumbs={[{ name: 'Файлы' }, ...folderStack]} on:navigate={(e) => { <FileBreadcrumbs crumbs={[{ name: t('file.root') }, ...folderStack]} on:navigate={(e) => {
const i = e.detail const i = e.detail
if (i === 0) { if (i === 0) {
folderStack = [] folderStack = []
@ -1190,10 +1198,10 @@
}}/> }}/>
<button class="btn btn-sm back-btn" on:click={navigateBack}> <button class="btn btn-sm back-btn" on:click={navigateBack}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
Back {t('common.back')}
</button> </button>
{:else} {:else}
<FileBreadcrumbs crumbs={[{ name: 'Файлы' }]}/> <FileBreadcrumbs crumbs={[{ name: t('file.root') }]}/>
{/if} {/if}
{#if fileItems.length === 0} {#if fileItems.length === 0}
@ -1204,11 +1212,11 @@
<polyline points="14 2 14 8 20 8"/> <polyline points="14 2 14 8 20 8"/>
</svg> </svg>
</div> </div>
<p>{folderStack.length > 0 ? 'В этой папке пока нет файлов' : 'В этом проекте пока нет файлов'}</p> <p>{folderStack.length > 0 ? t('file.noFiles') : t('file.noFilesCase')}</p>
<p class="hint">Добавьте файл или папку, чтобы сохранить материалы проекта.</p> <p class="hint">{t('file.hint')}</p>
<div class="empty-actions"> <div class="empty-actions">
<button class="btn btn-primary" on:click={addFile}>Добавить файл</button> <button class="btn btn-primary" on:click={addFile}>{t('file.addFileSimple')}</button>
<button class="btn" on:click={addFolder}>Добавить папку</button> <button class="btn" on:click={addFolder}>{t('file.addFolderSimple')}</button>
</div> </div>
</div> </div>
{:else} {:else}
@ -1239,7 +1247,7 @@
{/if} {/if}
{#if importing && !showImportDialog} {#if importing && !showImportDialog}
<div class="empty-state"><p>Сканирование...</p></div> <div class="empty-state"><p>{t('file.scanning')}</p></div>
{/if} {/if}
</div> </div>
@ -1257,10 +1265,10 @@
{:else if activeTab === 'actions'} {:else if activeTab === 'actions'}
<div class="actions-tab"> <div class="actions-tab">
<div class="tab-toolbar"> <div class="tab-toolbar">
<button class="btn btn-primary" on:click={openCreateAction}>+ Добавить действие</button> <button class="btn btn-primary" on:click={openCreateAction}>{t('action.addAction')}</button>
</div> </div>
{#if actions.length === 0} {#if actions.length === 0}
<div class="empty-state"><p>Действий пока нет</p></div> <div class="empty-state"><p>{t('action.noActions')}</p></div>
{:else} {:else}
{#each actions as action} {#each actions as action}
<div class="action-card"> <div class="action-card">
@ -1270,7 +1278,7 @@
<span class="action-data">{action.data}</span> <span class="action-data">{action.data}</span>
</div> </div>
<div class="action-btns"> <div class="action-btns">
<button class="btn btn-sm" on:click={() => wailsCall('RunAction', action.id)}>Запустить</button> <button class="btn btn-sm" on:click={() => wailsCall('RunAction', action.id)}>{t('action.run')}</button>
<button class="btn btn-sm btn-danger" on:click={() => deleteAction(action.id)}> <button class="btn btn-sm btn-danger" on:click={() => deleteAction(action.id)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button> </button>
@ -1283,18 +1291,18 @@
{:else if activeTab === 'worklog'} {:else if activeTab === 'worklog'}
<div class="worklog-tab"> <div class="worklog-tab">
<div class="worklog-form"> <div class="worklog-form">
<input type="text" placeholder="Что сделано" bind:value={worklogSummary} /> <input type="text" placeholder={t('worklog.whatDone')} bind:value={worklogSummary} />
<input type="number" placeholder="Мин" bind:value={worklogMinutes} min="1" /> <input type="number" placeholder={t('worklog.minutes')} bind:value={worklogMinutes} min="1" />
<button class="btn btn-primary" on:click={submitWorklog} <button class="btn btn-primary" on:click={submitWorklog}
disabled={!worklogSummary.trim() || !worklogMinutes}>Записать</button> disabled={!worklogSummary.trim() || !worklogMinutes}>{t('worklog.log')}</button>
</div> </div>
{#if worklog.length === 0} {#if worklog.length === 0}
<div class="empty-state"><p>Записей работы пока нет</p></div> <div class="empty-state"><p>{t('worklog.empty')}</p></div>
{:else} {:else}
{#each worklog as e} {#each worklog as e}
<div class="worklog-entry"> <div class="worklog-entry">
<div>{e.summary}</div> <div>{e.summary}</div>
<div class="wl-meta">{e.minutes} мин · {formatDate(e.createdAt)}</div> <div class="wl-meta">{e.minutes} {t('worklog.min')} · {formatDate(e.createdAt)}</div>
</div> </div>
{/each} {/each}
{/if} {/if}
@ -1303,7 +1311,7 @@
{:else if activeTab === 'activity'} {:else if activeTab === 'activity'}
<div class="activity-tab"> <div class="activity-tab">
{#if caseActivity.length === 0} {#if caseActivity.length === 0}
<div class="empty-state"><p>Активность пока не зафиксирована</p></div> <div class="empty-state"><p>{t('activity.perCaseEmpty')}</p></div>
{:else} {:else}
<div class="activity-events"> <div class="activity-events">
{#each caseActivity as ev} {#each caseActivity as ev}
@ -1324,14 +1332,14 @@
{:else if selectedSection === 'today' && todayDashboard} {:else if selectedSection === 'today' && todayDashboard}
<div class="today-dashboard"> <div class="today-dashboard">
<div class="today-header"> <div class="today-header">
<h2>Сегодня</h2> <h2>{t('today.title')}</h2>
<span class="today-date">{todayDashboard.date}</span> <span class="today-date">{todayDashboard.date}</span>
</div> </div>
{#if todayDashboard.summary} {#if todayDashboard.summary}
<div class="today-summary"> <div class="today-summary">
{#if todayDashboard.summary.changedCases > 0}<span class="summary-chip">{todayDashboard.summary.changedCases} {pluralize(todayDashboard.summary.changedCases, 'дело', 'дела', 'дел')}</span>{/if} {#if todayDashboard.summary.changedCases > 0}<span class="summary-chip">{todayDashboard.summary.changedCases} {pluralize(todayDashboard.summary.changedCases, t('today.plural.case_one'), t('today.plural.case_few'), t('today.plural.case_many'))}</span>{/if}
{#if todayDashboard.summary.notes > 0}<span class="summary-chip">{todayDashboard.summary.notes} {pluralize(todayDashboard.summary.notes, 'заметка', 'заметки', 'заметок')}</span>{/if} {#if todayDashboard.summary.notes > 0}<span class="summary-chip">{todayDashboard.summary.notes} {pluralize(todayDashboard.summary.notes, t('today.plural.note_one'), t('today.plural.note_few'), t('today.plural.note_many'))}</span>{/if}
{#if todayDashboard.summary.files > 0}<span class="summary-chip">{todayDashboard.summary.files} {pluralize(todayDashboard.summary.files, 'файл', 'файла', 'файлов')}</span>{/if} {#if todayDashboard.summary.files > 0}<span class="summary-chip">{todayDashboard.summary.files} {pluralize(todayDashboard.summary.files, t('today.plural.file_one'), t('today.plural.file_few'), t('today.plural.file_many'))}</span>{/if}
</div> </div>
{/if} {/if}
@ -1341,7 +1349,7 @@
<div class="today-case-header" role="button" tabindex="0" on:click={() => openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}> <div class="today-case-header" role="button" tabindex="0" on:click={() => openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}>
<span class="today-case-title">{group.nodeTitle}</span> <span class="today-case-title">{group.nodeTitle}</span>
<span class="today-case-type">{nodeKindLabel(group.nodeKind)}</span> <span class="today-case-type">{nodeKindLabel(group.nodeKind)}</span>
{#if group.events}<span class="today-case-count">{group.events.length} {pluralize(group.events.length, 'событие', 'события', 'событий')}</span>{/if} {#if group.events}<span class="today-case-count">{group.events.length} {pluralize(group.events.length, t('today.plural.event_one'), t('today.plural.event_few'), t('today.plural.event_many'))}</span>{/if}
<span class="today-case-time">{formatTime(group.lastActivityAt)}</span> <span class="today-case-time">{formatTime(group.lastActivityAt)}</span>
</div> </div>
{#if group.events && group.events.length > 0} {#if group.events && group.events.length > 0}
@ -1357,14 +1365,14 @@
{/each} {/each}
</div> </div>
{:else} {:else}
<div class="today-events-empty">Изменён сегодня</div> <div class="today-events-empty">{t('today.changedCases')}</div>
{/if} {/if}
</div> </div>
{/each} {/each}
{#if todayDashboard.events && todayDashboard.events.length > 0} {#if todayDashboard.events && todayDashboard.events.length > 0}
<div class="today-timeline"> <div class="today-timeline">
<h3>Лента за сегодня</h3> <h3>{t('today.timeline')}</h3>
{#each todayDashboard.events as ev} {#each todayDashboard.events as ev}
<div class="timeline-event" role="button" tabindex="0" on:click={() => openNodeById(ev.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(ev.nodeId)}> <div class="timeline-event" role="button" tabindex="0" on:click={() => openNodeById(ev.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(ev.nodeId)}>
<span class="timeline-dot"></span> <span class="timeline-dot"></span>
@ -1377,8 +1385,8 @@
{/if} {/if}
{:else} {:else}
<div class="today-empty"> <div class="today-empty">
<p>Сегодня пока тихо</p> <p>{t('today.empty')}</p>
<p class="hint">Здесь появятся дела, заметки, файлы и действия, с которыми вы работали сегодня.</p> <p class="hint">{t('today.emptyHint')}</p>
</div> </div>
{/if} {/if}
</div> </div>
@ -1386,10 +1394,10 @@
{:else if selectedSection === 'activity'} {:else if selectedSection === 'activity'}
<div class="activity-feed"> <div class="activity-feed">
<div class="activity-feed-header"> <div class="activity-feed-header">
<h2>Активность</h2> <h2>{t('activity.title')}</h2>
</div> </div>
{#if activityFeed.length === 0} {#if activityFeed.length === 0}
<div class="empty-state"><p>Активность пока не зафиксирована</p></div> <div class="empty-state"><p>{t('activity.empty')}</p></div>
{:else} {:else}
<div class="activity-feed-events"> <div class="activity-feed-events">
{#each activityFeed as ev} {#each activityFeed as ev}
@ -1411,30 +1419,30 @@
{:else} {:else}
<div class="welcome"> <div class="welcome">
<h2>Верстак</h2> <h2>{t('welcome.title')}</h2>
{#if loading}<p>Загрузка...</p> {#if loading}<p>{t('common.loading')}</p>
{:else if sections.length > 0} {:else if sections.length > 0}
<p>Выберите раздел в боковой панели.</p> <p>{t('welcome.selectSection')}</p>
<p class="hint">Или создайте новое дело кнопкой «+».</p> <p class="hint">{t('welcome.createCase')}</p>
{:else if error}<p class="error-text">Ошибка: {error}</p>{/if} {:else if error}<p class="error-text">{t('common.error')} {error}</p>{/if}
</div> </div>
{/if} {/if}
{#if !noteEditor && !selectedNode && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'} {#if !noteEditor && !selectedNode && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'}
<div class="fab" on:click={openCreateNode} title="Добавить дело">+</div> <div class="fab" on:click={openCreateNode} title={t('welcome.addCase')}>+</div>
{/if} {/if}
{#if showCreateNode} {#if showCreateNode}
<div class="modal-overlay" on:click|self={cancelCreateNode}> <div class="modal-overlay" on:click|self={cancelCreateNode}>
<div class="modal"> <div class="modal">
<h3>Новое дело</h3> <h3>{t('case.new')}</h3>
<div class="form-group"> <div class="form-group">
<label>Название</label> <label>{t('common.name')}</label>
<input type="text" placeholder="Название дела" bind:value={newNodeTitle} <input type="text" placeholder={t('case.namePlaceholder')} bind:value={newNodeTitle}
on:keydown={(e) => e.key === 'Enter' && submitCreateNode()} autofocus /> on:keydown={(e) => e.key === 'Enter' && submitCreateNode()} autofocus />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Раздел</label> <label>{t('common.section')}</label>
<select bind:value={newNodeSection}> <select bind:value={newNodeSection}>
{#each sections.filter(s => s.id !== 'today' && s.id !== 'inbox' && s.id !== 'activity') as s} {#each sections.filter(s => s.id !== 'today' && s.id !== 'inbox' && s.id !== 'activity') as s}
<option value={s.id}>{s.label}</option> <option value={s.id}>{s.label}</option>
@ -1443,9 +1451,9 @@
</div> </div>
{#if templates.length > 0} {#if templates.length > 0}
<div class="form-group"> <div class="form-group">
<label>Шаблон (опционально)</label> <label>{t('template.optional')}</label>
<select bind:value={newNodeTemplate}> <select bind:value={newNodeTemplate}>
<option value="">Без шаблона</option> <option value="">{t('template.optionNone')}</option>
{#each templates as t} {#each templates as t}
<option value={t.name}>{t.name}{t.description ? ' — ' + t.description : ''}</option> <option value={t.name}>{t.name}{t.description ? ' — ' + t.description : ''}</option>
{/each} {/each}
@ -1453,8 +1461,8 @@
</div> </div>
{/if} {/if}
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-primary" on:click={submitCreateNode}>Создать</button> <button class="btn btn-primary" on:click={submitCreateNode}>{t('common.create')}</button>
<button class="btn" on:click={cancelCreateNode}>Отмена</button> <button class="btn" on:click={cancelCreateNode}>{t('common.cancel')}</button>
</div> </div>
</div> </div>
</div> </div>
@ -1463,14 +1471,14 @@
{#if showCreateAction} {#if showCreateAction}
<div class="modal-overlay" on:click|self={cancelCreateAction}> <div class="modal-overlay" on:click|self={cancelCreateAction}>
<div class="modal"> <div class="modal">
<h3>Новое действие</h3> <h3>{t('action.newAction')}</h3>
<div class="form-group"> <div class="form-group">
<label>Название</label> <label>{t('common.name')}</label>
<input type="text" placeholder="Например: Открыть сайт" bind:value={newActionTitle} <input type="text" placeholder={t('action.namePlaceholder')} bind:value={newActionTitle}
on:keydown={(e) => e.key === 'Enter' && submitCreateAction()} autofocus /> on:keydown={(e) => e.key === 'Enter' && submitCreateAction()} autofocus />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Тип</label> <label>{t('common.type')}</label>
<select bind:value={newActionKind}> <select bind:value={newActionKind}>
{#each actionKinds as k} {#each actionKinds as k}
<option value={k.id}>{k.label}</option> <option value={k.id}>{k.label}</option>
@ -1478,14 +1486,14 @@
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>{newActionKind === 'open_url' ? 'URL' : newActionKind === 'open_folder' || newActionKind === 'open_file' ? 'Путь' : 'Команда'}</label> <label>{newActionKind === 'open_url' ? t('action.dataUrl') : newActionKind === 'open_folder' || newActionKind === 'open_file' ? t('action.dataPath') : t('action.dataCommand')}</label>
<input type="text" placeholder={newActionKind === 'open_url' ? 'https://example.com' : newActionKind === 'open_folder' || newActionKind === 'open_file' ? '/path/to/file' : 'команда'} <input type="text" placeholder={newActionKind === 'open_url' ? t('action.urlPlaceholder') : newActionKind === 'open_folder' || newActionKind === 'open_file' ? t('action.pathPlaceholder') : t('action.commandPlaceholder')}
bind:value={newActionData} bind:value={newActionData}
on:keydown={(e) => e.key === 'Enter' && submitCreateAction()} /> on:keydown={(e) => e.key === 'Enter' && submitCreateAction()} />
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-primary" on:click={submitCreateAction}>Создать</button> <button class="btn btn-primary" on:click={submitCreateAction}>{t('common.create')}</button>
<button class="btn" on:click={cancelCreateAction}>Отмена</button> <button class="btn" on:click={cancelCreateAction}>{t('common.cancel')}</button>
</div> </div>
</div> </div>
</div> </div>
@ -1494,11 +1502,11 @@
{#if showImportDialog && importSummary} {#if showImportDialog && importSummary}
<div class="modal-overlay" on:click|self={cancelImport}> <div class="modal-overlay" on:click|self={cancelImport}>
<div class="modal"> <div class="modal">
<h3>Добавить в «{selectedNode ? selectedNode.title : ''}»</h3> <h3>{t('file.importTitle')} «{selectedNode ? selectedNode.title : ''}»</h3>
<div class="import-summary"> <div class="import-summary">
<div class="summary-row"><span>Файлов:</span><span>{importSummary.files}</span></div> <div class="summary-row"><span>{t('file.importFiles')}</span><span>{importSummary.files}</span></div>
<div class="summary-row"><span>Папок:</span><span>{importSummary.folders}</span></div> <div class="summary-row"><span>{t('file.importFolders')}</span><span>{importSummary.folders}</span></div>
<div class="summary-row"><span>Размер:</span><span>{(importSummary.totalBytes / 1024).toFixed(1)} KB</span></div> <div class="summary-row"><span>{t('file.importSize')}</span><span>{(importSummary.totalBytes / 1024).toFixed(1)} KB</span></div>
{#if importSummary.isDangerous} {#if importSummary.isDangerous}
<div class="summary-warn"> <div class="summary-warn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
@ -1507,9 +1515,9 @@
{/if} {/if}
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-primary" on:click={() => confirmImport('copy')}>Скопировать</button> <button class="btn btn-primary" on:click={() => confirmImport('copy')}>{t('file.importCopy')}</button>
<button class="btn" on:click={() => confirmImport('link')}>Привязать</button> <button class="btn" on:click={() => confirmImport('link')}>{t('file.importLink')}</button>
<button class="btn" on:click={cancelImport}>Отмена</button> <button class="btn" on:click={cancelImport}>{t('common.cancel')}</button>
</div> </div>
</div> </div>
</div> </div>
@ -1518,9 +1526,9 @@
{#if showRename} {#if showRename}
<div class="modal-overlay" on:click|self={cancelRename}> <div class="modal-overlay" on:click|self={cancelRename}>
<div class="modal"> <div class="modal">
<h3>Переименовать</h3> <h3>{t('rename.title')}</h3>
<div class="form-group"> <div class="form-group">
<label>Новое имя</label> <label>{t('common.newName')}</label>
<input type="text" bind:value={renameValue} <input type="text" bind:value={renameValue}
on:keydown={onRenameKeydown} /> on:keydown={onRenameKeydown} />
</div> </div>
@ -1528,8 +1536,8 @@
<div class="rename-error">{renameError}</div> <div class="rename-error">{renameError}</div>
{/if} {/if}
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-primary" on:click={submitRename}>Переименовать</button> <button class="btn btn-primary" on:click={submitRename}>{t('common.rename')}</button>
<button class="btn" on:click={cancelRename}>Отмена</button> <button class="btn" on:click={cancelRename}>{t('common.cancel')}</button>
</div> </div>
</div> </div>
</div> </div>
@ -1549,69 +1557,69 @@
{#if showSettings} {#if showSettings}
<div class="modal-overlay" on:click|self={closeSettings}> <div class="modal-overlay" on:click|self={closeSettings}>
<div class="modal modal-sync"> <div class="modal modal-sync">
<h3>Настройки синхронизации</h3> <h3>{t('sync.settings')}</h3>
{#if syncStatus} {#if syncStatus}
<div class="sync-status"> <div class="sync-status">
<div class="sync-row"> <div class="sync-row">
<span class="sync-label">Статус</span> <span class="sync-label">{t('sync.status')}</span>
<span class="sync-value"> <span class="sync-value">
{#if syncStatus.revoked} {#if syncStatus.revoked}
<span style="color:#ff6b6b">Отозвано</span> <span style="color:#ff6b6b">{t('sync.revoked')}</span>
{:else if syncStatus.connected} {:else if syncStatus.connected}
<span style="color:#34d399">Подключено</span> <span style="color:#34d399">{t('sync.connected')}</span>
{:else if syncStatus.configured} {:else if syncStatus.configured}
<span style="color:#f59e0b">Не подключено</span> <span style="color:#f59e0b">{t('sync.notConnected')}</span>
{:else} {:else}
<span style="color:#666">Отключена</span> <span style="color:#666">{t('sync.disabled')}</span>
{/if} {/if}
</span> </span>
</div> </div>
{#if syncStatus.serverUrl} {#if syncStatus.serverUrl}
<div class="sync-row"><span class="sync-label">Сервер</span><span class="sync-value mono">{syncStatus.serverUrl}</span></div> <div class="sync-row"><span class="sync-label">{t('sync.server')}</span><span class="sync-value mono">{syncStatus.serverUrl}</span></div>
{/if} {/if}
{#if syncStatus.deviceName} {#if syncStatus.deviceName}
<div class="sync-row"><span class="sync-label">Устройство</span><span class="sync-value">{syncStatus.deviceName}</span></div> <div class="sync-row"><span class="sync-label">{t('sync.device')}</span><span class="sync-value">{syncStatus.deviceName}</span></div>
{/if} {/if}
{#if syncStatus.deviceId && !syncStatus.deviceName} {#if syncStatus.deviceId && !syncStatus.deviceName}
<div class="sync-row"><span class="sync-label">ID устройства</span><span class="sync-value mono">{syncStatus.deviceId}</span></div> <div class="sync-row"><span class="sync-label">{t('sync.deviceId')}</span><span class="sync-value mono">{syncStatus.deviceId}</span></div>
{/if} {/if}
<div class="sync-row"><span class="sync-label">Неотправлено</span><span class="sync-value">{syncStatus.unpushedOps}</span></div> <div class="sync-row"><span class="sync-label">{t('sync.unpushed')}</span><span class="sync-value">{syncStatus.unpushedOps}</span></div>
{#if syncStatus.lastSyncAt} {#if syncStatus.lastSyncAt}
<div class="sync-row"><span class="sync-label">Последняя синх.</span><span class="sync-value">{syncStatus.lastSyncAt}</span></div> <div class="sync-row"><span class="sync-label">{t('sync.lastSync')}</span><span class="sync-value">{syncStatus.lastSyncAt}</span></div>
{/if} {/if}
</div> </div>
{/if} {/if}
{#if syncStatus?.configured} {#if syncStatus?.configured}
<div class="sync-connected-actions"> <div class="sync-connected-actions">
<button class="btn" on:click={runSyncNow} disabled={syncLoading}>Синхронизировать</button> <button class="btn" on:click={runSyncNow} disabled={syncLoading}>{t('sync.syncNow')}</button>
<button class="btn btn-danger" on:click={disconnectSync} disabled={syncLoading}>Отключиться</button> <button class="btn btn-danger" on:click={disconnectSync} disabled={syncLoading}>{t('sync.disconnect')}</button>
</div> </div>
{:else} {:else}
<div class="form-group"> <div class="form-group">
<label>URL сервера</label> <label>{t('sync.serverUrl')}</label>
<input type="text" placeholder="https://example.com:47732" bind:value={syncServerUrl} /> <input type="text" placeholder={t('sync.serverUrlPlaceholder')} bind:value={syncServerUrl} />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Логин</label> <label>{t('sync.username')}</label>
<input type="text" placeholder="username" bind:value={syncUsername} /> <input type="text" placeholder={t('sync.usernamePlaceholder')} bind:value={syncUsername} />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Пароль</label> <label>{t('sync.password')}</label>
<input type="password" placeholder="password" bind:value={syncPassword} /> <input type="password" placeholder={t('sync.passwordPlaceholder')} bind:value={syncPassword} />
</div> </div>
<div class="modal-actions" style="margin-top:12px"> <div class="modal-actions" style="margin-top:12px">
<button class="btn" on:click={testConnection} disabled={syncLoading || !syncServerUrl}>Проверить</button> <button class="btn" on:click={testConnection} disabled={syncLoading || !syncServerUrl}>{t('sync.test')}</button>
<button class="btn btn-primary" on:click={saveSyncConfig} disabled={syncLoading}>Подключиться</button> <button class="btn btn-primary" on:click={saveSyncConfig} disabled={syncLoading}>{t('sync.connect')}</button>
</div> </div>
{/if} {/if}
<div style="margin-top:16px;padding-top:16px;border-top:1px solid #2a2a3c"> <div style="margin-top:16px;padding-top:16px;border-top:1px solid #2a2a3c">
<div class="form-group"> <div class="form-group">
<label>Автосинхронизация (мин, 0 = отключено)</label> <label>{t('sync.autoSync')}</label>
<input type="number" placeholder="0" bind:value={syncInterval} min="0" /> <input type="number" placeholder="0" bind:value={syncInterval} min="0" />
</div> </div>
<button class="btn" on:click={saveSyncInterval} disabled={syncLoading}>Сохранить интервал</button> <button class="btn" on:click={saveSyncInterval} disabled={syncLoading}>{t('sync.saveInterval')}</button>
</div> </div>
{#if syncResult} {#if syncResult}
@ -1619,7 +1627,7 @@
{/if} {/if}
<div class="modal-actions" style="margin-top:12px"> <div class="modal-actions" style="margin-top:12px">
<button class="btn" on:click={closeSettings}>Закрыть</button> <button class="btn" on:click={closeSettings}>{t('common.close')}</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import FileIcon from './lib/FileIcon.svelte' import FileIcon from './lib/FileIcon.svelte'
import { formatFileSize, formatFileType, getFileKind } from './lib/fileUtils.js' import { formatFileSize, formatFileType, getFileKind } from './lib/fileUtils.js'
import { t } from './lib/i18n'
export let item export let item
export let selected = false export let selected = false
@ -26,7 +27,7 @@
if (clickTimer) { if (clickTimer) {
clearTimeout(clickTimer) clearTimeout(clickTimer)
clickTimer = null clickTimer = null
// Double click → открыть // Double click → open
if (isFolder) { if (isFolder) {
dispatch('navigate', item.id) dispatch('navigate', item.id)
} else { } else {
@ -35,7 +36,7 @@
} else { } else {
clickTimer = setTimeout(() => { clickTimer = setTimeout(() => {
clickTimer = null clickTimer = null
// Single click → выделить // Single click → select
dispatch('selectOne', item.id) dispatch('selectOne', item.id)
}, 250) }, 250)
} }
@ -122,7 +123,7 @@
on:dragstart={handleDragStart} on:dragstart={handleDragStart}
on:dragover={handleDragOver} on:dragover={handleDragOver}
on:drop={handleDrop} on:drop={handleDrop}
aria-label={isFolder ? `Папка ${item.name}` : `Файл ${item.name}`}> aria-label={isFolder ? t('file.ariaFolder') + ' ' + item.name : t('file.ariaFile') + ' ' + item.name}>
<div class="file-row-icon"> <div class="file-row-icon">
<FileIcon {kind} size={22}/> <FileIcon {kind} size={22}/>
</div> </div>
@ -138,13 +139,13 @@
</div> </div>
<div class="file-row-actions"> <div class="file-row-actions">
{#if !isFolder} {#if !isFolder}
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title="Предпросмотр" aria-label="Предпросмотр"> <button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title={t('file.preview')} aria-label={t('file.preview')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/> <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/> <circle cx="12" cy="12" r="3"/>
</svg> </svg>
</button> </button>
<button class="action-btn" on:click|stopPropagation={handleOpenExternal} title="Открыть во внешней программе" aria-label="Открыть внешне"> <button class="action-btn" on:click|stopPropagation={handleOpenExternal} title={t('file.openExternal')} aria-label={t('file.openExternal')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/> <polyline points="15 3 21 3 21 9"/>
@ -152,21 +153,21 @@
</svg> </svg>
</button> </button>
{:else} {:else}
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Открыть папку" aria-label="Открыть папку"> <button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title={t('file.openFolder')} aria-label={t('file.openFolder')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/> <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
<line x1="9" y1="14" x2="15" y2="14"/> <line x1="9" y1="14" x2="15" y2="14"/>
</svg> </svg>
</button> </button>
{/if} {/if}
<button class="action-btn" on:click|stopPropagation={toggleMenu} title="Ещё" aria-label="Ещё" aria-expanded={menuOpen}> <button class="action-btn" on:click|stopPropagation={toggleMenu} title={t('file.more')} aria-label={t('file.more')} aria-expanded={menuOpen}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="2"/> <circle cx="12" cy="5" r="2"/>
<circle cx="12" cy="12" r="2"/> <circle cx="12" cy="12" r="2"/>
<circle cx="12" cy="19" r="2"/> <circle cx="12" cy="19" r="2"/>
</svg> </svg>
</button> </button>
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title="Удалить" aria-label="Удалить"> <button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title={t('common.delete')} aria-label={t('common.delete')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/> <polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
@ -181,39 +182,39 @@
<div class="menu" on:click|stopPropagation role="menu"> <div class="menu" on:click|stopPropagation role="menu">
<button class="menu-item" on:click={handleOpen} role="menuitem"> <button class="menu-item" on:click={handleOpen} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
Открыть {t('common.open')}
</button> </button>
<button class="menu-item" on:click={handleOpenExternal} role="menuitem"> <button class="menu-item" on:click={handleOpenExternal} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Открыть во внешней программе {t('file.openExternal')}
</button> </button>
{#if isFolder} {#if isFolder}
<button class="menu-item" on:click={handleShowInFolder} role="menuitem"> <button class="menu-item" on:click={handleShowInFolder} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
Показать в проводнике {t('file.showInExplorer')}
</button> </button>
{/if} {/if}
<div class="menu-sep"></div> <div class="menu-sep"></div>
<button class="menu-item" on:click={handleRename} role="menuitem"> <button class="menu-item" on:click={handleRename} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Переименовать {t('common.rename')}
</button> </button>
<button class="menu-item" on:click={handleDuplicate} role="menuitem"> <button class="menu-item" on:click={handleDuplicate} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Дублировать {t('common.duplicate')}
</button> </button>
<button class="menu-item" on:click={handleCut} role="menuitem"> <button class="menu-item" on:click={handleCut} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="8.12" y1="8.12" x2="20" y2="20"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="8.12" y1="8.12" x2="20" y2="20"/></svg>
Вырезать {t('common.cut')}
</button> </button>
<button class="menu-item" on:click={handleCopy} role="menuitem"> <button class="menu-item" on:click={handleCopy} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Копировать {t('common.copy')}
</button> </button>
<div class="menu-sep"></div> <div class="menu-sep"></div>
<button class="menu-item menu-item-danger" on:click={handleDelete} role="menuitem"> <button class="menu-item menu-item-danger" on:click={handleDelete} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
Удалить {t('common.delete')}
</button> </button>
</div> </div>
{/if} {/if}

View File

@ -1,11 +1,12 @@
<script> <script>
export let title = 'Подтверждение'
export let message = ''
export let confirmText = 'Удалить'
export let cancelText = 'Отмена'
export let danger = false
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import { t } from './i18n'
export let title = t('common.confirm')
export let message = ''
export let confirmText = t('common.delete')
export let cancelText = t('common.cancel')
export let danger = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
</script> </script>

View File

@ -2,6 +2,7 @@
import { createEventDispatcher, onMount, onDestroy } from 'svelte' import { createEventDispatcher, onMount, onDestroy } from 'svelte'
import FileIcon from './FileIcon.svelte' import FileIcon from './FileIcon.svelte'
import { formatFileSize, formatMimeType, getFileKind, isImageFile, isTextFile, isPdfFile, isMarkdownFile } from './fileUtils.js' import { formatFileSize, formatMimeType, getFileKind, isImageFile, isTextFile, isPdfFile, isMarkdownFile } from './fileUtils.js'
import { t } from './i18n'
export let item export let item
export let content = '' // base64 data URI or text content export let content = '' // base64 data URI or text content
@ -43,7 +44,7 @@
</div> </div>
<div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div> <div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div>
<div class="preview-actions"> <div class="preview-actions">
<button class="action-btn" on:click={handleOpenExternal} title="Открыть во внешней программе" aria-label="Открыть внешне"> <button class="action-btn" on:click={handleOpenExternal} title={t('file.openExternal')} aria-label={t('file.openExternal')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/> <polyline points="15 3 21 3 21 9"/>
@ -60,11 +61,11 @@
</header> </header>
<div class="preview-body"> <div class="preview-body">
{#if loading} {#if loading}
<div class="preview-status"><p>Загрузка...</p></div> <div class="preview-status"><p>{t('common.loading')}</p></div>
{:else if error} {:else if error}
<div class="preview-status"> <div class="preview-status">
<p>{error}</p> <p>{error}</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button> <button class="btn btn-sm" on:click={handleOpenExternal}>{t('file.openExternal')}</button>
</div> </div>
{:else if showImage && content} {:else if showImage && content}
<div class="preview-image-container"> <div class="preview-image-container">
@ -79,14 +80,14 @@
</div> </div>
{:else} {:else}
<div class="preview-status"> <div class="preview-status">
<p>Предпросмотр PDF недоступен.</p> <p>{t('file.pdfUnavailable')}</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button> <button class="btn btn-sm" on:click={handleOpenExternal}>{t('file.openExternal')}</button>
</div> </div>
{/if} {/if}
{:else} {:else}
<div class="preview-status"> <div class="preview-status">
<p>Предпросмотр недоступен для этого типа файлов.</p> <p>{t('file.previewUnavailable')}</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button> <button class="btn btn-sm" on:click={handleOpenExternal}>{t('file.openExternal')}</button>
</div> </div>
{/if} {/if}
</div> </div>

View File

@ -1,3 +1,5 @@
import { t } from './i18n'
export function formatFileSize(bytes) { export function formatFileSize(bytes) {
if (bytes == null || bytes < 0) return '—' if (bytes == null || bytes < 0) return '—'
if (bytes === 0) return '0 B' if (bytes === 0) return '0 B'
@ -8,51 +10,51 @@ export function formatFileSize(bytes) {
} }
const mimeLabels = { const mimeLabels = {
'image/jpeg': 'Изображение JPEG', 'image/jpeg': t('mime.jpeg'),
'image/png': 'Изображение PNG', 'image/png': t('mime.png'),
'image/gif': 'Изображение GIF', 'image/gif': t('mime.gif'),
'image/webp': 'Изображение WebP', 'image/webp': t('mime.webp'),
'image/svg+xml': 'Изображение SVG', 'image/svg+xml': t('mime.svg'),
'image/bmp': 'Изображение BMP', 'image/bmp': t('mime.bmp'),
'image/tiff': 'Изображение TIFF', 'image/tiff': t('mime.tiff'),
'image/avif': 'Изображение AVIF', 'image/avif': t('mime.avif'),
'application/pdf': 'PDF документ', 'application/pdf': t('mime.pdf'),
'application/msword': 'Документ Word', 'application/msword': t('mime.word'),
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Документ Word', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': t('mime.word'),
'application/vnd.ms-excel': 'Таблица Excel', 'application/vnd.ms-excel': t('mime.excel'),
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Таблица Excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': t('mime.excel'),
'application/vnd.ms-powerpoint': 'Презентация PowerPoint', 'application/vnd.ms-powerpoint': t('mime.ppt'),
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Презентация PowerPoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation': t('mime.ppt'),
'application/zip': 'ZIP архив', 'application/zip': t('mime.zip'),
'application/gzip': 'GZIP архив', 'application/gzip': t('mime.gzip'),
'application/x-tar': 'TAR архив', 'application/x-tar': t('mime.tar'),
'application/x-7z-compressed': '7z архив', 'application/x-7z-compressed': t('mime.sevenz'),
'application/x-rar-compressed': 'RAR архив', 'application/x-rar-compressed': t('mime.rar'),
'text/plain': 'Текстовый файл', 'text/plain': t('mime.text'),
'text/html': 'HTML файл', 'text/html': t('mime.html'),
'text/css': 'CSS файл', 'text/css': t('mime.css'),
'text/javascript': 'JavaScript файл', 'text/javascript': t('mime.js'),
'application/json': 'JSON файл', 'application/json': t('mime.json'),
'application/xml': 'XML файл', 'application/xml': t('mime.xml'),
'application/x-yaml': 'YAML файл', 'application/x-yaml': t('mime.yaml'),
'application/octet-stream': 'Бинарный файл', 'application/octet-stream': t('mime.binary'),
'application/x-msdos-program': 'Исполняемый файл', 'application/x-msdos-program': t('mime.executable'),
'inode/directory': 'Папка', 'inode/directory': t('mime.folder'),
} }
export function formatMimeType(mime) { export function formatMimeType(mime) {
if (!mime) return 'Неизвестно' if (!mime) return t('mime.unknown')
return mimeLabels[mime] || mime return mimeLabels[mime] || mime
} }
export function formatFileType(item) { export function formatFileType(item) {
if (item.type === 'folder') return 'Папка' if (item.type === 'folder') return t('mime.folder')
const mime = (item.mime || '').toLowerCase() const mime = (item.mime || '').toLowerCase()
if (mimeLabels[mime]) return mimeLabels[mime] if (mimeLabels[mime]) return mimeLabels[mime]
const name = (item.name || '').toLowerCase() const name = (item.name || '').toLowerCase()
const ext = name.split('.').pop() const ext = name.split('.').pop()
if (ext) return ext.toUpperCase() if (ext) return ext.toUpperCase()
return 'Файл' return t('mime.file')
} }
export function getFileKind(item) { export function getFileKind(item) {

View File

@ -245,6 +245,14 @@ export default {
'error.nameEmpty': 'Имя не может быть пустым', 'error.nameEmpty': 'Имя не может быть пустым',
'error.nameInvalid': 'Недопустимое имя', 'error.nameInvalid': 'Недопустимое имя',
'error.selectCaseFirst': 'Сначала выберите дело', 'error.selectCaseFirst': 'Сначала выберите дело',
'common.open': 'Открыть',
'delete.files': 'файлов ({count})',
'file.namePrompt': 'Введите имя файла:',
'file.pdfUnavailable': 'Предпросмотр PDF недоступен.',
'file.previewUnavailable': 'Предпросмотр недоступен для этого типа файлов.',
'case.new': 'Новое дело',
'case.namePlaceholder': 'Название дела',
'error.generic': 'Произошла ошибка', 'error.generic': 'Произошла ошибка',
'error.invalidCredentials': 'Неверный логин или пароль', 'error.invalidCredentials': 'Неверный логин или пароль',
'error.accountBlocked': 'Аккаунт заблокирован', 'error.accountBlocked': 'Аккаунт заблокирован',

View File

@ -15,29 +15,29 @@ import (
// Kind constants. // Kind constants.
const ( const (
KindOpenURL = "open_url" KindOpenURL = "open_url"
KindOpenFile = "open_file" KindOpenFile = "open_file"
KindOpenFolder = "open_folder" KindOpenFolder = "open_folder"
KindRunCommand = "run_command" KindRunCommand = "run_command"
KindRunScript = "run_script" KindRunScript = "run_script"
KindOpenTerminal = "open_terminal" KindOpenTerminal = "open_terminal"
KindLaunchApp = "launch_app" KindLaunchApp = "launch_app"
) )
// Record represents an action attached to a node. // Record represents an action attached to a node.
type Record struct { type Record struct {
ID string `json:"id"` ID string `json:"id"`
NodeID string `json:"node_id"` NodeID string `json:"node_id"`
Title string `json:"title"` Title string `json:"title"`
Kind string `json:"kind"` Kind string `json:"kind"`
Command string `json:"command,omitempty"` Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"` Args []string `json:"args,omitempty"`
WorkingDir string `json:"working_dir,omitempty"` WorkingDir string `json:"working_dir,omitempty"`
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
ConfirmRequired bool `json:"confirm_required"` ConfirmRequired bool `json:"confirm_required"`
CaptureOutput bool `json:"capture_output"` CaptureOutput bool `json:"capture_output"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
// RunResult is returned after executing an action. // RunResult is returned after executing an action.

View File

@ -130,10 +130,10 @@ func TestGetNotFound(t *testing.T) {
func TestKindLabel(t *testing.T) { func TestKindLabel(t *testing.T) {
cases := map[string]string{ cases := map[string]string{
KindOpenURL: "Открыть URL", KindOpenURL: "Открыть URL",
KindRunCommand: "Запустить команду", KindRunCommand: "Запустить команду",
KindOpenFolder: "Открыть папку", KindOpenFolder: "Открыть папку",
"unknown": "unknown", "unknown": "unknown",
} }
for kind, want := range cases { for kind, want := range cases {
got := KindLabel(kind) got := KindLabel(kind)

View File

@ -10,30 +10,30 @@ import (
// Event types. // Event types.
const ( const (
TypeNoteCreated = "note_created" TypeNoteCreated = "note_created"
TypeNoteUpdated = "note_updated" TypeNoteUpdated = "note_updated"
TypeFileAdded = "file_added" TypeFileAdded = "file_added"
TypeFileDeleted = "file_deleted" TypeFileDeleted = "file_deleted"
TypeFileRenamed = "file_renamed" TypeFileRenamed = "file_renamed"
TypeFileCopied = "file_copied" TypeFileCopied = "file_copied"
TypeFileMoved = "file_moved" TypeFileMoved = "file_moved"
TypeFolderAdded = "folder_added" TypeFolderAdded = "folder_added"
TypeFolderDeleted = "folder_deleted" TypeFolderDeleted = "folder_deleted"
TypeFolderRenamed = "folder_renamed" TypeFolderRenamed = "folder_renamed"
TypeNodeCreated = "node_created" TypeNodeCreated = "node_created"
TypeNodeUpdated = "node_updated" TypeNodeUpdated = "node_updated"
TypeActionCreated = "action_created" TypeActionCreated = "action_created"
TypeActionDone = "action_done" TypeActionDone = "action_done"
TypeWorklogAdded = "worklog_added" TypeWorklogAdded = "worklog_added"
) )
// Target types. // Target types.
const ( const (
TargetNote = "note" TargetNote = "note"
TargetFile = "file" TargetFile = "file"
TargetFolder = "folder" TargetFolder = "folder"
TargetAction = "action" TargetAction = "action"
TargetNode = "node" TargetNode = "node"
TargetWorklog = "worklog" TargetWorklog = "worklog"
) )

View File

@ -23,7 +23,7 @@ type Record struct {
ID string `json:"id"` ID string `json:"id"`
NodeID string `json:"node_id"` NodeID string `json:"node_id"`
Filename string `json:"filename"` Filename string `json:"filename"`
Path string `json:"path"` // relative to vault root Path string `json:"path"` // relative to vault root
StorageMode string `json:"storage_mode"` // "vault" | "external" StorageMode string `json:"storage_mode"` // "vault" | "external"
Size int64 `json:"size"` Size int64 `json:"size"`
SHA256 string `json:"sha256,omitempty"` SHA256 string `json:"sha256,omitempty"`

View File

@ -307,11 +307,11 @@ func TestPreviewImportDir(t *testing.T) {
func TestGuessMIME(t *testing.T) { func TestGuessMIME(t *testing.T) {
cases := map[string]string{ cases := map[string]string{
"a.md": "text/plain", "a.md": "text/plain",
"a.png": "image/png", "a.png": "image/png",
"a.pdf": "application/pdf", "a.pdf": "application/pdf",
"a.docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "a.docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"a.go": "text/plain", "a.go": "text/plain",
"a.unknown": "application/octet-stream", "a.unknown": "application/octet-stream",
} }
for name, want := range cases { for name, want := range cases {

View File

@ -7,19 +7,19 @@ import (
// Node is the central entity of Verstak — a tree item that can be // Node is the central entity of Verstak — a tree item that can be
// a case, folder, note, document, etc. // a case, folder, note, document, etc.
type Node struct { type Node struct {
ID string `json:"id"` ID string `json:"id"`
ParentID *string `json:"parent_id,omitempty"` ParentID *string `json:"parent_id,omitempty"`
Type string `json:"type"` Type string `json:"type"`
Title string `json:"title"` Title string `json:"title"`
Slug string `json:"slug"` Slug string `json:"slug"`
Path *string `json:"path,omitempty"` Path *string `json:"path,omitempty"`
Section string `json:"section,omitempty"` Section string `json:"section,omitempty"`
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"` DeletedAt *time.Time `json:"deleted_at,omitempty"`
Revision int `json:"revision"` Revision int `json:"revision"`
DeviceID *string `json:"device_id,omitempty"` DeviceID *string `json:"device_id,omitempty"`
} }
// IsDeleted reports whether the node has been soft-deleted. // IsDeleted reports whether the node has been soft-deleted.
@ -40,6 +40,6 @@ type Meta struct {
// NodeWithMeta bundles a node and its metadata entries. // NodeWithMeta bundles a node and its metadata entries.
type NodeWithMeta struct { type NodeWithMeta struct {
Node Node `json:"node"` Node Node `json:"node"`
Meta []Meta `json:"meta"` Meta []Meta `json:"meta"`
} }

View File

@ -62,8 +62,8 @@ func (m *Manager) Discover() {
meta.Name = e.Name() meta.Name = e.Name()
} }
m.plugins = append(m.plugins, Plugin{ m.plugins = append(m.plugins, Plugin{
Meta: meta, Meta: meta,
Dir: filepath.Join(pluginsDir, e.Name()), Dir: filepath.Join(pluginsDir, e.Name()),
Active: true, Active: true,
}) })
} }
@ -116,13 +116,13 @@ func (m *Manager) Templates() []TemplateDefinition {
// TemplateDefinition describes a predefined tree of nodes. // TemplateDefinition describes a predefined tree of nodes.
type TemplateDefinition struct { type TemplateDefinition struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"` Icon string `json:"icon,omitempty"`
Plugin string // source plugin name Plugin string // source plugin name
RootType string `json:"root_type"` RootType string `json:"root_type"`
Tree []TreeNode `json:"tree"` Tree []TreeNode `json:"tree"`
Meta []NodeMeta `json:"meta,omitempty"` Meta []NodeMeta `json:"meta,omitempty"`
} }
// TreeNode is a single item in a template tree. // TreeNode is a single item in a template tree.

View File

@ -8,8 +8,8 @@ import (
"verstak/internal/core/actions" "verstak/internal/core/actions"
"verstak/internal/core/files" "verstak/internal/core/files"
"verstak/internal/core/notes"
"verstak/internal/core/nodes" "verstak/internal/core/nodes"
"verstak/internal/core/notes"
"verstak/internal/core/search" "verstak/internal/core/search"
"verstak/internal/core/storage" "verstak/internal/core/storage"
"verstak/internal/core/vault" "verstak/internal/core/vault"

View File

@ -57,12 +57,12 @@ CREATE TABLE IF NOT EXISTS _schema_ver (
` `
var migrationFiles = map[int]string{ var migrationFiles = map[int]string{
1: migration001, 1: migration001,
2: migration002, 2: migration002,
3: migration003, 3: migration003,
4: migration004, 4: migration004,
5: migration005, 5: migration005,
6: migration006, 6: migration006,
// 7: migration007 (FTS5) — created lazily by search.Rebuild() // 7: migration007 (FTS5) — created lazily by search.Rebuild()
8: migration008, 8: migration008,
9: migration009, 9: migration009,

View File

@ -29,7 +29,7 @@ func NewClient(serverURL, apiKey, deviceID, vaultRoot string) *Client {
APIKey: apiKey, APIKey: apiKey,
DeviceID: deviceID, DeviceID: deviceID,
VaultRoot: vaultRoot, VaultRoot: vaultRoot,
HTTP: &http.Client{Timeout: 30 * time.Second}, HTTP: &http.Client{Timeout: 30 * time.Second},
} }
} }
@ -145,9 +145,9 @@ func (c *Client) TestAuth(serverURL, username, password string) error {
// PushRequest is the payload for POST /sync/push. // PushRequest is the payload for POST /sync/push.
type PushRequest struct { type PushRequest struct {
DeviceID string `json:"device_id"` DeviceID string `json:"device_id"`
IdempotencyKey string `json:"idempotency_key,omitempty"` IdempotencyKey string `json:"idempotency_key,omitempty"`
Ops []PushOp `json:"ops"` Ops []PushOp `json:"ops"`
} }
// PushOp is a single operation in a push request. // PushOp is a single operation in a push request.
@ -164,8 +164,8 @@ type PushOp struct {
// PushResponse is the response from POST /sync/push. // PushResponse is the response from POST /sync/push.
type PushResponse struct { type PushResponse struct {
Accepted []string `json:"accepted"` Accepted []string `json:"accepted"`
Count int `json:"count"` Count int `json:"count"`
Conflicts []map[string]interface{} `json:"conflicts"` Conflicts []map[string]interface{} `json:"conflicts"`
} }

View File

@ -30,18 +30,18 @@ const (
// Op represents a sync operation. // Op represents a sync operation.
type Op struct { type Op struct {
ID string `json:"id"` ID string `json:"id"`
OpID string `json:"op_id"` OpID string `json:"op_id"`
ServerSequence int `json:"server_sequence,omitempty"` ServerSequence int `json:"server_sequence,omitempty"`
DeviceID string `json:"device_id,omitempty"` DeviceID string `json:"device_id,omitempty"`
EntityType string `json:"entity_type"` EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"` EntityID string `json:"entity_id"`
OpType string `json:"op_type"` OpType string `json:"op_type"`
PayloadJSON string `json:"payload_json"` PayloadJSON string `json:"payload_json"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
PushedAt *string `json:"pushed_at,omitempty"` PushedAt *string `json:"pushed_at,omitempty"`
ClientSequence int `json:"client_sequence,omitempty"` ClientSequence int `json:"client_sequence,omitempty"`
LastSeenServerSeq int `json:"last_seen_server_seq,omitempty"` LastSeenServerSeq int `json:"last_seen_server_seq,omitempty"`
} }
// Service records and manages sync operations. // Service records and manages sync operations.

View File

@ -132,16 +132,16 @@ func TestE2ESync(t *testing.T) {
"device_id": deviceIDA, "device_id": deviceIDA,
"ops": []map[string]interface{}{ "ops": []map[string]interface{}{
{ {
"op_id": "op-node-create-001", "op_id": "op-node-create-001",
"entity_type": "node", "entity_type": "node",
"entity_id": nodeID, "entity_id": nodeID,
"op_type": "create", "op_type": "create",
"payload_json": fmt.Sprintf( "payload_json": fmt.Sprintf(
`{"id":"%s","parent_id":"","type":"case","title":"Test Project","slug":"test-project","section":"projects","created_at":"%s","updated_at":"%s"}`, `{"id":"%s","parent_id":"","type":"case","title":"Test Project","slug":"test-project","section":"projects","created_at":"%s","updated_at":"%s"}`,
nodeID, now, now), nodeID, now, now),
"client_sequence": 1, "client_sequence": 1,
"last_seen_server_seq": 0, "last_seen_server_seq": 0,
"created_at": now, "created_at": now,
}, },
}, },
"idempotency_key": "e2e-test-push-1", "idempotency_key": "e2e-test-push-1",
@ -161,7 +161,7 @@ func TestE2ESync(t *testing.T) {
t.Fatalf("push A status %d: %s", resp.StatusCode, string(body)) t.Fatalf("push A status %d: %s", resp.StatusCode, string(body))
} }
var pushRespA struct { var pushRespA struct {
Accepted []string `json:"accepted"` Accepted []string `json:"accepted"`
Conflicts []interface{} `json:"conflicts"` Conflicts []interface{} `json:"conflicts"`
} }
json.NewDecoder(resp.Body).Decode(&pushRespA) json.NewDecoder(resp.Body).Decode(&pushRespA)

View File

@ -5,9 +5,10 @@ package gui
// готова к упаковке (нет external JS/CSS, fetch к /api/* через origin). // готова к упаковке (нет external JS/CSS, fetch к /api/* через origin).
// //
// navigation state: // navigation state:
// sel = { kind:'section', section:'today'|'inbox'|'clients'|'projects'|'recipes'|'documents'|'archive' } //
// or { kind:'node', nodeId:'<uuid>' } // sel = { kind:'section', section:'today'|'inbox'|'clients'|'projects'|'recipes'|'documents'|'archive' }
// tab = 'ov'|'notes'|'files'|'actions'|'worklog'|'activity' // or { kind:'node', nodeId:'<uuid>' }
// tab = 'ov'|'notes'|'files'|'actions'|'worklog'|'activity'
const indexHTML = `<!DOCTYPE html> const indexHTML = `<!DOCTYPE html>
<html lang="ru"> <html lang="ru">
<head> <head>

View File

@ -11,8 +11,8 @@ import (
"verstak/internal/core/actions" "verstak/internal/core/actions"
"verstak/internal/core/files" "verstak/internal/core/files"
"verstak/internal/core/notes"
"verstak/internal/core/nodes" "verstak/internal/core/nodes"
"verstak/internal/core/notes"
"verstak/internal/core/plugins" "verstak/internal/core/plugins"
"verstak/internal/core/search" "verstak/internal/core/search"
"verstak/internal/core/storage" "verstak/internal/core/storage"
@ -280,7 +280,9 @@ func (s *Server) handleNotes(w http.ResponseWriter, r *http.Request) {
} }
jsonOK(w, n) jsonOK(w, n)
case "PUT": case "PUT":
var req struct{ Content string `json:"content"` } var req struct {
Content string `json:"content"`
}
json.NewDecoder(r.Body).Decode(&req) json.NewDecoder(r.Body).Decode(&req)
if err := s.notes.Save(path, req.Content); err != nil { if err := s.notes.Save(path, req.Content); err != nil {
jsonErr(w, 500, err.Error()) jsonErr(w, 500, err.Error())
@ -365,15 +367,15 @@ func (s *Server) handleActions(w http.ResponseWriter, r *http.Request) {
jsonOK(w, list) jsonOK(w, list)
case "POST": case "POST":
var req struct { var req struct {
NodeID string `json:"node_id"` NodeID string `json:"node_id"`
Kind string `json:"kind"` Kind string `json:"kind"`
Title string `json:"title"` Title string `json:"title"`
Command string `json:"command"` Command string `json:"command"`
URL string `json:"url"` URL string `json:"url"`
WorkingDir string `json:"working_dir"` WorkingDir string `json:"working_dir"`
Args []string `json:"args"` Args []string `json:"args"`
Confirm bool `json:"confirm"` Confirm bool `json:"confirm"`
Capture bool `json:"capture"` Capture bool `json:"capture"`
} }
json.NewDecoder(r.Body).Decode(&req) json.NewDecoder(r.Body).Decode(&req)
rec, err := s.actions.Create(req.NodeID, req.Kind, req.Title, req.Command, req.WorkingDir, req.URL, req.Args, req.Confirm, req.Capture) rec, err := s.actions.Create(req.NodeID, req.Kind, req.Title, req.Command, req.WorkingDir, req.URL, req.Args, req.Confirm, req.Capture)

View File

@ -12,8 +12,8 @@ import (
var localeFS embed.FS var localeFS embed.FS
var ( var (
mu sync.RWMutex mu sync.RWMutex
cache = map[string]map[string]string{} cache = map[string]map[string]string{}
defaultLocale = "ru" defaultLocale = "ru"
) )