diff --git a/.dockerignore b/.dockerignore index a118f64..2c2b83b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,6 @@ /** !/cmd/africastalking +!/cmd/ssh !/common !/config !/initializers diff --git a/.env.example b/.env.example index dae37bc..6d0368f 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,7 @@ DB_CONN=postgres://postgres:strongpass@localhost:5432/urdt_ussd CUSTODIAL_URL_BASE=http://localhost:5003 BEARER_TOKEN=eyJeSIsInRcCI6IkpXVCJ.yJwdWJsaWNLZXkiOiIwrrrrrr DATA_URL_BASE=http://localhost:5006 + +#Language +DEFAULT_LANGUAGE=eng +LANGUAGES=eng, swa diff --git a/Dockerfile b/Dockerfile index 3a5da7d..d68733c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,7 @@ WORKDIR /build RUN echo "Building on $BUILDPLATFORM, building for $TARGETPLATFORM" RUN go mod download RUN go build -tags logtrace -o ussd-africastalking -ldflags="-X main.build=${BUILD} -s -w" cmd/africastalking/main.go +RUN go build -tags logtrace -o ussd-ssh -ldflags="-X main.build=${BUILD} -s -w" cmd/ssh/main.go FROM debian:bookworm-slim @@ -30,6 +31,7 @@ RUN apt-get clean && rm -rf /var/lib/apt/lists/* WORKDIR /service COPY --from=build /build/ussd-africastalking . +COPY --from=build /build/ussd-ssh . COPY --from=build /build/LICENSE . COPY --from=build /build/README.md . COPY --from=build /build/services ./services @@ -37,5 +39,6 @@ COPY --from=build /build/.env.example . RUN mv .env.example .env EXPOSE 7123 +EXPOSE 7122 CMD ["./ussd-africastalking"] \ No newline at end of file diff --git a/cmd/async/main.go b/cmd/async/main.go index b6deca9..e48b92a 100644 --- a/cmd/async/main.go +++ b/cmd/async/main.go @@ -12,6 +12,7 @@ import ( "git.defalsify.org/vise.git/engine" "git.defalsify.org/vise.git/logging" "git.defalsify.org/vise.git/resource" + "git.defalsify.org/vise.git/lang" "git.grassecon.net/urdt/ussd/config" "git.grassecon.net/urdt/ussd/initializers" @@ -19,6 +20,7 @@ import ( "git.grassecon.net/urdt/ussd/internal/storage" "git.grassecon.net/urdt/ussd/remote" "git.grassecon.net/urdt/ussd/request" + "git.grassecon.net/urdt/ussd/internal/args" ) var ( @@ -56,6 +58,8 @@ func main() { var host string var port uint var err error + var gettextDir string + var langs args.LangVar flag.StringVar(&sessionId, "session-id", "075xx2123", "session id") flag.StringVar(&resourceDir, "resourcedir", path.Join("services", "registration"), "resource dir") @@ -64,6 +68,8 @@ func main() { flag.UintVar(&size, "s", 160, "max size of output") flag.StringVar(&host, "h", initializers.GetEnv("HOST", "127.0.0.1"), "http host") flag.UintVar(&port, "p", initializers.GetEnvUint("PORT", 7123), "http port") + flag.StringVar(&gettextDir, "gettext", "", "use gettext translations from given directory") + flag.Var(&langs, "language", "add symbol resolution for language") flag.Parse() if connStr != "" { @@ -79,6 +85,14 @@ func main() { ctx := context.Background() ctx = context.WithValue(ctx, "Database", database) + + ln, err := lang.LanguageFromCode(config.DefaultLanguage) + if err != nil { + fmt.Fprintf(os.Stderr, "default language set error: %v", err) + os.Exit(1) + } + ctx = context.WithValue(ctx, "Language", ln) + pfp := path.Join(scriptDir, "pp.csv") cfg := engine.Config{ diff --git a/cmd/http/main.go b/cmd/http/main.go index 5112aca..2ce4e6a 100644 --- a/cmd/http/main.go +++ b/cmd/http/main.go @@ -14,6 +14,7 @@ import ( "git.defalsify.org/vise.git/engine" "git.defalsify.org/vise.git/logging" "git.defalsify.org/vise.git/resource" + "git.defalsify.org/vise.git/lang" "git.grassecon.net/urdt/ussd/config" "git.grassecon.net/urdt/ussd/initializers" @@ -21,6 +22,7 @@ import ( httpserver "git.grassecon.net/urdt/ussd/internal/http" "git.grassecon.net/urdt/ussd/internal/storage" "git.grassecon.net/urdt/ussd/remote" + "git.grassecon.net/urdt/ussd/internal/args" ) var ( @@ -44,6 +46,8 @@ func main() { var host string var port uint var err error + var gettextDir string + var langs args.LangVar flag.StringVar(&resourceDir, "resourcedir", path.Join("services", "registration"), "resource dir") flag.StringVar(&connStr, "c", "", "connection string") @@ -51,6 +55,8 @@ func main() { flag.UintVar(&size, "s", 160, "max size of output") flag.StringVar(&host, "h", initializers.GetEnv("HOST", "127.0.0.1"), "http host") flag.UintVar(&port, "p", initializers.GetEnvUint("PORT", 7123), "http port") + flag.StringVar(&gettextDir, "gettext", "", "use gettext translations from given directory") + flag.Var(&langs, "language", "add symbol resolution for language") flag.Parse() if connStr != "" { @@ -66,6 +72,14 @@ func main() { ctx := context.Background() ctx = context.WithValue(ctx, "Database", database) + + ln, err := lang.LanguageFromCode(config.DefaultLanguage) + if err != nil { + fmt.Fprintf(os.Stderr, "default language set error: %v", err) + os.Exit(1) + } + ctx = context.WithValue(ctx, "Language", ln) + pfp := path.Join(scriptDir, "pp.csv") cfg := engine.Config{ diff --git a/cmd/main.go b/cmd/main.go index b84e864..ec07634 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -10,10 +10,12 @@ import ( "git.defalsify.org/vise.git/engine" "git.defalsify.org/vise.git/logging" "git.defalsify.org/vise.git/resource" + "git.defalsify.org/vise.git/lang" "git.grassecon.net/urdt/ussd/config" "git.grassecon.net/urdt/ussd/handlers" "git.grassecon.net/urdt/ussd/initializers" "git.grassecon.net/urdt/ussd/internal/storage" + "git.grassecon.net/urdt/ussd/internal/args" "git.grassecon.net/urdt/ussd/remote" ) @@ -27,6 +29,7 @@ func init() { initializers.LoadEnvVariables() } +// TODO: external script automatically generate language handler list from select language vise code OR consider dynamic menu generation script possibility func main() { config.LoadConfig() @@ -37,12 +40,16 @@ func main() { var engineDebug bool var resourceDir string var err error + var gettextDir string + var langs args.LangVar flag.StringVar(&resourceDir, "resourcedir", scriptDir, "resource dir") flag.StringVar(&sessionId, "session-id", "075xx2123", "session id") flag.StringVar(&connStr, "c", "", "connection string") flag.BoolVar(&engineDebug, "d", false, "use engine debug output") flag.UintVar(&size, "s", 160, "max size of output") + flag.StringVar(&gettextDir, "gettext", "", "use gettext translations from given directory") + flag.Var(&langs, "language", "add symbol resolution for language") flag.Parse() if connStr != "" { @@ -56,9 +63,21 @@ func main() { logg.Infof("start command", "conn", connData, "outputsize", size) + if len(langs.Langs()) == 0 { + langs.Set(config.DefaultLanguage) + } + ctx := context.Background() ctx = context.WithValue(ctx, "SessionId", sessionId) ctx = context.WithValue(ctx, "Database", database) + + ln, err := lang.LanguageFromCode(config.DefaultLanguage) + if err != nil { + fmt.Fprintf(os.Stderr, "default language set error: %v", err) + os.Exit(1) + } + ctx = context.WithValue(ctx, "Language", ln) + pfp := path.Join(scriptDir, "pp.csv") cfg := engine.Config{ @@ -70,6 +89,10 @@ func main() { } menuStorageService := storage.NewMenuStorageService(connData, resourceDir) + + if gettextDir != "" { + menuStorageService = menuStorageService.WithGettext(gettextDir, langs.Langs()) + } rs, err := menuStorageService.GetResource(ctx) if err != nil { diff --git a/cmd/ssh/main.go b/cmd/ssh/main.go index 58f2b0b..e55d793 100644 --- a/cmd/ssh/main.go +++ b/cmd/ssh/main.go @@ -4,9 +4,9 @@ import ( "context" "flag" "fmt" - "path" "os" "os/signal" + "path" "sync" "syscall" @@ -21,10 +21,12 @@ import ( ) var ( - wg sync.WaitGroup - keyStore db.Db + wg sync.WaitGroup + keyStore db.Db logg = logging.NewVanilla() scriptDir = path.Join("services", "registration") + + build = "dev" ) func init() { @@ -94,7 +96,7 @@ func main() { fmt.Fprintf(os.Stderr, "keystore file open error: %v", err) os.Exit(1) } - defer func () { + defer func() { logg.TraceCtxf(ctx, "shutdown auth key store reached") err = authKeyStore.Close() if err != nil { @@ -113,9 +115,9 @@ func main() { FlagFile: pfp, Conn: connData, ResourceDir: resourceDir, - SrvKeyFile: sshKeyFile, - Host: host, - Port: port, + SrvKeyFile: sshKeyFile, + Host: host, + Port: port, } go func() { select { @@ -127,7 +129,7 @@ func main() { if err != nil { logg.ErrorCtxf(ctx, "runner stop error", "err", err) } - + }() runner.Run(ctx, authKeyStore) } diff --git a/config/config.go b/config/config.go index 6f2b225..4b43b42 100644 --- a/config/config.go +++ b/config/config.go @@ -2,6 +2,7 @@ package config import ( "net/url" + "strings" "git.grassecon.net/urdt/ussd/initializers" ) @@ -18,6 +19,11 @@ const ( AliasPrefix = "api/v1/alias" ) +var ( + defaultLanguage = "eng" + languages []string +) + var ( custodialURLBase string dataURLBase string @@ -35,8 +41,28 @@ var ( VoucherDataURL string CheckAliasURL string DbConn string + DefaultLanguage string + Languages []string ) +func setLanguage() error { + defaultLanguage = initializers.GetEnv("DEFAULT_LANGUAGE", defaultLanguage) + languages = strings.Split(initializers.GetEnv("LANGUAGES", defaultLanguage), ",") + haveDefaultLanguage := false + for i, v := range(languages) { + languages[i] = strings.ReplaceAll(v, " ", "") + if languages[i] == defaultLanguage { + haveDefaultLanguage = true + } + } + + if !haveDefaultLanguage { + languages = append([]string{defaultLanguage}, languages...) + } + + return nil +} + func setBase() error { var err error @@ -71,6 +97,10 @@ func LoadConfig() error { if err != nil { return err } + err = setLanguage() + if err != nil { + return err + } CreateAccountURL, _ = url.JoinPath(custodialURLBase, createAccountPath) TrackStatusURL, _ = url.JoinPath(custodialURLBase, trackStatusPath) BalanceURL, _ = url.JoinPath(custodialURLBase, balancePathPrefix) @@ -80,6 +110,8 @@ func LoadConfig() error { VoucherTransfersURL, _ = url.JoinPath(dataURLBase, voucherTransfersPathPrefix) VoucherDataURL, _ = url.JoinPath(dataURLBase, voucherDataPathPrefix) CheckAliasURL, _ = url.JoinPath(dataURLBase, AliasPrefix) + DefaultLanguage = defaultLanguage + Languages = languages return nil } diff --git a/devtools/lang/main.go b/devtools/lang/main.go new file mode 100644 index 0000000..83c68b3 --- /dev/null +++ b/devtools/lang/main.go @@ -0,0 +1,126 @@ +// create language files from environment +package main + +import ( + "flag" + "fmt" + "os" + "path" + "strings" + + "git.defalsify.org/vise.git/logging" + "git.defalsify.org/vise.git/lang" + "git.grassecon.net/urdt/ussd/config" + "git.grassecon.net/urdt/ussd/initializers" +) + +const ( + + changeHeadSrc = `LOAD reset_account_authorized 0 +LOAD reset_incorrect 0 +CATCH incorrect_pin flag_incorrect_pin 1 +CATCH pin_entry flag_account_authorized 0 +` + + selectSrc = `LOAD set_language 6 +RELOAD set_language +CATCH terms flag_account_created 0 +MOVE language_changed +` +) + +var ( + logg = logging.NewVanilla() + mouts string + incmps string +) + +func init() { + initializers.LoadEnvVariables() +} + +func toLanguageLabel(ln lang.Language) string { + s := ln.Name + v := strings.Split(s, " (") + if len(v) > 1 { + s = v[0] + } + return s +} + +func toLanguageKey(ln lang.Language) string { + s := toLanguageLabel(ln) + return strings.ToLower(s) +} + +func main() { + var srcDir string + + flag.StringVar(&srcDir, "o", ".", "resource dir write to") + flag.Parse() + + logg.Infof("start command", "dir", srcDir) + + err := config.LoadConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "config load error: %v", err) + os.Exit(1) + } + logg.Tracef("using languages", "lang", config.Languages) + + for i, v := range(config.Languages) { + ln, err := lang.LanguageFromCode(v) + if err != nil { + fmt.Fprintf(os.Stderr, "error parsing language: %s\n", v) + os.Exit(1) + } + n := i + 1 + s := toLanguageKey(ln) + mouts += fmt.Sprintf("MOUT %s %v\n", s, n) + v = "set_" + ln.Code + incmps += fmt.Sprintf("INCMP %s %v\n", v, n) + + p := path.Join(srcDir, v) + w, err := os.OpenFile(p, os.O_WRONLY | os.O_CREATE | os.O_EXCL, 0600) + if err != nil { + fmt.Fprintf(os.Stderr, "failed open language set template output: %v\n", err) + os.Exit(1) + } + s = toLanguageLabel(ln) + defer w.Close() + _, err = w.Write([]byte(s)) + if err != nil { + fmt.Fprintf(os.Stderr, "failed write select language vis output: %v\n", err) + os.Exit(1) + } + } + src := mouts + "HALT\n" + incmps + src += "INCMP . *\n" + + p := path.Join(srcDir, "select_language.vis") + w, err := os.OpenFile(p, os.O_WRONLY | os.O_CREATE | os.O_EXCL, 0600) + if err != nil { + fmt.Fprintf(os.Stderr, "failed open select language vis output: %v\n", err) + os.Exit(1) + } + defer w.Close() + _, err = w.Write([]byte(src)) + if err != nil { + fmt.Fprintf(os.Stderr, "failed write select language vis output: %v\n", err) + os.Exit(1) + } + + src = changeHeadSrc + src + p = path.Join(srcDir, "change_language.vis") + w, err = os.OpenFile(p, os.O_WRONLY | os.O_CREATE | os.O_EXCL, 0600) + if err != nil { + fmt.Fprintf(os.Stderr, "failed open select language vis output: %v\n", err) + os.Exit(1) + } + defer w.Close() + _, err = w.Write([]byte(src)) + if err != nil { + fmt.Fprintf(os.Stderr, "failed write select language vis output: %v\n", err) + os.Exit(1) + } +} diff --git a/devtools/store/main.go b/devtools/store/dump/main.go similarity index 86% rename from devtools/store/main.go rename to devtools/store/dump/main.go index 6915f74..c84a134 100644 --- a/devtools/store/main.go +++ b/devtools/store/dump/main.go @@ -25,6 +25,15 @@ func init() { } +func formatItem(k []byte, v []byte) (string, error) { + o, err := debug.FromKey(k) + if err != nil { + return "", err + } + s := fmt.Sprintf("%vValue: %v\n\n", o, string(v)) + return s, nil +} + func main() { config.LoadConfig() @@ -75,12 +84,12 @@ func main() { if k == nil { break } - o, err := debug.FromKey(k) + r, err := formatItem(k, v) if err != nil { - fmt.Fprintf(os.Stderr, err.Error()) + fmt.Fprintf(os.Stderr, "format db item error: %v", err) os.Exit(1) } - fmt.Printf("%vValue: %v\n\n", o, string(v)) + fmt.Printf(r) } err = store.Close() diff --git a/devtools/gen/main.go b/devtools/store/generate/main.go similarity index 100% rename from devtools/gen/main.go rename to devtools/store/generate/main.go diff --git a/internal/args/lang.go b/internal/args/lang.go new file mode 100644 index 0000000..f9afdc9 --- /dev/null +++ b/internal/args/lang.go @@ -0,0 +1,34 @@ +package args + +import ( + "strings" + + "git.defalsify.org/vise.git/lang" +) + +type LangVar struct { + v []lang.Language +} + +func(lv *LangVar) Set(s string) error { + v, err := lang.LanguageFromCode(s) + if err != nil { + return err + } + lv.v = append(lv.v, v) + return err +} + +func(lv *LangVar) String() string { + var s []string + for _, v := range(lv.v) { + s = append(s, v.Code) + } + return strings.Join(s, ",") +} + +func(lv *LangVar) Langs() []lang.Language { + return lv.v +} + + diff --git a/internal/storage/storageservice.go b/internal/storage/storageservice.go index 33cbf5b..16f7614 100644 --- a/internal/storage/storageservice.go +++ b/internal/storage/storageservice.go @@ -9,6 +9,7 @@ import ( "git.defalsify.org/vise.git/db" fsdb "git.defalsify.org/vise.git/db/fs" "git.defalsify.org/vise.git/db/postgres" + "git.defalsify.org/vise.git/lang" "git.defalsify.org/vise.git/logging" "git.defalsify.org/vise.git/persist" "git.defalsify.org/vise.git/resource" @@ -28,6 +29,7 @@ type StorageService interface { type MenuStorageService struct { conn ConnData resourceDir string + poResource resource.Resource resourceStore db.Db stateStore db.Db userDataStore db.Db @@ -77,6 +79,28 @@ func (ms *MenuStorageService) getOrCreateDb(ctx context.Context, existingDb db.D return newDb, nil } +// WithGettext triggers use of gettext for translation of templates and menus. +// +// The first language in `lns` will be used as default language, to resolve node keys to +// language strings. +// +// If `lns` is an empty array, gettext will not be used. +func (ms *MenuStorageService) WithGettext(path string, lns []lang.Language) *MenuStorageService { + if len(lns) == 0 { + logg.Warnf("Gettext requested but no languages supplied") + return ms + } + rs := resource.NewPoResource(lns[0], path) + + for _, ln := range(lns) { + rs = rs.WithLanguage(ln) + } + + ms.poResource = rs + + return ms +} + func (ms *MenuStorageService) GetPersister(ctx context.Context) (*persist.Persister, error) { stateStore, err := ms.GetStateStore(ctx) if err != nil { @@ -109,6 +133,11 @@ func (ms *MenuStorageService) GetResource(ctx context.Context) (resource.Resourc return nil, err } rfs := resource.NewDbResource(ms.resourceStore) + if ms.poResource != nil { + logg.InfoCtxf(ctx, "using poresource for menu and template") + rfs.WithMenuGetter(ms.poResource.GetMenu) + rfs.WithTemplateGetter(ms.poResource.GetTemplate) + } return rfs, nil }