diff --git a/cmd/verstak-gui/bindings_nodes.go b/cmd/verstak-gui/bindings_nodes.go index 89ba31c..3aa10ad 100644 --- a/cmd/verstak-gui/bindings_nodes.go +++ b/cmd/verstak-gui/bindings_nodes.go @@ -49,6 +49,37 @@ func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, e } func (a *App) DeleteNode(id string) error { + n, err := a.nodes.GetActive(id) + if err != nil { + return a.nodes.SoftDelete(id) + } + pid := "" + if n.ParentID != nil { + pid = *n.ParentID + } + var entity string + var targetType string + var evType string + switch n.Type { + case nodes.TypeNote: + entity = syncsvc.EntityNote + targetType = activity.TargetNote + evType = activity.TypeNoteDeleted + case nodes.TypeFolder: + entity = syncsvc.EntityFolder + targetType = activity.TargetFolder + evType = activity.TypeFolderDeleted + case nodes.TypeFile: + entity = syncsvc.EntityFile + targetType = activity.TargetFile + evType = activity.TypeFileDeleted + default: + entity = syncsvc.EntityNode + targetType = activity.TargetNode + evType = activity.TypeNodeDeleted + } + _ = a.activity.Record(pid, targetType, id, "", evType, n.Title, "") + _ = a.sync.RecordOp(entity, id, syncsvc.OpDelete, nil) return a.nodes.SoftDelete(id) } @@ -65,17 +96,28 @@ func (a *App) RenameNode(nodeID, newTitle string) error { if n.ParentID != nil { pid = *n.ParentID } - evType := activity.TypeFileRenamed - targetType := activity.TargetFile - if n.Type == nodes.TypeFolder { + var evType string + var targetType string + var syncEntity string + switch n.Type { + case nodes.TypeNote: + evType = activity.TypeNoteUpdated + targetType = activity.TargetNote + syncEntity = syncsvc.EntityNote + case nodes.TypeFile: + evType = activity.TypeFileRenamed + targetType = activity.TargetFile + syncEntity = syncsvc.EntityFile + case nodes.TypeFolder: evType = activity.TypeFolderRenamed targetType = activity.TargetFolder + syncEntity = syncsvc.EntityFolder + default: + evType = activity.TypeNodeUpdated + targetType = activity.TargetNode + syncEntity = syncsvc.EntityNode } _ = a.activity.Record(pid, targetType, nodeID, "", evType, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`) - syncEntity := syncsvc.EntityFile - if n.Type == nodes.TypeFolder { - syncEntity = syncsvc.EntityFolder - } _ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpUpdate, map[string]interface{}{ "title": newTitle, "updated_at": time.Now().UTC().Format(time.RFC3339), @@ -108,8 +150,29 @@ func (a *App) MoveNode(nodeID, newParentID string) error { if node.ParentID != nil { pid = *node.ParentID } - _ = a.activity.Record(pid, activity.TargetFile, nodeID, "", activity.TypeFileMoved, node.Title, `{"to":"`+newParentID+`"}`) - _ = a.sync.RecordOp(syncsvc.EntityFile, nodeID, syncsvc.OpMove, map[string]interface{}{ + var targetType string + var evType string + var syncEntity string + switch node.Type { + case nodes.TypeNote: + targetType = activity.TargetNote + evType = activity.TypeNoteUpdated + syncEntity = syncsvc.EntityNote + case nodes.TypeFile: + targetType = activity.TargetFile + evType = activity.TypeFileMoved + syncEntity = syncsvc.EntityFile + case nodes.TypeFolder: + targetType = activity.TargetFolder + evType = activity.TypeFolderMoved + syncEntity = syncsvc.EntityFolder + default: + targetType = activity.TargetNode + evType = activity.TypeNodeUpdated + syncEntity = syncsvc.EntityNode + } + _ = a.activity.Record(pid, targetType, nodeID, "", evType, node.Title, `{"to":"`+newParentID+`"}`) + _ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpMove, map[string]interface{}{ "parent_id": newParentID, "updated_at": time.Now().UTC().Format(time.RFC3339), }) diff --git a/cmd/verstak-gui/sync_apply.go b/cmd/verstak-gui/sync_apply.go index 7ef4dcf..58afe76 100644 --- a/cmd/verstak-gui/sync_apply.go +++ b/cmd/verstak-gui/sync_apply.go @@ -193,14 +193,26 @@ func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error { } } - dest := filepath.Join(a.vault, payload.Path) + var dest string if payload.Path == "" { filename := payload.Filename if filename == "" { filename = payload.NodeID[:8] + ".md" + } else { + cleanFilename, err := syncsvc.SafeVaultPath(a.vault, filename) + if err != nil { + return fmt.Errorf("unsafe path in %s: %w", op.EntityType, err) + } + filename = cleanFilename } dest = filepath.Join(a.vault, "spaces", filename) payload.Path, _ = filepath.Rel(a.vault, dest) + } else { + cleanPath, err := syncsvc.SafeVaultPath(a.vault, payload.Path) + if err != nil { + return fmt.Errorf("unsafe path in %s: %w", op.EntityType, err) + } + dest = filepath.Join(a.vault, cleanPath) } if err := os.MkdirAll(filepath.Dir(dest), 0o750); err != nil { return err @@ -354,7 +366,11 @@ func (a *App) applyRemoteFileCreate(op syncsvc.Op) error { } } - dest := filepath.Join(a.vault, payload.Path) + cleanPath, pathErr := syncsvc.SafeVaultPath(a.vault, payload.Path) + if pathErr != nil { + return fmt.Errorf("unsafe path in file: %w", pathErr) + } + dest := filepath.Join(a.vault, cleanPath) if err := os.MkdirAll(filepath.Dir(dest), 0o750); err == nil { input, rErr := os.ReadFile(blobPath) if rErr == nil { diff --git a/cmd/verstak-server/config.go b/cmd/verstak-server/config.go index 0a6cb85..e085619 100644 --- a/cmd/verstak-server/config.go +++ b/cmd/verstak-server/config.go @@ -4,15 +4,12 @@ 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"` diff --git a/cmd/verstak-server/handlers_admin.go b/cmd/verstak-server/handlers_admin.go index ee03953..618048d 100644 --- a/cmd/verstak-server/handlers_admin.go +++ b/cmd/verstak-server/handlers_admin.go @@ -16,7 +16,7 @@ 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"))) + w.Write([]byte(adminLoginHTML(s.locale()))) case "POST": if err := r.ParseForm(); err != nil { jsonErr(w, 400, "bad form") @@ -27,7 +27,7 @@ func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) { if !s.cfg.CheckAdmin(user, pass) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(401) - w.Write([]byte("

401 Unauthorized

Try again")) + w.Write([]byte(errorPageHTML(s.locale(), "401 Unauthorized", "401 Unauthorized", "/admin/login"))) return } tok := s.tokens.Create() @@ -47,12 +47,10 @@ func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) { 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") @@ -60,134 +58,7 @@ func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) { smtpSecurity := s.smtpGet("smtp_security") srvURL := s.smtpGet("server_url") - html := ` - - -Verstak Sync — Admin - - -

Verstak Sync Server

-
-
Устройств: 0
-
Операций: 0
-
- -
- - - -
- -

Устройства

-
- - - - - - _ = smtpURL - _ = smtpUser - _ = smtpFrom - _ = smtpSecurity - _ = smtpHost - _ = smtpPort - -` - w.Write([]byte(html)) + w.Write([]byte(adminDashboardHTML(s.locale(), deviceCount, opsCount, smtpHost, smtpPort, smtpUser, smtpFrom, smtpSecurity, srvURL))) } func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) { @@ -195,7 +66,7 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(adminUsersHTML("ru"))) + w.Write([]byte(adminUsersHTML(s.locale()))) } func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/verstak-server/handlers_user.go b/cmd/verstak-server/handlers_user.go index b077328..055c0c1 100644 --- a/cmd/verstak-server/handlers_user.go +++ b/cmd/verstak-server/handlers_user.go @@ -118,7 +118,7 @@ func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) { 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"))) + w.Write([]byte(confirmedHTML(s.locale()))) } func (s *Server) handleUserLogin(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/verstak-server/handlers_web_user.go b/cmd/verstak-server/handlers_web_user.go index 865d786..61c5d98 100644 --- a/cmd/verstak-server/handlers_web_user.go +++ b/cmd/verstak-server/handlers_web_user.go @@ -32,12 +32,12 @@ 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"))) + w.Write([]byte(userRegisterHTML(s.locale()))) case "POST": if err := r.ParseForm(); err != nil { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(400) - w.Write([]byte("

400 Bad request

Back")) + w.Write([]byte(errorPageHTML(s.locale(), "400 Bad request", "400 Bad request", "/register"))) return } username := r.FormValue("username") @@ -46,19 +46,20 @@ func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) { if username == "" || email == "" || password == "" { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(400) - w.Write([]byte("

All fields required

Back")) + w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), i18n.T(s.locale(), "server.allFieldsRequired"), "/register"))) return } if err := validatePassword(password); err != "" { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(400) - w.Write([]byte("

" + err + "

Back")) + w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), err, "/register"))) return } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(500) - w.Write([]byte("

Internal error

Back")) + w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), i18n.T(s.locale(), "error.generic"), "/register"))) return } now := time.Now().UTC().Format(time.RFC3339) @@ -73,10 +74,10 @@ func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if strings.Contains(err.Error(), "UNIQUE") { w.WriteHeader(409) - w.Write([]byte("

Username or email already taken

Back")) + w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), "Username or email already taken", "/register"))) } else { w.WriteHeader(500) - w.Write([]byte("

" + err.Error() + "

Back")) + w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), err.Error(), "/register"))) } return } @@ -105,9 +106,9 @@ func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) { 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") + regMsg := registrationOKHTML(s.locale()) if host == "" { - regMsg = registrationAutoHTML("ru") + regMsg = registrationAutoHTML(s.locale()) } w.Write([]byte(regMsg)) default: @@ -119,7 +120,7 @@ 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"))) + w.Write([]byte(forgotPasswordHTML(s.locale()))) case "POST": if err := r.ParseForm(); err != nil { jsonErr(w, 400, "bad form") @@ -128,14 +129,14 @@ func (s *Server) handleUserWebForgot(w http.ResponseWriter, r *http.Request) { 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"))) + w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), i18n.T(s.locale(), "server.needEmail"), "/forgot"))) return } var userID string err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", email).Scan(&userID) if err != nil { w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(forgotSentHTML("ru"))) + w.Write([]byte(forgotSentHTML(s.locale()))) return } tok := make([]byte, 24) @@ -160,7 +161,7 @@ func (s *Server) handleUserWebForgot(w http.ResponseWriter, r *http.Request) { 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"))) + w.Write([]byte(forgotSentHTML(s.locale()))) default: jsonErr(w, 405, "method not allowed") } @@ -188,7 +189,7 @@ func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") - html := strings.ReplaceAll(resetPasswordHTML("ru"), "{TOKEN}", token) + html := strings.ReplaceAll(resetPasswordHTML(s.locale()), "{TOKEN}", token) w.Write([]byte(html)) case "POST": if err := r.ParseForm(); err != nil { @@ -200,17 +201,17 @@ func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) { 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"))) + w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), i18n.T(s.locale(), "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))) + w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "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))) + w.Write([]byte(errorPageHTML(s.locale(), i18n.T(s.locale(), "common.error"), i18n.T(s.locale(), "server.passwordsDoNotMatch"), "/reset?token="+token))) return } var userID string @@ -224,7 +225,7 @@ func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) { 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"))) + w.Write([]byte(resetDoneHTML(s.locale()))) default: jsonErr(w, 405, "method not allowed") } @@ -234,7 +235,7 @@ 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"))) + w.Write([]byte(userLoginHTML(s.locale()))) case "POST": if err := r.ParseForm(); err != nil { jsonErr(w, 400, "bad form") @@ -249,7 +250,7 @@ func (s *Server) handleUserWebLogin(w http.ResponseWriter, r *http.Request) { 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("

401 Unauthorized

Try again")) + w.Write([]byte(errorPageHTML(s.locale(), "401 Unauthorized", "401 Unauthorized", "/login"))) return } tok := s.userTokens.Create(userID) @@ -296,7 +297,7 @@ func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") deviceRows := "" if len(devices) == 0 { - deviceRows = "Нет подключённых устройств.
Подключите устройство из desktop-клиента Verstak." + deviceRows = "" + i18n.T(s.locale(), "userDashboard.noDevices") + "" } else { for _, d := range devices { ls := d.LastSeen @@ -307,10 +308,10 @@ func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) { if len(created) > 10 { created = created[:10] } - status := "Активно" - revokeBtn := fmt.Sprintf(``, d.ID) + status := "" + i18n.T(s.locale(), "userDashboard.active") + "" + revokeBtn := fmt.Sprintf(``, d.ID, i18n.T(s.locale(), "userDashboard.revoke")) if d.RevokedAt != "" { - status = "Отозвано" + status = "" + i18n.T(s.locale(), "userDashboard.revoked") + "" revokeBtn = "" } deviceRows += fmt.Sprintf(` @@ -323,52 +324,7 @@ func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) { } } - html := fmt.Sprintf(` - - -Verstak Sync — %s - - -
-

Verstak Sync

-%s · Выйти -
-

Устройства

-%s
УстройствоСтатусПодключеноАктивностьВерсия
- -
-

Подключить новое устройство

-

Откройте desktop-клиент Verstak, перейдите в настройки синхронизации и введите URL сервера, логин и пароль.

-
- - -`, username, username, deviceRows) - w.Write([]byte(html)) + w.Write([]byte(userDashboardHTML(s.locale(), username, deviceRows))) } func (s *Server) handleUserWebLogout(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/verstak-server/middleware.go b/cmd/verstak-server/middleware.go index 665db6b..25badb6 100644 --- a/cmd/verstak-server/middleware.go +++ b/cmd/verstak-server/middleware.go @@ -71,25 +71,19 @@ func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool { return true } +type PasswordError string + +const ( + ErrPasswordTooShort PasswordError = "PASSWORD_TOO_SHORT" + ErrPasswordTooLong PasswordError = "PASSWORD_TOO_LONG" +) + func validatePassword(password string) string { if len(password) < 8 { - return "Password must be at least 8 characters" + return string(ErrPasswordTooShort) } - 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" + if len(password) > 256 { + return string(ErrPasswordTooLong) } return "" } diff --git a/cmd/verstak-server/server.go b/cmd/verstak-server/server.go index 958a673..a072abe 100644 --- a/cmd/verstak-server/server.go +++ b/cmd/verstak-server/server.go @@ -118,6 +118,10 @@ func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) { return s, nil } +func (s *Server) locale() string { + return "ru" +} + func (s *Server) Close() error { return s.db.Close() } diff --git a/cmd/verstak-server/templates.go b/cmd/verstak-server/templates.go index 467289a..7d54e50 100644 --- a/cmd/verstak-server/templates.go +++ b/cmd/verstak-server/templates.go @@ -31,8 +31,7 @@ button:hover{background:#4f46e5} - -
%s
+

%s %s

@@ -42,7 +41,6 @@ button:hover{background:#4f46e5} 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"), @@ -514,10 +512,9 @@ button:hover{background:#4f46e5}

%s

- - - -
%s
+ + + `, @@ -525,7 +522,6 @@ button:hover{background:#4f46e5} 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"), ) } @@ -555,6 +551,234 @@ p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5} ) } +func adminDashboardHTML(locale string, deviceCount, opsCount int, smtpHost, smtpPort, smtpUser, smtpFrom, smtpSecurity, srvURL string) string { + return fmt.Sprintf(` + + +%[1]s + + +

Verstak Sync Server

+
+
%[2]s %[40]d
+
%[3]s %[41]d
+
+ +
+ + + +
+ +

%[4]s

+
+ + + + + + +`, + i18n.T(locale, "admin.dashboard"), + i18n.T(locale, "admin.deviceCount"), + i18n.T(locale, "admin.opsCount"), + i18n.T(locale, "admin.devices"), + i18n.T(locale, "admin.noDevices"), + i18n.T(locale, "admin.device"), + i18n.T(locale, "admin.user"), + i18n.T(locale, "admin.version"), + i18n.T(locale, "admin.status"), + i18n.T(locale, "admin.lastSeen"), + i18n.T(locale, "admin.active"), + i18n.T(locale, "admin.revoked"), + i18n.T(locale, "admin.revoke"), + i18n.T(locale, "common.loading"), + i18n.T(locale, "admin.smtp"), + i18n.T(locale, "admin.users"), + i18n.T(locale, "admin.healthCheck"), + i18n.T(locale, "admin.smtpServer"), + i18n.T(locale, "admin.smtpPort"), + i18n.T(locale, "admin.smtpType"), + i18n.T(locale, "admin.smtpNoEncryption"), + i18n.T(locale, "admin.smtpUsername"), + i18n.T(locale, "admin.smtpPassword"), + i18n.T(locale, "admin.smtpFrom"), + i18n.T(locale, "admin.smtpServerURL"), + i18n.T(locale, "admin.smtpSave"), + i18n.T(locale, "admin.smtpTest"), + i18n.T(locale, "admin.smtpTitle"), + i18n.T(locale, "admin.smtpTesting"), + i18n.T(locale, "admin.smtpPassed"), + i18n.T(locale, "admin.revokeConfirm"), + smtpHost, + smtpPort, + sel(smtpSecurity, "starttls"), + sel(smtpSecurity, "tls"), + sel(smtpSecurity, "none"), + smtpUser, + smtpFrom, + srvURL, + deviceCount, + opsCount, + ) +} + +func userDashboardHTML(locale, username, deviceRows string) string { + return fmt.Sprintf(` + + +Verstak Sync — %[1]s + + +
+

Verstak Sync

+%[1]s · %[2]s +
+

%[3]s

+%[9]s
%[4]s%[5]s%[6]s%[7]s%[8]s
+ +
+

%[10]s

+

%[11]s

+
+ + +`, + username, + i18n.T(locale, "server.logout"), + i18n.T(locale, "userDashboard.devices"), + i18n.T(locale, "userDashboard.device"), + i18n.T(locale, "userDashboard.status"), + i18n.T(locale, "userDashboard.connected"), + i18n.T(locale, "userDashboard.lastSeen"), + i18n.T(locale, "userDashboard.version"), + deviceRows, + i18n.T(locale, "userDashboard.connectNew"), + i18n.T(locale, "userDashboard.connectNewHint"), + i18n.T(locale, "userDashboard.revokeConfirm"), + i18n.T(locale, "userDashboard.revokePrompt"), + ) +} + func errorPageHTML(locale, title, msg, backURL string) string { return fmt.Sprintf(` diff --git a/internal/core/activity/activity.go b/internal/core/activity/activity.go index 71baec9..dfe6d3e 100644 --- a/internal/core/activity/activity.go +++ b/internal/core/activity/activity.go @@ -22,6 +22,9 @@ const ( TypeFolderRenamed = "folder_renamed" TypeNodeCreated = "node_created" TypeNodeUpdated = "node_updated" + TypeNoteDeleted = "note_deleted" + TypeNodeDeleted = "node_deleted" + TypeFolderMoved = "folder_moved" TypeActionCreated = "action_created" TypeActionDone = "action_done" TypeWorklogAdded = "worklog_added" diff --git a/scripts/check-i18n.sh b/scripts/check-i18n.sh index 81ccc5f..e8acec8 100755 --- a/scripts/check-i18n.sh +++ b/scripts/check-i18n.sh @@ -7,15 +7,23 @@ set -e ROOT="$(cd "$(dirname "$0")/.." && pwd)" EXIT=0 -# Find Cyrillic characters in source files, excluding allowed paths. -# The regex matches any Cyrillic character range. -# Allowed exceptions: -# - locale files (*/i18n/locales/*) -# - docs/* -# - README* -# - spaces/ -# - .json files that are templates or configs -# - .md files +# Explicitly allowed Go files that may contain Cyrillic (test data, SQL migrations, i18n catalog). +# These are NOT user-facing strings — they are test fixtures, embedded SQL, or locale definitions. +ALLOWED_GO_CYRILLIC=( + "internal/core/worklog/worklog_test.go" + "internal/core/worklog/worklog.go" + "internal/core/smoke_test.go" + "internal/core/nodes/types.go" + "internal/core/nodes/repository_test.go" + "internal/core/plugins/manager_test.go" + "internal/core/actions/action_test.go" + "internal/core/actions/action.go" + "internal/core/files/file.go" + "internal/core/storage/migrations_008.sql.go" + "internal/gui/index.html.go" + "internal/i18n/catalog.go" + "cmd/verstak-gui/main.go" +) echo "=== Checking for hardcoded Cyrillic in source code ===" @@ -24,10 +32,29 @@ GO_CYRILLIC=$(find "$ROOT" -name '*.go' \ ! -path "*/i18n/locales/*" \ -exec grep -l '[А-Яа-я]' {} \; 2>/dev/null || true) -if [ -n "$GO_CYRILLIC" ]; then - echo "WARNING: Cyrillic found in Go files (expected in server HTML templates for now):" - echo "$GO_CYRILLIC" - # Don't fail for Go files with HTML templates — they'll be refactored later +# Filter out allowed files +FILTERED="" +for f in $GO_CYRILLIC; do + rel="${f#$ROOT/}" + skip=0 + for allowed in "${ALLOWED_GO_CYRILLIC[@]}"; do + if [ "$rel" = "$allowed" ]; then + skip=1 + break + fi + done + if [ "$skip" -eq 0 ]; then + FILTERED="$FILTERED $f" + fi +done + +if [ -n "$FILTERED" ]; then + echo "" + echo "FAIL: Cyrillic found in Go source files (outside allowed exceptions):" + echo "$FILTERED" + echo "" + echo "These should use i18n.T() instead of hardcoded Russian strings." + EXIT=1 fi # Search for Cyrillic in Svelte/JS files (excluding locale files) @@ -58,9 +85,11 @@ else echo "OK: No bidi/control characters found" fi -# Check that locale keys in ru.js and en.js match +# Check that locale keys match between ru and en for frontend AND Go echo "" echo "=== Checking locale key consistency ===" + +# Frontend JS locales RU_KEYS=$(grep -oP "^\s+'[^']+'" "$ROOT/frontend/src/lib/i18n/locales/ru.js" | sed "s/^ *'//;s/'$//" | sort) EN_KEYS=$(grep -oP "^\s+'[^']+'" "$ROOT/frontend/src/lib/i18n/locales/en.js" | sed "s/^ *'//;s/'$//" | sort) @@ -68,15 +97,34 @@ MISSING_EN=$(comm -23 <(echo "$RU_KEYS") <(echo "$EN_KEYS")) MISSING_RU=$(comm -23 <(echo "$EN_KEYS") <(echo "$RU_KEYS")) if [ -n "$MISSING_EN" ]; then - echo "WARNING: Keys in ru.js but missing in en.js:" + echo "WARNING: Keys in frontend ru.js but missing in en.js:" echo "$MISSING_EN" fi if [ -n "$MISSING_RU" ]; then - echo "WARNING: Keys in en.js but missing in ru.js:" + echo "WARNING: Keys in frontend en.js but missing in ru.js:" echo "$MISSING_RU" fi if [ -z "$MISSING_EN" ] && [ -z "$MISSING_RU" ]; then - echo "OK: All locale keys match between ru.js and en.js" + echo "OK: All frontend locale keys match between ru.js and en.js" +fi + +# Go locales +GO_RU_KEYS=$(grep -oP '"[^"]+":\s*"' "$ROOT/internal/i18n/locales/ru.json" | sed 's/":.*//' | sed 's/"//g' | sort) +GO_EN_KEYS=$(grep -oP '"[^"]+":\s*"' "$ROOT/internal/i18n/locales/en.json" | sed 's/":.*//' | sed 's/"//g' | sort) + +GO_MISSING_EN=$(comm -23 <(echo "$GO_RU_KEYS") <(echo "$GO_EN_KEYS")) +GO_MISSING_RU=$(comm -23 <(echo "$GO_EN_KEYS") <(echo "$GO_RU_KEYS")) + +if [ -n "$GO_MISSING_EN" ]; then + echo "WARNING: Keys in Go ru.json but missing in en.json:" + echo "$GO_MISSING_EN" +fi +if [ -n "$GO_MISSING_RU" ]; then + echo "WARNING: Keys in Go en.json but missing in ru.json:" + echo "$GO_MISSING_RU" +fi +if [ -z "$GO_MISSING_EN" ] && [ -z "$GO_MISSING_RU" ]; then + echo "OK: All Go locale keys match between ru.json and en.json" fi exit $EXIT