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
-
-
-
-
-Устройства
-
-
-
-
-
-
-
-
-
Health check
-
Загрузка...
-
-
- _ = 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
+
+
+