Skip to content

Commit 3de8b52

Browse files
authored
Merge pull request #41 from AppsGanin/feat/i18n
feat(i18n)!: Russian/English across the panel, bots, subscription page and CLI
2 parents afe313e + bfe6c88 commit 3de8b52

210 files changed

Lines changed: 10930 additions & 3619 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/rospanel/cli.go

Lines changed: 86 additions & 84 deletions
Large diffs are not rendered by default.

cmd/rospanel/dbrecover.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func ensureHealthyDB(dbPath, dataDir string) error {
4949
return fmt.Errorf("database is corrupt and there is no local backup to restore from. "+
5050
"The damaged file is left at %s. Restore an off-box backup with `rospanel restore <file>`, "+
5151
"or wipe and start fresh with `rospanel reset`. "+
52-
"Turn on scheduled local backups (НастройкиБэкапы) so this is recoverable next time", dbPath)
52+
"Turn on scheduled local backups (SettingsBackups) so this is recoverable next time", dbPath)
5353
}
5454

5555
quarantine, qerr := quarantineDB(dbPath)

cmd/rospanel/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ func main() {
8989
case "help", "--help", "-h":
9090
printUsage(os.Stdout)
9191
default:
92-
fmt.Fprintf(os.Stderr, "неизвестная команда %q\n\n", os.Args[1])
92+
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", os.Args[1])
9393
printUsage(os.Stderr)
9494
os.Exit(2)
9595
}

cmd/rospanel/node.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ func runNode(args []string) {
4343
case "help", "--help", "-h":
4444
printNodeUsage(os.Stdout)
4545
default:
46-
fmt.Fprintf(os.Stderr, "неизвестная node-команда %q\n\n", args[0])
46+
fmt.Fprintf(os.Stderr, "unknown node command %q\n\n", args[0])
4747
printNodeUsage(os.Stderr)
4848
os.Exit(2)
4949
}
@@ -221,9 +221,9 @@ func runNodeUninstall(args []string) {
221221
log.Fatal("node uninstall: run as root (sudo)")
222222
}
223223
if !hasYesFlag(args) && !confirmTTY(
224-
"Удалить systemd-сервис rospanel-node? Нода будет остановлена.\n"+
225-
"Данные ноды сохранятся, бинарь не удаляется. Продолжить? [y/N]: ") {
226-
fmt.Println("Отменено.")
224+
"Remove the rospanel-node systemd service? The node will be stopped.\n"+
225+
"Node data is kept and the binary is not removed. Continue? [y/N]: ") {
226+
fmt.Println("Cancelled.")
227227
return
228228
}
229229
_ = exec.Command("systemctl", "disable", "--now", "rospanel-node").Run()

cmd/rospanel/service.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import (
3939
// generate the Xray config, start the background loops and serve the admin API +
4040
// masquerade/subscription surface until a termination signal arrives.
4141
func runServer(dataDir string) {
42-
log.Printf("startup: РосПанель %s booting (data dir %s)", version.Version, dataDir)
42+
log.Printf("startup: RosPanel %s booting (data dir %s)", version.Version, dataDir)
4343
adminAddr := env("ROSPANEL_ADMIN_ADDR", "127.0.0.1:8080")
4444
startupStage("resolving Xray binary")
4545
xrayBin := resolveXrayBin(env("XRAY_BIN", "xray"), filepath.Join(dataDir, "bin"))
@@ -308,7 +308,7 @@ func bootstrapTLS(st *store.Store, certPath, keyPath, acmeDir string) error {
308308
if err != nil {
309309
return err
310310
}
311-
// A real host set in the panel (setup wizard → Settings → Домен) always wins.
311+
// A real host set in the panel (setup wizard → Settings → Domain) always wins.
312312
// When none is set yet — or a previous boot persisted the loopback fallback —
313313
// resolve one for an unattended first boot: an explicit ROSPANEL_HOST (domain
314314
// or IP) takes priority so an operator can pin a domain; otherwise auto-detect

internal/abuse/feeds.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ func (s *Store) Matcher() *Matcher { return s.matcher }
134134
// FileInfo is one category's state, for the settings UI.
135135
type FileInfo struct {
136136
Category string `json:"category"`
137-
Title string `json:"title"`
137+
TitleKey string `json:"title_key"` // dictionary key; the panel words it
138138
Enabled bool `json:"enabled"`
139139
Present bool `json:"present"` // a cached feed on disk, or a non-empty custom list
140140
Entries int `json:"entries"` // entries currently loaded in the matcher
@@ -149,7 +149,7 @@ func (s *Store) Status() []FileInfo {
149149
out := make([]FileInfo, 0, len(Feeds)+1)
150150
for _, cat := range feedCats() {
151151
fi := FileInfo{
152-
Category: string(cat), Title: cat.Title(),
152+
Category: string(cat), TitleKey: cat.TitleKey(),
153153
Enabled: s.catEnabled(cat), Entries: counts[cat],
154154
}
155155
if st, err := os.Stat(s.path(cat)); err == nil {
@@ -162,7 +162,7 @@ func (s *Store) Status() []FileInfo {
162162
customSet := strings.TrimSpace(s.custom) != ""
163163
s.cfgMu.Unlock()
164164
out = append(out, FileInfo{
165-
Category: string(CatCustom), Title: CatCustom.Title(),
165+
Category: string(CatCustom), TitleKey: CatCustom.TitleKey(),
166166
Enabled: s.catEnabled(CatCustom), Present: customSet, Entries: counts[CatCustom],
167167
})
168168
return out

internal/abuse/ipmatch_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ func TestIPMatchCIDRAndBoundaries(t *testing.T) {
1111
m.SetIP(CatBadIP, []string{"10.0.0.0/24", "192.0.2.5", "2001:db8::/32"})
1212

1313
hit := []string{
14-
"10.0.0.0", // network address
15-
"10.0.0.1", //
16-
"10.0.0.255", // broadcast / last in /24
17-
"192.0.2.5", // single host
18-
"2001:db8::1", // inside v6 prefix
14+
"10.0.0.0", // network address
15+
"10.0.0.1", //
16+
"10.0.0.255", // broadcast / last in /24
17+
"192.0.2.5", // single host
18+
"2001:db8::1", // inside v6 prefix
1919
"2001:db8:ffff:ffff::1",
2020
}
2121
for _, ip := range hit {
@@ -25,9 +25,9 @@ func TestIPMatchCIDRAndBoundaries(t *testing.T) {
2525
}
2626

2727
miss := []string{
28-
"10.0.1.0", // just past the /24
28+
"10.0.1.0", // just past the /24
2929
"9.255.255.255",
30-
"192.0.2.4", // adjacent to the single host
30+
"192.0.2.4", // adjacent to the single host
3131
"192.0.2.6",
3232
"2001:db9::1", // just past the v6 /32
3333
"8.8.8.8",

internal/abuse/matcher.go

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,19 +43,13 @@ const (
4343
CatGambling Category = "gambling"
4444
)
4545

46-
// Title is the Russian label shown in the panel.
47-
func (c Category) Title() string {
46+
// TitleKey is the dictionary key for the category's name. It is a key and not a
47+
// word because the only reader is a Telegram alert, and which language that alert
48+
// is written in is a setting, not a build-time constant.
49+
func (c Category) TitleKey() string {
4850
switch c {
49-
case CatCustom:
50-
return "Свой список"
51-
case CatBadIP:
52-
return "Вредоносный IP"
53-
case CatMalware:
54-
return "Вредоносное ПО"
55-
case CatPiracy:
56-
return "Пиратство"
57-
case CatGambling:
58-
return "Азартные игры"
51+
case CatCustom, CatBadIP, CatMalware, CatPiracy, CatGambling:
52+
return "abuse." + string(c)
5953
}
6054
return string(c)
6155
}

internal/branding/branding.go

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// colour and logo. The name and accent live in the settings table (so they ride
33
// along in the SQLite backup); a custom logo is a file under <dataDir>/branding/
44
// (which the data-dir tar backup already captures). When nothing is configured
5-
// the built-in РосПанель defaults apply.
5+
// the built-in RosPanel defaults apply.
66
package branding
77

88
import (
@@ -20,12 +20,14 @@ import (
2020
"regexp"
2121
"strconv"
2222
"strings"
23+
24+
"github.com/AppsGanin/rospanel/internal/model"
2325
)
2426

2527
//go:embed default-logo.svg
2628
var defaultLogoSVG []byte
2729

28-
// DefaultLogo returns the built-in РосПанель logo (SVG).
30+
// DefaultLogo returns the built-in RosPanel logo (SVG).
2931
func DefaultLogo() []byte { return defaultLogoSVG }
3032

3133
const (
@@ -47,7 +49,7 @@ type Theme struct {
4749
Surface string `json:"surface"` // cards / inputs / panels
4850
}
4951

50-
// DefaultTheme is the stock РосПанель palette (Госуслуги-style blue on a soft
52+
// DefaultTheme is the stock RosPanel palette (Gosuslugi-style blue on a soft
5153
// blue page with white surfaces).
5254
func DefaultTheme() Theme {
5355
return Theme{
@@ -100,27 +102,30 @@ func pick(v, fallback string) string {
100102
// NormalizeTheme validates each provided colour and returns the JSON to persist.
101103
// Empty fields are dropped (⇒ default applies); a non-empty non-hex field errors.
102104
func NormalizeTheme(t Theme) (string, error) {
103-
clean := func(name, v string) (string, error) {
105+
// Which colour failed is part of the message, and it is a word — so each field
106+
// gets its own code rather than one code with the field name glued in: an
107+
// argument travels verbatim and would arrive at an English panel in Russian.
108+
clean := func(code, fallback, v string) (string, error) {
104109
v = strings.ToLower(strings.TrimSpace(v))
105110
if v == "" || accentRe.MatchString(v) {
106111
return v, nil
107112
}
108-
return "", fmt.Errorf("цвет «%s» должен быть в формате #RRGGBB", name)
113+
return "", model.FieldErr(code, fallback)
109114
}
110115
var err error
111-
if t.Accent, err = clean("акцент", t.Accent); err != nil {
116+
if t.Accent, err = clean("err.badColorAccent", "цвет «акцент» должен быть в формате #RRGGBB", t.Accent); err != nil {
112117
return "", err
113118
}
114-
if t.Text, err = clean("текст", t.Text); err != nil {
119+
if t.Text, err = clean("err.badColorText", "цвет «текст» должен быть в формате #RRGGBB", t.Text); err != nil {
115120
return "", err
116121
}
117-
if t.Muted, err = clean("приглушённый текст", t.Muted); err != nil {
122+
if t.Muted, err = clean("err.badColorMuted", "цвет «приглушённый текст» должен быть в формате #RRGGBB", t.Muted); err != nil {
118123
return "", err
119124
}
120-
if t.Bg, err = clean("фон", t.Bg); err != nil {
125+
if t.Bg, err = clean("err.badColorBg", "цвет «фон» должен быть в формате #RRGGBB", t.Bg); err != nil {
121126
return "", err
122127
}
123-
if t.Surface, err = clean("поверхность", t.Surface); err != nil {
128+
if t.Surface, err = clean("err.badColorSurface", "цвет «поверхность» должен быть в формате #RRGGBB", t.Surface); err != nil {
124129
return "", err
125130
}
126131
if t == (Theme{}) {
@@ -229,17 +234,17 @@ func SaveLogo(dataDir string, r io.Reader) error {
229234
return err
230235
}
231236
if len(b) == 0 {
232-
return fmt.Errorf("пустой файл")
237+
return model.FieldErr("err.emptyFile", "пустой файл")
233238
}
234239
if len(b) > MaxLogoBytes {
235-
return fmt.Errorf("логотип больше %d КБ", MaxLogoBytes>>10)
240+
return model.FieldErr("err.logoTooBig", "логотип больше {{kb}} КБ", map[string]any{"kb": MaxLogoBytes >> 10})
236241
}
237242
cfg, format, err := image.DecodeConfig(bytes.NewReader(b))
238243
if err != nil || (format != "png" && format != "jpeg") {
239-
return fmt.Errorf("нужен PNG или JPEG")
244+
return model.FieldErr("err.needPngJpeg", "нужен PNG или JPEG")
240245
}
241246
if cfg.Width > maxLogoDim || cfg.Height > maxLogoDim {
242-
return fmt.Errorf("изображение больше %d×%d пикселей", maxLogoDim, maxLogoDim)
247+
return model.FieldErr("err.imageTooLarge", "изображение больше {{dim}}×{{dim}} пикселей", map[string]any{"dim": maxLogoDim})
243248
}
244249
if err := os.MkdirAll(brandingDir(dataDir), 0o700); err != nil {
245250
return err

internal/core/errors.go

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,75 @@
11
package core
22

3-
import "fmt"
3+
import (
4+
"errors"
5+
"fmt"
6+
"strings"
47

5-
// ValidationError marks an error caused by bad operator input rather than a
6-
// server fault. The server layer maps it to HTTP 400 (vs 500 for everything
7-
// else); its message is operator-facing (Russian) and safe to show in the UI.
8-
type ValidationError struct{ Msg string }
8+
"github.com/AppsGanin/rospanel/internal/model"
9+
)
10+
11+
// ValidationError marks an error caused by bad operator input rather than a server
12+
// fault. The server layer maps it to HTTP 400 (vs 500 for everything else).
13+
//
14+
// It carries a dictionary CODE alongside the message, and the panel renders that
15+
// code against its own dictionaries — the panel's language is a per-browser choice
16+
// the server cannot see, so a message worded here would be stuck in one language on
17+
// a bilingual page.
18+
//
19+
// Msg is not dead weight: it is the fallback the panel shows for a code it does not
20+
// know, so a build whose dictionaries lag the server still reads as a sentence
21+
// rather than as "err.tokenRequired". It is also what Error() returns, which is what
22+
// lands in the logs and in the external REST API, where there is no dictionary.
23+
type ValidationError struct {
24+
Msg string
25+
Code string
26+
Args map[string]any
27+
}
928

1029
func (e *ValidationError) Error() string { return e.Msg }
1130

12-
// invalid builds a ValidationError with a formatted operator-facing message.
31+
// invalid builds a ValidationError with a formatted operator-facing message and no
32+
// code. Kept for the messages the panel never surfaces; anything an operator can see
33+
// should use invalidCode so it can be translated.
1334
func invalid(format string, a ...any) error {
1435
return &ValidationError{Msg: fmt.Sprintf(format, a...)}
1536
}
37+
38+
// invalidCode builds a ValidationError the panel can translate. code names an entry
39+
// under err.* in the frontend dictionaries; fallback is the wording shown when that
40+
// entry is missing; args are interpolated into both.
41+
//
42+
// code comes first on purpose: a call site should read as the thing that went wrong,
43+
// not as a sentence that happens to carry an id.
44+
func invalidCode(code, fallback string, args ...map[string]any) error {
45+
e := &ValidationError{Msg: fallback, Code: code}
46+
if len(args) > 0 && args[0] != nil {
47+
e.Args = args[0]
48+
// Fill the fallback too: it is what a stale panel renders, and a bare
49+
// template with unfilled slots would read worse than the raw code.
50+
e.Msg = interpolate(fallback, args[0])
51+
}
52+
return e
53+
}
54+
55+
// interpolate fills {{name}} placeholders. The panel does the same to the translated
56+
// string; this keeps the untranslated path from showing braces to an operator.
57+
func interpolate(s string, args map[string]any) string {
58+
for k, v := range args {
59+
s = strings.ReplaceAll(s, "{{"+k+"}}", fmt.Sprint(v))
60+
}
61+
return s
62+
}
63+
64+
// fromFieldErr turns a model-layer validation failure into a ValidationError,
65+
// carrying its code across the layer boundary. The model cannot build a
66+
// ValidationError itself — that would invert the import — so it raises a FieldError
67+
// and this is where the two meet. An uncoded error still becomes a 400; it just
68+
// falls back to its own text in the panel.
69+
func fromFieldErr(err error) error {
70+
var fe *model.FieldError
71+
if errors.As(err, &fe) {
72+
return &ValidationError{Msg: fe.Msg, Code: fe.Code, Args: fe.Args}
73+
}
74+
return invalid("%s", err.Error())
75+
}

0 commit comments

Comments
 (0)