Compare commits

...

178 Commits

Author SHA1 Message Date
f40e11c267 Merge pull request 'account-pin-block-v2' (#256) from account-pin-block-v2 into master
Some checks are pending
release / docker (push) Waiting to run
Reviewed-on: #256
2025-01-08 13:30:39 +01:00
b698f08136
chore: add space after punctuation 2025-01-08 15:27:10 +03:00
efdb52bccd
chore: add space after punctuation 2025-01-08 14:54:58 +03:00
2ff9fed3c5
chore: rename countIncorrectPINAttempts to incrementIncorrectPINAttempts 2025-01-08 14:54:57 +03:00
477b4cf8f6
chore : rename remainingPINAttempts to currentWrongPinAttemptsCount 2025-01-08 14:54:57 +03:00
ed6651697a
chore : add variable description to AllowedPINAttempts 2025-01-08 14:54:56 +03:00
c359d99075 Merge branch 'master' into account-pin-block-v2 2025-01-08 10:00:46 +01:00
8d477356f3
update tests 2025-01-08 11:47:55 +03:00
7f3294a8a2
update tests 2025-01-08 11:47:41 +03:00
f49e54a562 Merge pull request 'Space after comma' (#259) from lash/helpcomma into master
Reviewed-on: #259
2025-01-08 07:57:18 +01:00
lash
5081b6d4ce
Space after comma 2025-01-08 06:48:35 +00:00
4d72ae0313
add handler showing a message for a blocked account 2025-01-08 09:30:51 +03:00
4fe64a7747
show message for a blocked account 2025-01-08 09:29:00 +03:00
3004698d5b
add a message for a blocked account 2025-01-08 09:28:31 +03:00
50c7ff1046
register handler to show blocked account message 2025-01-08 09:27:45 +03:00
07b061a68b
remove blocked account templates 2025-01-08 09:26:53 +03:00
64fba91670
catch blocked account 2025-01-07 14:38:44 +03:00
c15958a1ad
reset incorrect pin attempts on correct entry 2025-01-07 14:32:44 +03:00
ee442daefa
add blocked account node 2025-01-07 14:03:53 +03:00
656052dc74 Merge pull request 'trim any leading whitespace in the input' (#258) from send-input-fix into master
Some checks failed
release / docker (push) Has been cancelled
Reviewed-on: #258
Reviewed-by: lash <accounts-grassrootseconomics@holbrook.no>
2025-01-07 10:33:20 +01:00
6c5873da6f
trim any leading whitespace in the input 2025-01-07 12:15:15 +03:00
11d30583a4
map content of reset_incorrect and catch blocked account 2025-01-07 10:50:30 +03:00
f83f539046
add node to show remaining pin attempts 2025-01-07 10:48:59 +03:00
562bd4fa24
check for incorrect pin 2025-01-06 22:54:31 +03:00
90df0eefc3
add value for allowed number of PIN attempts 2025-01-06 22:53:59 +03:00
b37f2a0a11
add flag for when an account has been blocked 2025-01-06 21:06:54 +03:00
68e4c9af03
add key for incorrect pin attempts 2025-01-06 21:00:34 +03:00
80b96e9bf6 Merge pull request 'Add gettext capability to template and menu resources' (#239) from lash/gettext into master
Reviewed-on: #239
2025-01-06 09:49:53 +01:00
b5561decd1 Merge branch 'master' into lash/gettext 2025-01-06 09:48:33 +01:00
f3d4f35718 Merge pull request 'Factor out db dump formatting' (#243) from lash/dump-format into master
Reviewed-on: #243
2025-01-06 09:44:29 +01:00
52787bdb4d Merge branch 'master' into lash/dump-format 2025-01-06 09:42:26 +01:00
824d39908b
ci: fix missing ssh dir
Some checks failed
release / docker (push) Has been cancelled
2025-01-06 11:19:36 +03:00
a312ea5b84
feat: inject build string in ssh binary, expose default ssh port
Some checks failed
release / docker (push) Has been cancelled
2025-01-06 11:09:51 +03:00
4836162f40
ci: add ssh build
Some checks failed
release / docker (push) Has been cancelled
2025-01-06 10:51:20 +03:00
lash
2024cc96e2
Bring up-to-date with refactor
Some checks failed
release / docker (push) Has been cancelled
2025-01-06 07:22:58 +00:00
lash
d2d878d5d7 Merge branch 'master' into lash/ssh-4 2025-01-06 07:12:00 +00:00
c995143543 Merge pull request 'log-session-id-at-sessionid' (#251) from log-session-id-at-sessionid into master
Reviewed-on: #251
Reviewed-by: lash <accounts-grassrootseconomics@holbrook.no>
2025-01-06 08:01:20 +01:00
44570e20ef
remove unused context key :- at-session-id 2025-01-06 09:59:47 +03:00
362eb209ef
add SessionId to context key 2025-01-06 09:54:28 +03:00
c69d3896f1
pass context as an argument,rename context keys 2025-01-06 08:52:53 +03:00
974af6b2a7
pass context as an argument 2025-01-06 08:50:53 +03:00
lash
bb4037e73f
Add languages env example 2025-01-05 21:25:09 +00:00
lash
83857026d3 Merge branch 'master' into lash/dump-format 2025-01-04 10:00:25 +00:00
lash
349051b5ef Merge branch 'master' into lash/gettext 2025-01-04 09:59:26 +00:00
47b5ff0435 Merge pull request 'Improve separation of concerns in all modules, phase 1' (#246) from lash/purify into master
Reviewed-on: #246
2025-01-04 10:56:17 +01:00
lash
25867cf05e
Rehabilitate voucher test 2025-01-04 09:42:36 +00:00
d5a2680500
make context accessible 2025-01-04 12:02:45 +03:00
lash
d950b10b50
Move prefix db spec to separate package 2025-01-04 08:37:28 +00:00
lash
bcb3ab905e
Move db related to own package 2025-01-04 08:09:18 +00:00
lash
3ed9caf16d
Factor out request parsers 2025-01-04 08:02:44 +00:00
lash
86464c31d2 Merge branch 'master' into lash/purify 2025-01-04 07:57:54 +00:00
5ee10d8e14 Merge pull request 'logs-at-sessionid' (#245) from logs-at-sessionid into master
Some checks failed
release / docker (push) Has been cancelled
Reviewed-on: #245
2025-01-04 08:56:09 +01:00
62f3681b9e
define context keysessionid using go-vise --withcontext 2025-01-04 10:40:26 +03:00
3ce1435591
extract session id from africastalking request 2025-01-04 10:38:25 +03:00
f65c458daa
update go-vise. 2025-01-04 10:35:59 +03:00
lash
67007fcd48
Factor out gdbm package 2025-01-04 07:35:28 +00:00
lash
f1b258fa6d
Factor out at code 2025-01-04 07:29:22 +00:00
lash
daec816a3e
Move store devtools location 2025-01-03 17:21:52 +00:00
lash
ac0c43cb43
Factor out formatting method 2025-01-03 17:18:23 +00:00
lash
9013cc3618
Improve error messages 2025-01-03 15:10:20 +00:00
lash
056d056613
Add language source and template file generator 2025-01-03 14:43:08 +00:00
lash
e581ec4771 Merge tag 'v0.8.0-beta.4' into lash/gettext 2025-01-03 10:29:17 +00:00
lash
e16b7445e8
Move arg var to same spot as other runners 2025-01-03 10:28:27 +00:00
lash
1b12f0ba5f
Add po language alternative to all runners 2025-01-03 10:00:52 +00:00
d2fce05461 Merge pull request 'fix: language change' (#242) from language-change-fix into master
Some checks failed
release / docker (push) Has been cancelled
Reviewed-on: #242
2025-01-03 09:30:27 +01:00
68ac237449 Merge branch 'master' into language-change-fix 2025-01-03 09:28:48 +01:00
162e6c1934
fix: language change 2025-01-03 11:26:56 +03:00
8bd025f2b2 Merge pull request 'hash-pin' (#235) from hash-pin into master
Some checks failed
release / docker (push) Has been cancelled
Reviewed-on: #235
2025-01-03 09:25:26 +01:00
9d6e25e184
revert to previous state for the adminstore 2025-01-03 11:24:24 +03:00
c26f5683f6
removed second unused argument 2025-01-03 11:17:09 +03:00
91dc9ce82f
tests: add sample pin/hash pair from migration dataset 2025-01-03 11:10:07 +03:00
0fe48a30fa
Merge branch 'master' into hash-pin 2025-01-03 06:58:41 +03:00
lash
c1e0617bb3
Update go-vise 2025-01-02 21:13:06 +00:00
lash
6723884103
Update go-vise 2025-01-02 21:02:01 +00:00
lash
b888af446d
update govise 2025-01-02 18:49:16 +00:00
lash
43b2c3b78d
Rehabilitate gettext resource 2025-01-02 18:13:37 +00:00
lash
d67853f6d9 Merge branch 'master' into lash/gettext 2025-01-02 14:53:18 +00:00
58edfa01a2 Merge pull request 'menu-primary-selectors' (#237) from menu-primary-selectors into master
Some checks failed
release / docker (push) Has been cancelled
Reviewed-on: #237
Reviewed-by: lash <accounts-grassrootseconomics@holbrook.no>
2025-01-02 15:50:42 +01:00
3830c12a57
update tests 2025-01-02 17:42:03 +03:00
f1fd690a7b
update expected content 2025-01-02 17:37:26 +03:00
lash
06230dc557
Add todo comment 2025-01-02 14:31:13 +00:00
491b7424a9
point to the correct ./devtools/admin_numbers directory 2025-01-02 16:01:19 +03:00
29ce4b83bd
added tests for HashPIN and VerifyPIN 2025-01-02 15:22:07 +03:00
ca8df5989a
updated expected age in test 2025-01-02 15:15:52 +03:00
82b4365d16
hash the PIN in TestAuthorize 2025-01-02 14:38:22 +03:00
98db85511b
hash the PIN in the ResetOthersPin function 2025-01-02 14:37:45 +03:00
99a4d3ff42
verify the PIN input against the hashed PIN 2025-01-02 13:51:57 +03:00
d95c7abea4
return if the PIN is not a match, and hash the PIN before saving it 2025-01-02 13:45:18 +03:00
fd1ac85a1b
add code to Hash and Verify the PIN 2025-01-02 13:43:38 +03:00
c899c098f6
updated the expected age 2025-01-02 13:20:01 +03:00
5ca6a74274
move PIN test to the common package 2025-01-02 13:18:49 +03:00
48d63fb43f
added pin.go to contain all PIN related functionality 2025-01-02 13:16:38 +03:00
lash
6ee2c88fe2
Implement gettext spec in local vm cmd 2025-01-02 09:39:49 +00:00
e666c58644
start primary selectors with 1 2025-01-02 12:17:28 +03:00
e980586910
chore: repeat same node on invalid menu choice 2025-01-02 12:15:57 +03:00
ffd5be1f1f
add quit option on view profile 2025-01-02 12:12:52 +03:00
ed1aeecf7d Merge pull request 'Legible dumper' (#232) from lash/dump-key-prefix into master
Some checks failed
release / docker (push) Has been cancelled
Reviewed-on: #232
2024-12-31 09:56:04 +01:00
3b69f3d38d Merge branch 'master' into lash/dump-key-prefix 2024-12-31 09:55:40 +01:00
lash
cd58f5ae33
Upgrade govise 2024-12-31 08:55:25 +00:00
7a535f796a
output the value as a string 2024-12-31 11:41:04 +03:00
7c4c73125e Merge pull request 'force-restart-state' (#223) from force-restart-state into master
Reviewed-on: #223
2024-12-31 09:34:44 +01:00
lash
c7dbe1d88f
Remove obsolete subprefix strings 2024-12-31 08:30:08 +00:00
4ea52bf3fb
removed unused code 2024-12-31 11:16:43 +03:00
be2ea3a2f0
removed the non-working restart_state devtool 2024-12-31 10:51:29 +03:00
8217ea8fdc Merge branch 'master' into force-restart-state 2024-12-31 05:06:26 +03:00
3c73fc7188
added a test for the Init func with the different states 2024-12-31 05:05:39 +03:00
1311a0cab9
use the 'send_with_invite' test group in the menu traversal test 2024-12-31 02:36:28 +03:00
lash
3bcd48e5a7
Update govise 2024-12-30 19:58:34 +00:00
lash
0e12c0ee4e
Add prefix for dumper, format base dump key for pg 2024-12-30 19:35:45 +00:00
3caee98cdb Merge pull request 'mixed-languages' (#228) from mixed-languages into master
Reviewed-on: #228
Reviewed-by: lash <accounts-grassrootseconomics@holbrook.no>
2024-12-30 09:07:13 +01:00
db7c9bf56d
chore: add colon to enhance formatting. 2024-12-30 08:07:39 +03:00
0a332ec501
chore: ensure swahili language translation. 2024-12-30 08:05:36 +03:00
90367fe53e Merge pull request 'profile-update-fix' (#226) from profile-update-fix into master
Reviewed-on: #226
Reviewed-by: lash <accounts-grassrootseconomics@holbrook.no>
2024-12-28 16:20:32 +01:00
50c006546c
added code to reset the state and persist it 2024-12-28 13:21:03 +03:00
e8c171a82e Merge branch 'master' into force-restart-state 2024-12-28 11:46:15 +03:00
58a60f2c81
update expected age in test 2024-12-28 08:51:38 +03:00
0820e1b9f2 Merge branch 'master' into profile-update-fix 2024-12-28 06:30:14 +01:00
46edf2b819
remove
redundant catch on pin entry
2024-12-27 16:13:36 +03:00
11eb61ba35
repeat same node on invalid selection 2024-12-27 16:11:09 +03:00
813b92af78 Merge pull request 'issue-205: added comments for menu handlers methods.' (#218) from konstantinmds/ussd:dev/issue-205 into master
Reviewed-on: #218
Reviewed-by: lash <accounts-grassrootseconomics@holbrook.no>
2024-12-27 12:58:00 +01:00
5579991d66
guard profile update after being set 2024-12-27 10:07:05 +03:00
f4f4fdd3ac issue-205:
added comments for menu handlers methods and changed function name to better fit function workings.
2024-12-25 15:59:28 +01:00
be215d3f75
set the code to an empty byte for it to move to the top node 2024-12-20 12:26:07 +03:00
235af3519d Merge pull request 'add-space-after-colon' (#211) from add-space-after-colon into master
Some checks failed
release / docker (push) Has been cancelled
Reviewed-on: #211
2024-12-19 11:35:13 +01:00
1292851226
rename the function to ReplaceSeparatorFunc 2024-12-19 13:32:39 +03:00
dfd0a0994b Merge branch 'master' into force-restart-state 2024-12-18 22:39:20 +03:00
97fcdda12f
Merge branch 'master' into add-space-after-colon 2024-12-18 22:30:41 +03:00
055c2db790
use a common mockReplaceSeparator func 2024-12-18 22:25:47 +03:00
ecfdab47a8
updated test 2024-12-18 22:21:52 +03:00
fda68231ea
use the replaceSeparator func to format the generated menus 2024-12-18 21:59:09 +03:00
d08afff443
add the replaceSeparator func and pass it to the Handler struct 2024-12-18 21:56:37 +03:00
17ba6a06ba
remove the MenuSeparator from the context 2024-12-18 21:49:42 +03:00
dbd59a4023 Merge pull request 'add link to terms page' (#217) from link-terms-and-conditions into master
Reviewed-on: #217
Reviewed-by: lash <accounts-grassrootseconomics@holbrook.no>
2024-12-18 07:45:48 +01:00
5534706189
reset the state when input is nil 2024-12-17 17:58:08 +03:00
5428626c3f
cleaned up the restart_state 2024-12-17 17:56:56 +03:00
9b33117cb1
add space on expected content 2024-12-17 16:02:35 +03:00
70b2fa4ac2
add spacing after link 2024-12-17 15:46:28 +03:00
fd6ff86579
add link to terms and conditions as expected content 2024-12-17 15:24:15 +03:00
549782f230
add link to terms page 2024-12-17 15:12:38 +03:00
8cf4848b45 Merge pull request 'Userstore dumper tool' (#153) from lash/store-dumper into master
Some checks failed
release / docker (push) Has been cancelled
Reviewed-on: #153
2024-12-14 13:02:48 +01:00
9f6c0a1111 Merge branch 'master' into lash/store-dumper 2024-12-14 13:02:38 +01:00
lash
1ab49647f6
Upgrade vise 2024-12-14 12:02:13 +00:00
lash
8d4d8a48e0
Fix compile errors, test errors 2024-12-14 11:56:31 +00:00
7aea2af9a1
updated tests 2024-12-13 11:44:04 +03:00
5cd791aae7
use the MenuSeparator 2024-12-13 11:43:47 +03:00
df5e5f1a4b
properly format the vouchers 2024-12-13 11:40:39 +03:00
64c1fe5276
set the separator as a var and add it to the context 2024-12-13 11:38:10 +03:00
f38ea59569
set the separator as a config and not an arg 2024-12-13 01:10:46 +03:00
6cc285d1e8
add the custom separator to the menu 2024-12-12 21:12:25 +03:00
0d7f7aaca1
use latest commits from go-vise 2024-12-12 21:09:48 +03:00
f8ea2daa73
initialize the restart state devtool 2024-12-12 19:55:01 +03:00
lash
c820e89cb7
Segment tx userdata types, add internals docs 2024-12-11 19:13:13 +00:00
e05f8e7291
update to the latest go-vise changes 2024-12-11 19:46:52 +03:00
2383e8ead3
updated failed menuhandler_test 2024-12-11 19:35:04 +03:00
1a4ee0d3e1
updated the description of the GetTransactionsList function 2024-12-11 19:32:41 +03:00
6f3b30e2fe
Capitalize statement details and add a space after the colon 2024-12-11 19:31:17 +03:00
b1e4b63c6a
Add a space after the colon for vouchers 2024-12-11 19:28:53 +03:00
3129e8210e
Capitalize the transfer statement details 2024-12-11 19:25:38 +03:00
5d8de80a18
Write the error in the response 2024-12-11 18:58:50 +03:00
lash
604c16ec90
Habilitate store dumper 2024-12-08 22:48:39 +00:00
lash
a3e821fb16
Clarify gen cmd example with reverse public key lookup 2024-12-08 21:57:53 +00:00
lash
890f50704f
Add documentation comments for db subprefix types 2024-12-08 21:21:48 +00:00
lash
3416fdf50c
Add keyinfo restore 2024-12-07 23:06:03 +00:00
lash
ff943a125c Merge branch 'master' into lash/store-dumper 2024-12-05 15:30:29 +00:00
lash
c2a4efde2b Merge branch 'master' into lash/store-dumper 2024-12-02 15:39:29 +00:00
lash
c9deca1180
change userdata prefix on subprefix debug 2024-12-02 14:36:33 +00:00
lash
6d4f3109f8
Consolidate subtyp and typ debug 2024-12-01 23:12:58 +00:00
lash
35cf3a1cd1
Add debug label test, capability debug flag 2024-12-01 18:41:28 +00:00
lash
1a782c1db9
Add debug package 2024-12-01 17:32:06 +00:00
lash
3d2ca606ca Merge branch 'master' into lash/store-dumper 2024-12-01 16:34:10 +00:00
584d02db29 Merge branch 'master' into lash/store-dumper 2024-11-05 00:19:06 +01:00
5937c6bf5c Merge branch 'master' into lash/store-dumper 2024-11-04 13:56:44 +01:00
lash
10b3083647
WIP Add db dumper tool 2024-11-02 20:29:32 +00:00
lash
bb1a846cb3 Merge remote-tracking branch 'origin/master' into lash/ssh-4 2024-10-31 20:52:09 +00:00
lash
967e53d83b Merge branch 'master' into lash/ssh-4 2024-10-14 14:50:12 +01:00
lash
d246cdee51
Rename datatype const name for ssh prefix 2024-09-27 21:25:21 +01:00
lash
d518a76536 Merge branch 'lash/subprefix' into lash/ssh-4 2024-09-27 21:18:25 +01:00
lash
6f65c33be4
Re-add ssh 2024-09-26 15:15:06 +01:00
77 changed files with 2593 additions and 637 deletions

View File

@ -1,5 +1,6 @@
/** /**
!/cmd/africastalking !/cmd/africastalking
!/cmd/ssh
!/common !/common
!/config !/config
!/initializers !/initializers

View File

@ -18,3 +18,7 @@ DB_TIMEZONE=Africa/Nairobi
CUSTODIAL_URL_BASE=http://localhost:5003 CUSTODIAL_URL_BASE=http://localhost:5003
BEARER_TOKEN=eyJeSIsInRcCI6IkpXVCJ.yJwdWJsaWNLZXkiOiIwrrrrrr BEARER_TOKEN=eyJeSIsInRcCI6IkpXVCJ.yJwdWJsaWNLZXkiOiIwrrrrrr
DATA_URL_BASE=http://localhost:5006 DATA_URL_BASE=http://localhost:5006
#Language
DEFAULT_LANGUAGE=eng
LANGUAGES=eng, swa

View File

@ -19,6 +19,7 @@ WORKDIR /build
RUN echo "Building on $BUILDPLATFORM, building for $TARGETPLATFORM" RUN echo "Building on $BUILDPLATFORM, building for $TARGETPLATFORM"
RUN go mod download 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-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 FROM debian:bookworm-slim
@ -30,6 +31,7 @@ RUN apt-get clean && rm -rf /var/lib/apt/lists/*
WORKDIR /service WORKDIR /service
COPY --from=build /build/ussd-africastalking . COPY --from=build /build/ussd-africastalking .
COPY --from=build /build/ussd-ssh .
COPY --from=build /build/LICENSE . COPY --from=build /build/LICENSE .
COPY --from=build /build/README.md . COPY --from=build /build/README.md .
COPY --from=build /build/services ./services COPY --from=build /build/services ./services
@ -37,5 +39,6 @@ COPY --from=build /build/.env.example .
RUN mv .env.example .env RUN mv .env.example .env
EXPOSE 7123 EXPOSE 7123
EXPOSE 7122
CMD ["./ussd-africastalking"] CMD ["./ussd-africastalking"]

View File

@ -1,110 +1,41 @@
package main package main
import ( import (
"bytes"
"context" "context"
"encoding/json"
"flag" "flag"
"fmt" "fmt"
"io"
"log"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"path" "path"
"strconv" "strconv"
"strings"
"syscall" "syscall"
"git.defalsify.org/vise.git/engine" "git.defalsify.org/vise.git/engine"
"git.defalsify.org/vise.git/logging" "git.defalsify.org/vise.git/logging"
"git.defalsify.org/vise.git/resource" "git.defalsify.org/vise.git/resource"
"git.defalsify.org/vise.git/lang"
"git.grassecon.net/urdt/ussd/common"
"git.grassecon.net/urdt/ussd/config" "git.grassecon.net/urdt/ussd/config"
"git.grassecon.net/urdt/ussd/initializers" "git.grassecon.net/urdt/ussd/initializers"
"git.grassecon.net/urdt/ussd/internal/handlers" "git.grassecon.net/urdt/ussd/internal/handlers"
httpserver "git.grassecon.net/urdt/ussd/internal/http" "git.grassecon.net/urdt/ussd/internal/http/at"
httpserver "git.grassecon.net/urdt/ussd/internal/http/at"
"git.grassecon.net/urdt/ussd/internal/storage" "git.grassecon.net/urdt/ussd/internal/storage"
"git.grassecon.net/urdt/ussd/remote" "git.grassecon.net/urdt/ussd/remote"
"git.grassecon.net/urdt/ussd/internal/args"
) )
var ( var (
logg = logging.NewVanilla() logg = logging.NewVanilla().WithDomain("AfricasTalking").WithContextKey("at-session-id")
scriptDir = path.Join("services", "registration") scriptDir = path.Join("services", "registration")
build = "dev" build = "dev"
menuSeparator = ": "
) )
func init() { func init() {
initializers.LoadEnvVariables() initializers.LoadEnvVariables()
} }
type atRequestParser struct{}
func (arp *atRequestParser) GetSessionId(rq any) (string, error) {
rqv, ok := rq.(*http.Request)
if !ok {
log.Printf("got an invalid request:", rq)
return "", handlers.ErrInvalidRequest
}
// Capture body (if any) for logging
body, err := io.ReadAll(rqv.Body)
if err != nil {
log.Printf("failed to read request body:", err)
return "", fmt.Errorf("failed to read request body: %v", err)
}
// Reset the body for further reading
rqv.Body = io.NopCloser(bytes.NewReader(body))
// Log the body as JSON
bodyLog := map[string]string{"body": string(body)}
logBytes, err := json.Marshal(bodyLog)
if err != nil {
log.Printf("failed to marshal request body:", err)
} else {
log.Printf("Received request:", string(logBytes))
}
if err := rqv.ParseForm(); err != nil {
log.Printf("failed to parse form data: %v", err)
return "", fmt.Errorf("failed to parse form data: %v", err)
}
phoneNumber := rqv.FormValue("phoneNumber")
if phoneNumber == "" {
return "", fmt.Errorf("no phone number found")
}
formattedNumber, err := common.FormatPhoneNumber(phoneNumber)
if err != nil {
fmt.Printf("Error: %v\n", err)
return "", fmt.Errorf("failed to format number")
}
return formattedNumber, nil
}
func (arp *atRequestParser) GetInput(rq any) ([]byte, error) {
rqv, ok := rq.(*http.Request)
if !ok {
return nil, handlers.ErrInvalidRequest
}
if err := rqv.ParseForm(); err != nil {
return nil, fmt.Errorf("failed to parse form data: %v", err)
}
text := rqv.FormValue("text")
parts := strings.Split(text, "*")
if len(parts) == 0 {
return nil, fmt.Errorf("no input found")
}
return []byte(parts[len(parts)-1]), nil
}
func main() { func main() {
config.LoadConfig() config.LoadConfig()
@ -115,6 +46,8 @@ func main() {
var engineDebug bool var engineDebug bool
var host string var host string
var port uint var port uint
var gettextDir string
var langs args.LangVar
flag.StringVar(&dbDir, "dbdir", ".state", "database dir to read from") flag.StringVar(&dbDir, "dbdir", ".state", "database dir to read from")
flag.StringVar(&resourceDir, "resourcedir", path.Join("services", "registration"), "resource dir") flag.StringVar(&resourceDir, "resourcedir", path.Join("services", "registration"), "resource dir")
flag.StringVar(&database, "db", "gdbm", "database to be used") flag.StringVar(&database, "db", "gdbm", "database to be used")
@ -122,18 +55,28 @@ func main() {
flag.UintVar(&size, "s", 160, "max size of output") flag.UintVar(&size, "s", 160, "max size of output")
flag.StringVar(&host, "h", initializers.GetEnv("HOST", "127.0.0.1"), "http host") flag.StringVar(&host, "h", initializers.GetEnv("HOST", "127.0.0.1"), "http host")
flag.UintVar(&port, "p", initializers.GetEnvUint("PORT", 7123), "http port") 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() flag.Parse()
logg.Infof("start command", "build", build, "dbdir", dbDir, "resourcedir", resourceDir, "outputsize", size) logg.Infof("start command", "build", build, "dbdir", dbDir, "resourcedir", resourceDir, "outputsize", size)
ctx := context.Background() ctx := context.Background()
ctx = context.WithValue(ctx, "Database", database) 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") pfp := path.Join(scriptDir, "pp.csv")
cfg := engine.Config{ cfg := engine.Config{
Root: "root", Root: "root",
OutputSize: uint32(size), OutputSize: uint32(size),
FlagCount: uint32(128), FlagCount: uint32(128),
MenuSeparator: menuSeparator,
} }
if engineDebug { if engineDebug {
@ -191,7 +134,7 @@ func main() {
} }
defer stateStore.Close() defer stateStore.Close()
rp := &atRequestParser{} rp := &at.ATRequestParser{}
bsh := handlers.NewBaseSessionHandler(cfg, rs, stateStore, userdataStore, rp, hl) bsh := handlers.NewBaseSessionHandler(cfg, rs, stateStore, userdataStore, rp, hl)
sh := httpserver.NewATSessionHandler(bsh) sh := httpserver.NewATSessionHandler(bsh)

View File

@ -12,17 +12,20 @@ import (
"git.defalsify.org/vise.git/engine" "git.defalsify.org/vise.git/engine"
"git.defalsify.org/vise.git/logging" "git.defalsify.org/vise.git/logging"
"git.defalsify.org/vise.git/resource" "git.defalsify.org/vise.git/resource"
"git.defalsify.org/vise.git/lang"
"git.grassecon.net/urdt/ussd/config" "git.grassecon.net/urdt/ussd/config"
"git.grassecon.net/urdt/ussd/initializers" "git.grassecon.net/urdt/ussd/initializers"
"git.grassecon.net/urdt/ussd/internal/handlers" "git.grassecon.net/urdt/ussd/internal/handlers"
"git.grassecon.net/urdt/ussd/internal/storage" "git.grassecon.net/urdt/ussd/internal/storage"
"git.grassecon.net/urdt/ussd/remote" "git.grassecon.net/urdt/ussd/remote"
"git.grassecon.net/urdt/ussd/internal/args"
) )
var ( var (
logg = logging.NewVanilla() logg = logging.NewVanilla()
scriptDir = path.Join("services", "registration") scriptDir = path.Join("services", "registration")
menuSeparator = ": "
) )
func init() { func init() {
@ -34,7 +37,7 @@ type asyncRequestParser struct {
input []byte input []byte
} }
func (p *asyncRequestParser) GetSessionId(r any) (string, error) { func (p *asyncRequestParser) GetSessionId(ctx context.Context, r any) (string, error) {
return p.sessionId, nil return p.sessionId, nil
} }
@ -53,6 +56,8 @@ func main() {
var engineDebug bool var engineDebug bool
var host string var host string
var port uint var port uint
var gettextDir string
var langs args.LangVar
flag.StringVar(&sessionId, "session-id", "075xx2123", "session id") flag.StringVar(&sessionId, "session-id", "075xx2123", "session id")
flag.StringVar(&dbDir, "dbdir", ".state", "database dir to read from") flag.StringVar(&dbDir, "dbdir", ".state", "database dir to read from")
flag.StringVar(&resourceDir, "resourcedir", path.Join("services", "registration"), "resource dir") flag.StringVar(&resourceDir, "resourcedir", path.Join("services", "registration"), "resource dir")
@ -61,18 +66,29 @@ func main() {
flag.UintVar(&size, "s", 160, "max size of output") flag.UintVar(&size, "s", 160, "max size of output")
flag.StringVar(&host, "h", initializers.GetEnv("HOST", "127.0.0.1"), "http host") flag.StringVar(&host, "h", initializers.GetEnv("HOST", "127.0.0.1"), "http host")
flag.UintVar(&port, "p", initializers.GetEnvUint("PORT", 7123), "http port") 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() flag.Parse()
logg.Infof("start command", "dbdir", dbDir, "resourcedir", resourceDir, "outputsize", size, "sessionId", sessionId) logg.Infof("start command", "dbdir", dbDir, "resourcedir", resourceDir, "outputsize", size, "sessionId", sessionId)
ctx := context.Background() ctx := context.Background()
ctx = context.WithValue(ctx, "Database", database) 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") pfp := path.Join(scriptDir, "pp.csv")
cfg := engine.Config{ cfg := engine.Config{
Root: "root", Root: "root",
OutputSize: uint32(size), OutputSize: uint32(size),
FlagCount: uint32(128), FlagCount: uint32(128),
MenuSeparator: menuSeparator,
} }
if engineDebug { if engineDebug {

View File

@ -14,6 +14,7 @@ import (
"git.defalsify.org/vise.git/engine" "git.defalsify.org/vise.git/engine"
"git.defalsify.org/vise.git/logging" "git.defalsify.org/vise.git/logging"
"git.defalsify.org/vise.git/resource" "git.defalsify.org/vise.git/resource"
"git.defalsify.org/vise.git/lang"
"git.grassecon.net/urdt/ussd/config" "git.grassecon.net/urdt/ussd/config"
"git.grassecon.net/urdt/ussd/initializers" "git.grassecon.net/urdt/ussd/initializers"
@ -21,11 +22,13 @@ import (
httpserver "git.grassecon.net/urdt/ussd/internal/http" httpserver "git.grassecon.net/urdt/ussd/internal/http"
"git.grassecon.net/urdt/ussd/internal/storage" "git.grassecon.net/urdt/ussd/internal/storage"
"git.grassecon.net/urdt/ussd/remote" "git.grassecon.net/urdt/ussd/remote"
"git.grassecon.net/urdt/ussd/internal/args"
) )
var ( var (
logg = logging.NewVanilla() logg = logging.NewVanilla()
scriptDir = path.Join("services", "registration") scriptDir = path.Join("services", "registration")
menuSeparator = ": "
) )
func init() { func init() {
@ -42,6 +45,8 @@ func main() {
var engineDebug bool var engineDebug bool
var host string var host string
var port uint var port uint
var gettextDir string
var langs args.LangVar
flag.StringVar(&dbDir, "dbdir", ".state", "database dir to read from") flag.StringVar(&dbDir, "dbdir", ".state", "database dir to read from")
flag.StringVar(&resourceDir, "resourcedir", path.Join("services", "registration"), "resource dir") flag.StringVar(&resourceDir, "resourcedir", path.Join("services", "registration"), "resource dir")
flag.StringVar(&database, "db", "gdbm", "database to be used") flag.StringVar(&database, "db", "gdbm", "database to be used")
@ -49,18 +54,29 @@ func main() {
flag.UintVar(&size, "s", 160, "max size of output") flag.UintVar(&size, "s", 160, "max size of output")
flag.StringVar(&host, "h", initializers.GetEnv("HOST", "127.0.0.1"), "http host") flag.StringVar(&host, "h", initializers.GetEnv("HOST", "127.0.0.1"), "http host")
flag.UintVar(&port, "p", initializers.GetEnvUint("PORT", 7123), "http port") 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() flag.Parse()
logg.Infof("start command", "dbdir", dbDir, "resourcedir", resourceDir, "outputsize", size) logg.Infof("start command", "dbdir", dbDir, "resourcedir", resourceDir, "outputsize", size)
ctx := context.Background() ctx := context.Background()
ctx = context.WithValue(ctx, "Database", database) 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") pfp := path.Join(scriptDir, "pp.csv")
cfg := engine.Config{ cfg := engine.Config{
Root: "root", Root: "root",
OutputSize: uint32(size), OutputSize: uint32(size),
FlagCount: uint32(128), FlagCount: uint32(128),
MenuSeparator: menuSeparator,
} }
if engineDebug { if engineDebug {

View File

@ -10,22 +10,26 @@ import (
"git.defalsify.org/vise.git/engine" "git.defalsify.org/vise.git/engine"
"git.defalsify.org/vise.git/logging" "git.defalsify.org/vise.git/logging"
"git.defalsify.org/vise.git/resource" "git.defalsify.org/vise.git/resource"
"git.defalsify.org/vise.git/lang"
"git.grassecon.net/urdt/ussd/config" "git.grassecon.net/urdt/ussd/config"
"git.grassecon.net/urdt/ussd/initializers" "git.grassecon.net/urdt/ussd/initializers"
"git.grassecon.net/urdt/ussd/internal/handlers" "git.grassecon.net/urdt/ussd/internal/handlers"
"git.grassecon.net/urdt/ussd/internal/storage" "git.grassecon.net/urdt/ussd/internal/storage"
"git.grassecon.net/urdt/ussd/internal/args"
"git.grassecon.net/urdt/ussd/remote" "git.grassecon.net/urdt/ussd/remote"
) )
var ( var (
logg = logging.NewVanilla() logg = logging.NewVanilla()
scriptDir = path.Join("services", "registration") scriptDir = path.Join("services", "registration")
menuSeparator = ": "
) )
func init() { func init() {
initializers.LoadEnvVariables() initializers.LoadEnvVariables()
} }
// TODO: external script automatically generate language handler list from select language vise code OR consider dynamic menu generation script possibility
func main() { func main() {
config.LoadConfig() config.LoadConfig()
@ -34,18 +38,34 @@ func main() {
var sessionId string var sessionId string
var database string var database string
var engineDebug bool var engineDebug bool
var gettextDir string
var langs args.LangVar
flag.StringVar(&sessionId, "session-id", "075xx2123", "session id") flag.StringVar(&sessionId, "session-id", "075xx2123", "session id")
flag.StringVar(&database, "db", "gdbm", "database to be used") flag.StringVar(&database, "db", "gdbm", "database to be used")
flag.StringVar(&dbDir, "dbdir", ".state", "database dir to read from") flag.StringVar(&dbDir, "dbdir", ".state", "database dir to read from")
flag.BoolVar(&engineDebug, "d", false, "use engine debug output") flag.BoolVar(&engineDebug, "d", false, "use engine debug output")
flag.UintVar(&size, "s", 160, "max size of 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() flag.Parse()
logg.Infof("start command", "dbdir", dbDir, "outputsize", size) logg.Infof("start command", "dbdir", dbDir, "outputsize", size)
if len(langs.Langs()) == 0 {
langs.Set(config.DefaultLanguage)
}
ctx := context.Background() ctx := context.Background()
ctx = context.WithValue(ctx, "SessionId", sessionId) ctx = context.WithValue(ctx, "SessionId", sessionId)
ctx = context.WithValue(ctx, "Database", database) 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") pfp := path.Join(scriptDir, "pp.csv")
cfg := engine.Config{ cfg := engine.Config{
@ -53,12 +73,16 @@ func main() {
SessionId: sessionId, SessionId: sessionId,
OutputSize: uint32(size), OutputSize: uint32(size),
FlagCount: uint32(128), FlagCount: uint32(128),
MenuSeparator: menuSeparator,
} }
resourceDir := scriptDir resourceDir := scriptDir
menuStorageService := storage.NewMenuStorageService(dbDir, resourceDir) menuStorageService := storage.NewMenuStorageService(dbDir, resourceDir)
if gettextDir != "" {
menuStorageService = menuStorageService.WithGettext(gettextDir, langs.Langs())
}
err := menuStorageService.EnsureDbDir() err = menuStorageService.EnsureDbDir()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, err.Error()) fmt.Fprintf(os.Stderr, err.Error())
os.Exit(1) os.Exit(1)

34
cmd/ssh/README.md Normal file
View File

@ -0,0 +1,34 @@
# URDT-USSD SSH server
An SSH server entry point for the vise engine.
## Adding public keys for access
Map your (client) public key to a session identifier (e.g. phone number)
```
go run -v -tags logtrace ./cmd/ssh/sshkey/main.go -i <session_id> [--dbdir <dbpath>] <client_publickey_filepath>
```
## Create a private key for the server
```
ssh-keygen -N "" -f <server_privatekey_filepath>
```
## Run the server
```
go run -v -tags logtrace ./cmd/ssh/main.go -h <host> -p <port> [--dbdir <dbpath>] <server_privatekey_filepath>
```
## Connect to the server
```
ssh [-v] -T -p <port> -i <client_publickey_filepath> <host>
```

117
cmd/ssh/main.go Normal file
View File

@ -0,0 +1,117 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"path"
"sync"
"syscall"
"git.defalsify.org/vise.git/db"
"git.defalsify.org/vise.git/engine"
"git.defalsify.org/vise.git/logging"
"git.grassecon.net/urdt/ussd/internal/ssh"
)
var (
wg sync.WaitGroup
keyStore db.Db
logg = logging.NewVanilla()
scriptDir = path.Join("services", "registration")
build = "dev"
)
func main() {
var dbDir string
var resourceDir string
var size uint
var engineDebug bool
var stateDebug bool
var host string
var port uint
flag.StringVar(&dbDir, "dbdir", ".state", "database dir to read from")
flag.StringVar(&resourceDir, "resourcedir", path.Join("services", "registration"), "resource dir")
flag.BoolVar(&engineDebug, "engine-debug", false, "use engine debug output")
flag.BoolVar(&stateDebug, "state-debug", false, "use engine debug output")
flag.UintVar(&size, "s", 160, "max size of output")
flag.StringVar(&host, "h", "127.0.0.1", "http host")
flag.UintVar(&port, "p", 7122, "http port")
flag.Parse()
sshKeyFile := flag.Arg(0)
_, err := os.Stat(sshKeyFile)
if err != nil {
fmt.Fprintf(os.Stderr, "cannot open ssh server private key file: %v\n", err)
os.Exit(1)
}
ctx := context.Background()
logg.WarnCtxf(ctx, "!!!!! WARNING WARNING WARNING")
logg.WarnCtxf(ctx, "!!!!! =======================")
logg.WarnCtxf(ctx, "!!!!! This is not a production ready server!")
logg.WarnCtxf(ctx, "!!!!! Do not expose to internet and only use with tunnel!")
logg.WarnCtxf(ctx, "!!!!! (See ssh -L <...>)")
logg.Infof("start command", "dbdir", dbDir, "resourcedir", resourceDir, "outputsize", size, "keyfile", sshKeyFile, "host", host, "port", port)
pfp := path.Join(scriptDir, "pp.csv")
cfg := engine.Config{
Root: "root",
OutputSize: uint32(size),
FlagCount: uint32(16),
}
if stateDebug {
cfg.StateDebug = true
}
if engineDebug {
cfg.EngineDebug = true
}
authKeyStore, err := ssh.NewSshKeyStore(ctx, dbDir)
if err != nil {
fmt.Fprintf(os.Stderr, "keystore file open error: %v", err)
os.Exit(1)
}
defer func() {
logg.TraceCtxf(ctx, "shutdown auth key store reached")
err = authKeyStore.Close()
if err != nil {
logg.ErrorCtxf(ctx, "keystore close error", "err", err)
}
}()
cint := make(chan os.Signal)
cterm := make(chan os.Signal)
signal.Notify(cint, os.Interrupt, syscall.SIGINT)
signal.Notify(cterm, os.Interrupt, syscall.SIGTERM)
runner := &ssh.SshRunner{
Cfg: cfg,
Debug: engineDebug,
FlagFile: pfp,
DbDir: dbDir,
ResourceDir: resourceDir,
SrvKeyFile: sshKeyFile,
Host: host,
Port: port,
}
go func() {
select {
case _ = <-cint:
case _ = <-cterm:
}
logg.TraceCtxf(ctx, "shutdown runner reached")
err := runner.Stop()
if err != nil {
logg.ErrorCtxf(ctx, "runner stop error", "err", err)
}
}()
runner.Run(ctx, authKeyStore)
}

44
cmd/ssh/sshkey/main.go Normal file
View File

@ -0,0 +1,44 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"git.grassecon.net/urdt/ussd/internal/ssh"
)
func main() {
var dbDir string
var sessionId string
flag.StringVar(&dbDir, "dbdir", ".state", "database dir to read from")
flag.StringVar(&sessionId, "i", "", "session id")
flag.Parse()
if sessionId == "" {
fmt.Fprintf(os.Stderr, "empty session id\n")
os.Exit(1)
}
ctx := context.Background()
sshKeyFile := flag.Arg(0)
if sshKeyFile == "" {
fmt.Fprintf(os.Stderr, "missing key file argument\n")
os.Exit(1)
}
store, err := ssh.NewSshKeyStore(ctx, dbDir)
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
defer store.Close()
err = store.AddFromFile(ctx, sshKeyFile, sessionId)
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
}

View File

@ -7,43 +7,85 @@ import (
"git.defalsify.org/vise.git/logging" "git.defalsify.org/vise.git/logging"
) )
// DataType is a subprefix value used in association with vise/db.DATATYPE_USERDATA.
//
// All keys are used only within the context of a single account. Unless otherwise specified, the user context is the session id.
//
// * The first byte is vise/db.DATATYPE_USERDATA
// * The last 2 bytes are the DataTyp value, big-endian.
// * The intermediate bytes are the id of the user context.
//
// All values are strings
type DataTyp uint16 type DataTyp uint16
const ( const (
DATA_ACCOUNT DataTyp = iota // API Tracking id to follow status of account creation
DATA_ACCOUNT_CREATED DATA_TRACKING_ID = iota
DATA_TRACKING_ID // EVM address returned from API on account creation
DATA_PUBLIC_KEY DATA_PUBLIC_KEY
DATA_CUSTODIAL_ID // Currently active PIN used to authenticate ussd state change requests
DATA_ACCOUNT_PIN DATA_ACCOUNT_PIN
DATA_ACCOUNT_STATUS // The first name of the user
DATA_FIRST_NAME DATA_FIRST_NAME
// The last name of the user
DATA_FAMILY_NAME DATA_FAMILY_NAME
// The year-of-birth of the user
DATA_YOB DATA_YOB
// The location of the user
DATA_LOCATION DATA_LOCATION
// The gender of the user
DATA_GENDER DATA_GENDER
// The offerings description of the user
DATA_OFFERINGS DATA_OFFERINGS
// The ethereum address of the recipient of an ongoing send request
DATA_RECIPIENT DATA_RECIPIENT
// The voucher value amount of an ongoing send request
DATA_AMOUNT DATA_AMOUNT
// A general swap field for temporary values
DATA_TEMPORARY_VALUE DATA_TEMPORARY_VALUE
// Currently active voucher symbol of user
DATA_ACTIVE_SYM DATA_ACTIVE_SYM
// Voucher balance of user's currently active voucher
DATA_ACTIVE_BAL DATA_ACTIVE_BAL
// String boolean indicating whether use of PIN is blocked
DATA_BLOCKED_NUMBER DATA_BLOCKED_NUMBER
// Reverse mapping of a user's evm address to a session id.
DATA_PUBLIC_KEY_REVERSE DATA_PUBLIC_KEY_REVERSE
// Decimal count of the currently active voucher
DATA_ACTIVE_DECIMAL DATA_ACTIVE_DECIMAL
// EVM address of the currently active voucher
DATA_ACTIVE_ADDRESS DATA_ACTIVE_ADDRESS
// Start the sub prefix data at 256 (0x0100) //Holds count of the number of incorrect PIN attempts
DATA_INCORRECT_PIN_ATTEMPTS
)
const (
// List of valid voucher symbols in the user context.
DATA_VOUCHER_SYMBOLS DataTyp = 256 + iota DATA_VOUCHER_SYMBOLS DataTyp = 256 + iota
// List of voucher balances for vouchers valid in the user context.
DATA_VOUCHER_BALANCES DATA_VOUCHER_BALANCES
// List of voucher decimal counts for vouchers valid in the user context.
DATA_VOUCHER_DECIMALS DATA_VOUCHER_DECIMALS
// List of voucher EVM addresses for vouchers valid in the user context.
DATA_VOUCHER_ADDRESSES DATA_VOUCHER_ADDRESSES
DATA_TX_SENDERS // List of senders for valid transactions in the user context.
)
const (
DATA_TX_SENDERS = 512 + iota
// List of recipients for valid transactions in the user context.
DATA_TX_RECIPIENTS DATA_TX_RECIPIENTS
// List of voucher values for valid transactions in the user context.
DATA_TX_VALUES DATA_TX_VALUES
// List of voucher EVM addresses for valid transactions in the user context.
DATA_TX_ADDRESSES DATA_TX_ADDRESSES
// List of valid transaction hashes in the user context.
DATA_TX_HASHES DATA_TX_HASHES
// List of transaction dates for valid transactions in the user context.
DATA_TX_DATES DATA_TX_DATES
// List of voucher symbols for valid transactions in the user context.
DATA_TX_SYMBOLS DATA_TX_SYMBOLS
// List of voucher decimal counts for valid transactions in the user context.
DATA_TX_DECIMALS DATA_TX_DECIMALS
) )

37
common/pin.go Normal file
View File

@ -0,0 +1,37 @@
package common
import (
"regexp"
"golang.org/x/crypto/bcrypt"
)
const (
// Define the regex pattern as a constant
pinPattern = `^\d{4}$`
//Allowed incorrect PIN attempts
AllowedPINAttempts = uint8(3)
)
// checks whether the given input is a 4 digit number
func IsValidPIN(pin string) bool {
match, _ := regexp.MatchString(pinPattern, pin)
return match
}
// HashPIN uses bcrypt with 8 salt rounds to hash the PIN
func HashPIN(pin string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(pin), 8)
if err != nil {
return "", err
}
return string(hash), nil
}
// VerifyPIN compareS the hashed PIN with the plaintext PIN
func VerifyPIN(hashedPIN, pin string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hashedPIN), []byte(pin))
return err == nil
}

173
common/pin_test.go Normal file
View File

@ -0,0 +1,173 @@
package common
import (
"testing"
"golang.org/x/crypto/bcrypt"
)
func TestIsValidPIN(t *testing.T) {
tests := []struct {
name string
pin string
expected bool
}{
{
name: "Valid PIN with 4 digits",
pin: "1234",
expected: true,
},
{
name: "Valid PIN with leading zeros",
pin: "0001",
expected: true,
},
{
name: "Invalid PIN with less than 4 digits",
pin: "123",
expected: false,
},
{
name: "Invalid PIN with more than 4 digits",
pin: "12345",
expected: false,
},
{
name: "Invalid PIN with letters",
pin: "abcd",
expected: false,
},
{
name: "Invalid PIN with special characters",
pin: "12@#",
expected: false,
},
{
name: "Empty PIN",
pin: "",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
actual := IsValidPIN(tt.pin)
if actual != tt.expected {
t.Errorf("IsValidPIN(%q) = %v; expected %v", tt.pin, actual, tt.expected)
}
})
}
}
func TestHashPIN(t *testing.T) {
tests := []struct {
name string
pin string
}{
{
name: "Valid PIN with 4 digits",
pin: "1234",
},
{
name: "Valid PIN with leading zeros",
pin: "0001",
},
{
name: "Empty PIN",
pin: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hashedPIN, err := HashPIN(tt.pin)
if err != nil {
t.Errorf("HashPIN(%q) returned an error: %v", tt.pin, err)
return
}
if hashedPIN == "" {
t.Errorf("HashPIN(%q) returned an empty hash", tt.pin)
}
// Ensure the hash can be verified with bcrypt
err = bcrypt.CompareHashAndPassword([]byte(hashedPIN), []byte(tt.pin))
if tt.pin != "" && err != nil {
t.Errorf("HashPIN(%q) produced a hash that does not match: %v", tt.pin, err)
}
})
}
}
func TestVerifyMigratedHashPin(t *testing.T) {
tests := []struct {
pin string
hash string
}{
{
pin: "1234",
hash: "$2b$08$dTvIGxCCysJtdvrSnaLStuylPoOS/ZLYYkxvTeR5QmTFY3TSvPQC6",
},
}
for _, tt := range tests {
t.Run(tt.pin, func(t *testing.T) {
ok := VerifyPIN(tt.hash, tt.pin)
if !ok {
t.Errorf("VerifyPIN could not verify migrated PIN: %v", tt.pin)
}
})
}
}
func TestVerifyPIN(t *testing.T) {
tests := []struct {
name string
pin string
hashedPIN string
shouldPass bool
}{
{
name: "Valid PIN verification",
pin: "1234",
hashedPIN: hashPINHelper("1234"),
shouldPass: true,
},
{
name: "Invalid PIN verification with incorrect PIN",
pin: "5678",
hashedPIN: hashPINHelper("1234"),
shouldPass: false,
},
{
name: "Invalid PIN verification with empty PIN",
pin: "",
hashedPIN: hashPINHelper("1234"),
shouldPass: false,
},
{
name: "Invalid PIN verification with invalid hash",
pin: "1234",
hashedPIN: "invalidhash",
shouldPass: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := VerifyPIN(tt.hashedPIN, tt.pin)
if result != tt.shouldPass {
t.Errorf("VerifyPIN(%q, %q) = %v; expected %v", tt.hashedPIN, tt.pin, result, tt.shouldPass)
}
})
}
}
// Helper function to hash a PIN for testing purposes
func hashPINHelper(pin string) string {
hashedPIN, err := HashPIN(pin)
if err != nil {
panic("Failed to hash PIN for test setup: " + err.Error())
}
return hashedPIN
}

View File

@ -8,14 +8,15 @@ import (
"git.defalsify.org/vise.git/resource" "git.defalsify.org/vise.git/resource"
"git.defalsify.org/vise.git/persist" "git.defalsify.org/vise.git/persist"
"git.grassecon.net/urdt/ussd/internal/storage" "git.grassecon.net/urdt/ussd/internal/storage"
dbstorage "git.grassecon.net/urdt/ussd/internal/storage/db"
) )
func StoreToDb(store *UserDataStore) db.Db { func StoreToDb(store *UserDataStore) db.Db {
return store.Db return store.Db
} }
func StoreToPrefixDb(store *UserDataStore, pfx []byte) storage.PrefixDb { func StoreToPrefixDb(store *UserDataStore, pfx []byte) dbstorage.PrefixDb {
return storage.NewSubPrefixDb(store.Db, pfx) return dbstorage.NewSubPrefixDb(store.Db, pfx)
} }
type StorageServices interface { type StorageServices interface {

View File

@ -6,7 +6,7 @@ import (
"strings" "strings"
"time" "time"
"git.grassecon.net/urdt/ussd/internal/storage" dbstorage "git.grassecon.net/urdt/ussd/internal/storage/db"
dataserviceapi "github.com/grassrootseconomics/ussd-data-service/pkg/api" dataserviceapi "github.com/grassrootseconomics/ussd-data-service/pkg/api"
) )
@ -56,7 +56,7 @@ func ProcessTransfers(transfers []dataserviceapi.Last10TxResponse) TransferMetad
// GetTransferData retrieves and matches transfer data // GetTransferData retrieves and matches transfer data
// returns a formatted string of the full transaction/statement // returns a formatted string of the full transaction/statement
func GetTransferData(ctx context.Context, db storage.PrefixDb, publicKey string, index int) (string, error) { func GetTransferData(ctx context.Context, db dbstorage.PrefixDb, publicKey string, index int) (string, error) {
keys := []DataTyp{DATA_TX_SENDERS, DATA_TX_RECIPIENTS, DATA_TX_VALUES, DATA_TX_ADDRESSES, DATA_TX_HASHES, DATA_TX_DATES, DATA_TX_SYMBOLS} keys := []DataTyp{DATA_TX_SENDERS, DATA_TX_RECIPIENTS, DATA_TX_VALUES, DATA_TX_ADDRESSES, DATA_TX_HASHES, DATA_TX_DATES, DATA_TX_SYMBOLS}
data := make(map[DataTyp]string) data := make(map[DataTyp]string)
@ -84,18 +84,18 @@ func GetTransferData(ctx context.Context, db storage.PrefixDb, publicKey string,
// Adjust for 0-based indexing // Adjust for 0-based indexing
i := index - 1 i := index - 1
transactionType := "received" transactionType := "Received"
party := fmt.Sprintf("from: %s", strings.TrimSpace(senders[i])) party := fmt.Sprintf("From: %s", strings.TrimSpace(senders[i]))
if strings.TrimSpace(senders[i]) == publicKey { if strings.TrimSpace(senders[i]) == publicKey {
transactionType = "sent" transactionType = "Sent"
party = fmt.Sprintf("to: %s", strings.TrimSpace(recipients[i])) party = fmt.Sprintf("To: %s", strings.TrimSpace(recipients[i]))
} }
formattedDate := formatDate(strings.TrimSpace(dates[i])) formattedDate := formatDate(strings.TrimSpace(dates[i]))
// Build the full transaction detail // Build the full transaction detail
detail := fmt.Sprintf( detail := fmt.Sprintf(
"%s %s %s\n%s\ncontract address: %s\ntxhash: %s\ndate: %s", "%s %s %s\n%s\nContract address: %s\nTxhash: %s\nDate: %s",
transactionType, transactionType,
strings.TrimSpace(values[i]), strings.TrimSpace(values[i]),
strings.TrimSpace(syms[i]), strings.TrimSpace(syms[i]),

View File

@ -20,7 +20,7 @@ type UserDataStore struct {
func (store *UserDataStore) ReadEntry(ctx context.Context, sessionId string, typ DataTyp) ([]byte, error) { func (store *UserDataStore) ReadEntry(ctx context.Context, sessionId string, typ DataTyp) ([]byte, error) {
store.SetPrefix(db.DATATYPE_USERDATA) store.SetPrefix(db.DATATYPE_USERDATA)
store.SetSession(sessionId) store.SetSession(sessionId)
k := PackKey(typ, []byte(sessionId)) k := ToBytes(typ)
return store.Get(ctx, k) return store.Get(ctx, k)
} }
@ -29,6 +29,6 @@ func (store *UserDataStore) ReadEntry(ctx context.Context, sessionId string, typ
func (store *UserDataStore) WriteEntry(ctx context.Context, sessionId string, typ DataTyp, value []byte) error { func (store *UserDataStore) WriteEntry(ctx context.Context, sessionId string, typ DataTyp, value []byte) error {
store.SetPrefix(db.DATATYPE_USERDATA) store.SetPrefix(db.DATATYPE_USERDATA)
store.SetSession(sessionId) store.SetSession(sessionId)
k := PackKey(typ, []byte(sessionId)) k := ToBytes(typ)
return store.Put(ctx, k, value) return store.Put(ctx, k, value)
} }

View File

@ -6,7 +6,7 @@ import (
"math/big" "math/big"
"strings" "strings"
"git.grassecon.net/urdt/ussd/internal/storage" dbstorage "git.grassecon.net/urdt/ussd/internal/storage/db"
dataserviceapi "github.com/grassrootseconomics/ussd-data-service/pkg/api" dataserviceapi "github.com/grassrootseconomics/ussd-data-service/pkg/api"
) )
@ -63,7 +63,7 @@ func ScaleDownBalance(balance, decimals string) string {
} }
// GetVoucherData retrieves and matches voucher data // GetVoucherData retrieves and matches voucher data
func GetVoucherData(ctx context.Context, db storage.PrefixDb, input string) (*dataserviceapi.TokenHoldings, error) { func GetVoucherData(ctx context.Context, db dbstorage.PrefixDb, input string) (*dataserviceapi.TokenHoldings, error) {
keys := []DataTyp{DATA_VOUCHER_SYMBOLS, DATA_VOUCHER_BALANCES, DATA_VOUCHER_DECIMALS, DATA_VOUCHER_ADDRESSES} keys := []DataTyp{DATA_VOUCHER_SYMBOLS, DATA_VOUCHER_BALANCES, DATA_VOUCHER_DECIMALS, DATA_VOUCHER_ADDRESSES}
data := make(map[DataTyp]string) data := make(map[DataTyp]string)

View File

@ -10,7 +10,7 @@ import (
visedb "git.defalsify.org/vise.git/db" visedb "git.defalsify.org/vise.git/db"
memdb "git.defalsify.org/vise.git/db/mem" memdb "git.defalsify.org/vise.git/db/mem"
"git.grassecon.net/urdt/ussd/internal/storage" dbstorage "git.grassecon.net/urdt/ussd/internal/storage/db"
dataserviceapi "github.com/grassrootseconomics/ussd-data-service/pkg/api" dataserviceapi "github.com/grassrootseconomics/ussd-data-service/pkg/api"
) )
@ -86,7 +86,7 @@ func TestGetVoucherData(t *testing.T) {
} }
prefix := ToBytes(visedb.DATATYPE_USERDATA) prefix := ToBytes(visedb.DATATYPE_USERDATA)
spdb := storage.NewSubPrefixDb(db, prefix) spdb := dbstorage.NewSubPrefixDb(db, prefix)
// Test voucher data // Test voucher data
mockData := map[DataTyp][]byte{ mockData := map[DataTyp][]byte{

View File

@ -2,6 +2,7 @@ package config
import ( import (
"net/url" "net/url"
"strings"
"git.grassecon.net/urdt/ussd/initializers" "git.grassecon.net/urdt/ussd/initializers"
) )
@ -18,6 +19,11 @@ const (
AliasPrefix = "api/v1/alias" AliasPrefix = "api/v1/alias"
) )
var (
defaultLanguage = "eng"
languages []string
)
var ( var (
custodialURLBase string custodialURLBase string
dataURLBase string dataURLBase string
@ -34,8 +40,28 @@ var (
VoucherTransfersURL string VoucherTransfersURL string
VoucherDataURL string VoucherDataURL string
CheckAliasURL string CheckAliasURL 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 { func setBase() error {
var err error var err error
@ -60,6 +86,10 @@ func LoadConfig() error {
if err != nil { if err != nil {
return err return err
} }
err = setLanguage()
if err != nil {
return err
}
CreateAccountURL, _ = url.JoinPath(custodialURLBase, createAccountPath) CreateAccountURL, _ = url.JoinPath(custodialURLBase, createAccountPath)
TrackStatusURL, _ = url.JoinPath(custodialURLBase, trackStatusPath) TrackStatusURL, _ = url.JoinPath(custodialURLBase, trackStatusPath)
BalanceURL, _ = url.JoinPath(custodialURLBase, balancePathPrefix) BalanceURL, _ = url.JoinPath(custodialURLBase, balancePathPrefix)
@ -69,6 +99,8 @@ func LoadConfig() error {
VoucherTransfersURL, _ = url.JoinPath(dataURLBase, voucherTransfersPathPrefix) VoucherTransfersURL, _ = url.JoinPath(dataURLBase, voucherTransfersPathPrefix)
VoucherDataURL, _ = url.JoinPath(dataURLBase, voucherDataPathPrefix) VoucherDataURL, _ = url.JoinPath(dataURLBase, voucherDataPathPrefix)
CheckAliasURL, _ = url.JoinPath(dataURLBase, AliasPrefix) CheckAliasURL, _ = url.JoinPath(dataURLBase, AliasPrefix)
DefaultLanguage = defaultLanguage
Languages = languages
return nil return nil
} }

5
debug/cap.go Normal file
View File

@ -0,0 +1,5 @@
package debug
var (
DebugCap uint32
)

84
debug/db.go Normal file
View File

@ -0,0 +1,84 @@
package debug
import (
"fmt"
"encoding/binary"
"git.grassecon.net/urdt/ussd/common"
"git.defalsify.org/vise.git/db"
)
var (
dbTypStr map[common.DataTyp]string = make(map[common.DataTyp]string)
)
type KeyInfo struct {
SessionId string
Typ uint8
SubTyp common.DataTyp
Label string
Description string
}
func (k KeyInfo) String() string {
v := uint16(k.SubTyp)
s := subTypToString(k.SubTyp)
if s == "" {
v = uint16(k.Typ)
s = typToString(k.Typ)
}
return fmt.Sprintf("Session Id: %s\nTyp: %s (%d)\n", k.SessionId, s, v)
}
func ToKeyInfo(k []byte, sessionId string) (KeyInfo, error) {
o := KeyInfo{}
b := []byte(sessionId)
if len(k) <= len(b) {
return o, fmt.Errorf("storage key missing")
}
o.SessionId = sessionId
o.Typ = uint8(k[0])
k = k[1:]
o.SessionId = string(k[:len(b)])
k = k[len(b):]
if o.Typ == db.DATATYPE_USERDATA {
if len(k) == 0 {
return o, fmt.Errorf("missing subtype key")
}
v := binary.BigEndian.Uint16(k[:2])
o.SubTyp = common.DataTyp(v)
o.Label = subTypToString(o.SubTyp)
k = k[2:]
} else {
o.Label = typToString(o.Typ)
}
if len(k) != 0 {
return o, fmt.Errorf("excess key information")
}
return o, nil
}
func FromKey(k []byte) (KeyInfo, error) {
o := KeyInfo{}
if len(k) < 4 {
return o, fmt.Errorf("insufficient key length")
}
sessionIdBytes := k[1:len(k)-2]
return ToKeyInfo(k, string(sessionIdBytes))
}
func subTypToString(v common.DataTyp) string {
return dbTypStr[v + db.DATATYPE_USERDATA + 1]
}
func typToString(v uint8) string {
return dbTypStr[common.DataTyp(uint16(v))]
}

44
debug/db_debug.go Normal file
View File

@ -0,0 +1,44 @@
// +build debugdb
package debug
import (
"git.defalsify.org/vise.git/db"
"git.grassecon.net/urdt/ussd/common"
)
func init() {
DebugCap |= 1
dbTypStr[db.DATATYPE_STATE] = "internal state"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_TRACKING_ID] = "tracking id"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_PUBLIC_KEY] = "public key"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_ACCOUNT_PIN] = "account pin"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_FIRST_NAME] = "first name"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_FAMILY_NAME] = "family name"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_YOB] = "year of birth"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_LOCATION] = "location"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_GENDER] = "gender"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_OFFERINGS] = "offerings"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_RECIPIENT] = "recipient"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_AMOUNT] = "amount"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_TEMPORARY_VALUE] = "temporary value"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_ACTIVE_SYM] = "active sym"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_ACTIVE_BAL] = "active bal"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_BLOCKED_NUMBER] = "blocked number"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_PUBLIC_KEY_REVERSE] = "public_key_reverse"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_ACTIVE_DECIMAL] = "active decimal"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_ACTIVE_ADDRESS] = "active address"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_VOUCHER_SYMBOLS] = "voucher symbols"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_VOUCHER_BALANCES] = "voucher balances"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_VOUCHER_DECIMALS] = "voucher decimals"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_VOUCHER_ADDRESSES] = "voucher addresses"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_TX_SENDERS] = "tx senders"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_TX_RECIPIENTS] = "tx recipients"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_TX_VALUES] = "tx values"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_TX_ADDRESSES] = "tx addresses"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_TX_HASHES] = "tx hashes"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_TX_DATES] = "tx dates"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_TX_SYMBOLS] = "tx symbols"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_TX_DECIMALS] = "tx decimals"
}

78
debug/db_test.go Normal file
View File

@ -0,0 +1,78 @@
package debug
import (
"testing"
"git.grassecon.net/urdt/ussd/common"
"git.defalsify.org/vise.git/db"
)
func TestDebugDbSubKeyInfo(t *testing.T) {
s := "foo"
b := []byte{0x20}
b = append(b, []byte(s)...)
b = append(b, []byte{0x00, 0x02}...)
r, err := ToKeyInfo(b, s)
if err != nil {
t.Fatal(err)
}
if r.SessionId != s {
t.Fatalf("expected %s, got %s", s, r.SessionId)
}
if r.Typ != 32 {
t.Fatalf("expected 64, got %d", r.Typ)
}
if r.SubTyp != 2 {
t.Fatalf("expected 2, got %d", r.SubTyp)
}
if DebugCap & 1 > 0 {
if r.Label != "tracking id" {
t.Fatalf("expected 'tracking id', got '%s'", r.Label)
}
}
}
func TestDebugDbKeyInfo(t *testing.T) {
s := "bar"
b := []byte{0x10}
b = append(b, []byte(s)...)
r, err := ToKeyInfo(b, s)
if err != nil {
t.Fatal(err)
}
if r.SessionId != s {
t.Fatalf("expected %s, got %s", s, r.SessionId)
}
if r.Typ != 16 {
t.Fatalf("expected 16, got %d", r.Typ)
}
if DebugCap & 1 > 0 {
if r.Label != "internal state" {
t.Fatalf("expected 'internal_state', got '%s'", r.Label)
}
}
}
func TestDebugDbKeyInfoRestore(t *testing.T) {
s := "bar"
b := []byte{db.DATATYPE_USERDATA}
b = append(b, []byte(s)...)
k := common.ToBytes(common.DATA_ACTIVE_SYM)
b = append(b, k...)
r, err := ToKeyInfo(b, s)
if err != nil {
t.Fatal(err)
}
if r.SessionId != s {
t.Fatalf("expected %s, got %s", s, r.SessionId)
}
if r.Typ != 32 {
t.Fatalf("expected 32, got %d", r.Typ)
}
if DebugCap & 1 > 0 {
if r.Label != "active sym" {
t.Fatalf("expected 'active sym', got '%s'", r.Label)
}
}
}

126
devtools/lang/main.go Normal file
View File

@ -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)
}
}

View File

@ -0,0 +1,89 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"path"
"git.grassecon.net/urdt/ussd/config"
"git.grassecon.net/urdt/ussd/initializers"
"git.grassecon.net/urdt/ussd/internal/storage"
"git.grassecon.net/urdt/ussd/debug"
"git.defalsify.org/vise.git/db"
"git.defalsify.org/vise.git/logging"
)
var (
logg = logging.NewVanilla()
scriptDir = path.Join("services", "registration")
)
func init() {
initializers.LoadEnvVariables()
}
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()
var dbDir string
var sessionId string
var database string
var engineDebug bool
flag.StringVar(&sessionId, "session-id", "075xx2123", "session id")
flag.StringVar(&database, "db", "gdbm", "database to be used")
flag.StringVar(&dbDir, "dbdir", ".state", "database dir to read from")
flag.BoolVar(&engineDebug, "d", false, "use engine debug output")
flag.Parse()
ctx := context.Background()
ctx = context.WithValue(ctx, "SessionId", sessionId)
ctx = context.WithValue(ctx, "Database", database)
resourceDir := scriptDir
menuStorageService := storage.NewMenuStorageService(dbDir, resourceDir)
store, err := menuStorageService.GetUserdataDb(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "get userdata db: %v\n", err.Error())
os.Exit(1)
}
store.SetPrefix(db.DATATYPE_USERDATA)
d, err := store.Dump(ctx, []byte(sessionId))
if err != nil {
fmt.Fprintf(os.Stderr, "store dump fail: %v\n", err.Error())
os.Exit(1)
}
for true {
k, v := d.Next(ctx)
if k == nil {
break
}
r, err := formatItem(k, v)
if err != nil {
fmt.Fprintf(os.Stderr, "format db item error: %v", err)
os.Exit(1)
}
fmt.Printf(r)
}
err = store.Close()
if err != nil {
fmt.Fprintf(os.Stderr, err.Error())
os.Exit(1)
}
}

View File

@ -0,0 +1,79 @@
package main
import (
"context"
"crypto/sha1"
"flag"
"fmt"
"os"
"path"
"git.defalsify.org/vise.git/logging"
"git.grassecon.net/urdt/ussd/config"
"git.grassecon.net/urdt/ussd/internal/storage"
"git.grassecon.net/urdt/ussd/initializers"
"git.grassecon.net/urdt/ussd/common"
)
var (
logg = logging.NewVanilla()
scriptDir = path.Join("services", "registration")
)
func init() {
initializers.LoadEnvVariables()
}
func main() {
config.LoadConfig()
var dbDir string
var sessionId string
var database string
var engineDebug bool
flag.StringVar(&sessionId, "session-id", "075xx2123", "session id")
flag.StringVar(&database, "db", "gdbm", "database to be used")
flag.StringVar(&dbDir, "dbdir", ".state", "database dir to read from")
flag.BoolVar(&engineDebug, "d", false, "use engine debug output")
flag.Parse()
ctx := context.Background()
ctx = context.WithValue(ctx, "SessionId", sessionId)
ctx = context.WithValue(ctx, "Database", database)
resourceDir := scriptDir
menuStorageService := storage.NewMenuStorageService(dbDir, resourceDir)
store, err := menuStorageService.GetUserdataDb(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, err.Error())
os.Exit(1)
}
userStore := common.UserDataStore{store}
h := sha1.New()
h.Write([]byte(sessionId))
address := h.Sum(nil)
addressString := fmt.Sprintf("%x", address)
err = userStore.WriteEntry(ctx, sessionId, common.DATA_PUBLIC_KEY, []byte(addressString))
if err != nil {
fmt.Fprintf(os.Stderr, err.Error())
os.Exit(1)
}
err = userStore.WriteEntry(ctx, addressString, common.DATA_PUBLIC_KEY_REVERSE, []byte(sessionId))
if err != nil {
fmt.Fprintf(os.Stderr, err.Error())
os.Exit(1)
}
err = store.Close()
if err != nil {
fmt.Fprintf(os.Stderr, err.Error())
os.Exit(1)
}
}

28
doc/data.md Normal file
View File

@ -0,0 +1,28 @@
# Internals
## Version
This document describes component versions:
* `urdt-ussd` `v0.5.0-beta`
* `go-vise` `v0.2.2`
## User profile data
All user profile items are stored under keys matching the user's session id, prefixed with the 8-bit value `git.defalsify.org/vise.git/db.DATATYPE_USERDATA` (32), and followed with a 16-big big-endian value subprefix.
For example, given the sessionId `+254123` and the key `git.grassecon.net/urdt-ussd/common.DATA_PUBLIC_KEY` (2) will be stored under the key:
```
0x322b3235343132330002
prefix sessionid subprefix
32 2b323534313233 0002
```
### Sub-prefixes
All sub-prefixes are defined as constants in the `git.grassecon.net/urdt-ussd/common` package. The constant names have the prefix `DATA_`
Please refer to inline godoc documentation for the `git.grassecon.net/urdt-ussd/common` package for details on each data item.

4
go.mod
View File

@ -3,7 +3,7 @@ module git.grassecon.net/urdt/ussd
go 1.23.0 go 1.23.0
require ( require (
git.defalsify.org/vise.git v0.2.1-0.20241122120231-9e9ee5bdfa7a git.defalsify.org/vise.git v0.2.3-0.20250103172917-3e190a44568d
github.com/alecthomas/assert/v2 v2.2.2 github.com/alecthomas/assert/v2 v2.2.2
github.com/gofrs/uuid v4.4.0+incompatible github.com/gofrs/uuid v4.4.0+incompatible
github.com/grassrootseconomics/eth-custodial v1.3.0-beta github.com/grassrootseconomics/eth-custodial v1.3.0-beta
@ -11,6 +11,7 @@ require (
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/peteole/testdata-loader v0.3.0 github.com/peteole/testdata-loader v0.3.0
github.com/stretchr/testify v1.9.0 github.com/stretchr/testify v1.9.0
golang.org/x/crypto v0.27.0
gopkg.in/leonelquinteros/gotext.v1 v1.3.1 gopkg.in/leonelquinteros/gotext.v1 v1.3.1
) )
@ -32,7 +33,6 @@ require (
github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/objx v0.5.2 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
golang.org/x/crypto v0.27.0 // indirect
golang.org/x/sync v0.8.0 // indirect golang.org/x/sync v0.8.0 // indirect
golang.org/x/text v0.18.0 // indirect golang.org/x/text v0.18.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect

4
go.sum
View File

@ -1,5 +1,5 @@
git.defalsify.org/vise.git v0.2.1-0.20241122120231-9e9ee5bdfa7a h1:LvGKktk0kUnuRN3nF9r15D8OoV0sFaMmQr52kGq2gtE= git.defalsify.org/vise.git v0.2.3-0.20250103172917-3e190a44568d h1:bPAOVZOX4frSGhfOdcj7kc555f8dc9DmMd2YAyC2AMw=
git.defalsify.org/vise.git v0.2.1-0.20241122120231-9e9ee5bdfa7a/go.mod h1:jyBMe1qTYUz3mmuoC9JQ/TvFeW0vTanCUcPu3H8p4Ck= git.defalsify.org/vise.git v0.2.3-0.20250103172917-3e190a44568d/go.mod h1:jyBMe1qTYUz3mmuoC9JQ/TvFeW0vTanCUcPu3H8p4Ck=
github.com/alecthomas/assert/v2 v2.2.2 h1:Z/iVC0xZfWTaFNE6bA3z07T86hd45Xe2eLt6WVy2bbk= github.com/alecthomas/assert/v2 v2.2.2 h1:Z/iVC0xZfWTaFNE6bA3z07T86hd45Xe2eLt6WVy2bbk=
github.com/alecthomas/assert/v2 v2.2.2/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= github.com/alecthomas/assert/v2 v2.2.2/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ=
github.com/alecthomas/participle/v2 v2.0.0 h1:Fgrq+MbuSsJwIkw3fEj9h75vDP0Er5JzepJ0/HNHv0g= github.com/alecthomas/participle/v2 v2.0.0 h1:Fgrq+MbuSsJwIkw3fEj9h75vDP0Er5JzepJ0/HNHv0g=

34
internal/args/lang.go Normal file
View File

@ -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
}

View File

@ -2,6 +2,7 @@ package handlers
import ( import (
"context" "context"
"strings"
"git.defalsify.org/vise.git/asm" "git.defalsify.org/vise.git/asm"
"git.defalsify.org/vise.git/db" "git.defalsify.org/vise.git/db"
@ -64,7 +65,11 @@ func (ls *LocalHandlerService) SetDataStore(db *db.Db) {
} }
func (ls *LocalHandlerService) GetHandler(accountService remote.AccountServiceInterface) (*ussd.Handlers, error) { func (ls *LocalHandlerService) GetHandler(accountService remote.AccountServiceInterface) (*ussd.Handlers, error) {
ussdHandlers, err := ussd.NewHandlers(ls.Parser, *ls.UserdataStore, ls.AdminStore, accountService) replaceSeparatorFunc := func(input string) string {
return strings.ReplaceAll(input, ":", ls.Cfg.MenuSeparator)
}
ussdHandlers, err := ussd.NewHandlers(ls.Parser, *ls.UserdataStore, ls.AdminStore, accountService, replaceSeparatorFunc)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -111,7 +116,7 @@ func (ls *LocalHandlerService) GetHandler(accountService remote.AccountServiceIn
ls.DbRs.AddLocalFunc("set_voucher", ussdHandlers.SetVoucher) ls.DbRs.AddLocalFunc("set_voucher", ussdHandlers.SetVoucher)
ls.DbRs.AddLocalFunc("get_voucher_details", ussdHandlers.GetVoucherDetails) ls.DbRs.AddLocalFunc("get_voucher_details", ussdHandlers.GetVoucherDetails)
ls.DbRs.AddLocalFunc("reset_valid_pin", ussdHandlers.ResetValidPin) ls.DbRs.AddLocalFunc("reset_valid_pin", ussdHandlers.ResetValidPin)
ls.DbRs.AddLocalFunc("check_pin_mismatch", ussdHandlers.CheckPinMisMatch) ls.DbRs.AddLocalFunc("check_pin_mismatch", ussdHandlers.CheckBlockedNumPinMisMatch)
ls.DbRs.AddLocalFunc("validate_blocked_number", ussdHandlers.ValidateBlockedNumber) ls.DbRs.AddLocalFunc("validate_blocked_number", ussdHandlers.ValidateBlockedNumber)
ls.DbRs.AddLocalFunc("retrieve_blocked_number", ussdHandlers.RetrieveBlockedNumber) ls.DbRs.AddLocalFunc("retrieve_blocked_number", ussdHandlers.RetrieveBlockedNumber)
ls.DbRs.AddLocalFunc("reset_unregistered_number", ussdHandlers.ResetUnregisteredNumber) ls.DbRs.AddLocalFunc("reset_unregistered_number", ussdHandlers.ResetUnregisteredNumber)
@ -123,6 +128,7 @@ func (ls *LocalHandlerService) GetHandler(accountService remote.AccountServiceIn
ls.DbRs.AddLocalFunc("view_statement", ussdHandlers.ViewTransactionStatement) ls.DbRs.AddLocalFunc("view_statement", ussdHandlers.ViewTransactionStatement)
ls.DbRs.AddLocalFunc("update_all_profile_items", ussdHandlers.UpdateAllProfileItems) ls.DbRs.AddLocalFunc("update_all_profile_items", ussdHandlers.UpdateAllProfileItems)
ls.DbRs.AddLocalFunc("set_back", ussdHandlers.SetBack) ls.DbRs.AddLocalFunc("set_back", ussdHandlers.SetBack)
ls.DbRs.AddLocalFunc("show_blocked_account", ussdHandlers.ShowBlockedAccount)
return ussdHandlers, nil return ussdHandlers, nil
} }

View File

@ -6,9 +6,9 @@ import (
"io" "io"
"git.defalsify.org/vise.git/engine" "git.defalsify.org/vise.git/engine"
"git.defalsify.org/vise.git/resource"
"git.defalsify.org/vise.git/persist"
"git.defalsify.org/vise.git/logging" "git.defalsify.org/vise.git/logging"
"git.defalsify.org/vise.git/persist"
"git.defalsify.org/vise.git/resource"
"git.grassecon.net/urdt/ussd/internal/storage" "git.grassecon.net/urdt/ussd/internal/storage"
) )
@ -39,7 +39,7 @@ type RequestSession struct {
// TODO: seems like can remove this. // TODO: seems like can remove this.
type RequestParser interface { type RequestParser interface {
GetSessionId(rq any) (string, error) GetSessionId(context context.Context, rq any) (string, error)
GetInput(rq any) ([]byte, error) GetInput(rq any) ([]byte, error)
} }

View File

@ -5,7 +5,6 @@ import (
"context" "context"
"fmt" "fmt"
"path" "path"
"regexp"
"strconv" "strconv"
"strings" "strings"
@ -24,27 +23,16 @@ import (
"git.grassecon.net/urdt/ussd/remote" "git.grassecon.net/urdt/ussd/remote"
"gopkg.in/leonelquinteros/gotext.v1" "gopkg.in/leonelquinteros/gotext.v1"
"git.grassecon.net/urdt/ussd/internal/storage" dbstorage "git.grassecon.net/urdt/ussd/internal/storage/db"
dataserviceapi "github.com/grassrootseconomics/ussd-data-service/pkg/api" dataserviceapi "github.com/grassrootseconomics/ussd-data-service/pkg/api"
) )
var ( var (
logg = logging.NewVanilla().WithDomain("ussdmenuhandler") logg = logging.NewVanilla().WithDomain("ussdmenuhandler").WithContextKey("SessionId")
scriptDir = path.Join("services", "registration") scriptDir = path.Join("services", "registration")
translationDir = path.Join(scriptDir, "locale") translationDir = path.Join(scriptDir, "locale")
) )
// Define the regex patterns as constants
const (
pinPattern = `^\d{4}$`
)
// isValidPIN checks whether the given input is a 4 digit number
func isValidPIN(pin string) bool {
match, _ := regexp.MatchString(pinPattern, pin)
return match
}
// FlagManager handles centralized flag management // FlagManager handles centralized flag management
type FlagManager struct { type FlagManager struct {
parser *asm.FlagParser parser *asm.FlagParser
@ -76,11 +64,13 @@ type Handlers struct {
adminstore *utils.AdminStore adminstore *utils.AdminStore
flagManager *asm.FlagParser flagManager *asm.FlagParser
accountService remote.AccountServiceInterface accountService remote.AccountServiceInterface
prefixDb storage.PrefixDb prefixDb dbstorage.PrefixDb
profile *models.Profile profile *models.Profile
ReplaceSeparatorFunc func(string) string
} }
func NewHandlers(appFlags *asm.FlagParser, userdataStore db.Db, adminstore *utils.AdminStore, accountService remote.AccountServiceInterface) (*Handlers, error) { // NewHandlers creates a new instance of the Handlers struct with the provided dependencies.
func NewHandlers(appFlags *asm.FlagParser, userdataStore db.Db, adminstore *utils.AdminStore, accountService remote.AccountServiceInterface, replaceSeparatorFunc func(string) string) (*Handlers, error) {
if userdataStore == nil { if userdataStore == nil {
return nil, fmt.Errorf("cannot create handler with nil userdata store") return nil, fmt.Errorf("cannot create handler with nil userdata store")
} }
@ -90,7 +80,7 @@ func NewHandlers(appFlags *asm.FlagParser, userdataStore db.Db, adminstore *util
// Instantiate the SubPrefixDb with "DATATYPE_USERDATA" prefix // Instantiate the SubPrefixDb with "DATATYPE_USERDATA" prefix
prefix := common.ToBytes(db.DATATYPE_USERDATA) prefix := common.ToBytes(db.DATATYPE_USERDATA)
prefixDb := storage.NewSubPrefixDb(userdataStore, prefix) prefixDb := dbstorage.NewSubPrefixDb(userdataStore, prefix)
h := &Handlers{ h := &Handlers{
userdataStore: userDb, userdataStore: userDb,
@ -99,10 +89,12 @@ func NewHandlers(appFlags *asm.FlagParser, userdataStore db.Db, adminstore *util
accountService: accountService, accountService: accountService,
prefixDb: prefixDb, prefixDb: prefixDb,
profile: &models.Profile{Max: 6}, profile: &models.Profile{Max: 6},
ReplaceSeparatorFunc: replaceSeparatorFunc,
} }
return h, nil return h, nil
} }
// WithPersister sets persister instance to the handlers.
func (h *Handlers) WithPersister(pe *persist.Persister) *Handlers { func (h *Handlers) WithPersister(pe *persist.Persister) *Handlers {
if h.pe != nil { if h.pe != nil {
panic("persister already set") panic("persister already set")
@ -111,6 +103,7 @@ func (h *Handlers) WithPersister(pe *persist.Persister) *Handlers {
return h return h
} }
// Init initializes the handler for a new session.
func (h *Handlers) Init(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) Init(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var r resource.Result var r resource.Result
if h.pe == nil { if h.pe == nil {
@ -124,9 +117,17 @@ func (h *Handlers) Init(ctx context.Context, sym string, input []byte) (resource
h.st = h.pe.GetState() h.st = h.pe.GetState()
h.ca = h.pe.GetMemory() h.ca = h.pe.GetMemory()
sessionId, _ := ctx.Value("SessionId").(string) if len(input) == 0 {
flag_admin_privilege, _ := h.flagManager.GetFlag("flag_admin_privilege") // move to the top node
h.st.Code = []byte{}
}
sessionId, ok := ctx.Value("SessionId").(string)
if ok {
ctx = context.WithValue(ctx, "SessionId", sessionId)
}
flag_admin_privilege, _ := h.flagManager.GetFlag("flag_admin_privilege")
isAdmin, _ := h.adminstore.IsAdmin(sessionId) isAdmin, _ := h.adminstore.IsAdmin(sessionId)
if isAdmin { if isAdmin {
@ -149,7 +150,7 @@ func (h *Handlers) Exit() {
h.pe = nil h.pe = nil
} }
// SetLanguage sets the language across the menu // SetLanguage sets the language across the menu.
func (h *Handlers) SetLanguage(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) SetLanguage(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
@ -173,6 +174,7 @@ func (h *Handlers) SetLanguage(ctx context.Context, sym string, input []byte) (r
return res, nil return res, nil
} }
// handles the account creation when no existing account is present for the session and stores associated data in the user data store.
func (h *Handlers) createAccountNoExist(ctx context.Context, sessionId string, res *resource.Result) error { func (h *Handlers) createAccountNoExist(ctx context.Context, sessionId string, res *resource.Result) error {
flag_account_created, _ := h.flagManager.GetFlag("flag_account_created") flag_account_created, _ := h.flagManager.GetFlag("flag_account_created")
r, err := h.accountService.CreateAccount(ctx) r, err := h.accountService.CreateAccount(ctx)
@ -205,9 +207,9 @@ func (h *Handlers) createAccountNoExist(ctx context.Context, sessionId string, r
return nil return nil
} }
// CreateAccount checks if any account exists on the JSON data file, and if not // CreateAccount checks if any account exists on the JSON data file, and if not,
// creates an account on the API, // creates an account on the API,
// sets the default values and flags // sets the default values and flags.
func (h *Handlers) CreateAccount(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) CreateAccount(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
var err error var err error
@ -216,7 +218,7 @@ func (h *Handlers) CreateAccount(ctx context.Context, sym string, input []byte)
return res, fmt.Errorf("missing session") return res, fmt.Errorf("missing session")
} }
store := h.userdataStore store := h.userdataStore
_, err = store.ReadEntry(ctx, sessionId, common.DATA_ACCOUNT_CREATED) _, err = store.ReadEntry(ctx, sessionId, common.DATA_PUBLIC_KEY)
if err != nil { if err != nil {
if db.IsNotFound(err) { if db.IsNotFound(err) {
logg.InfoCtxf(ctx, "Creating an account because it doesn't exist") logg.InfoCtxf(ctx, "Creating an account because it doesn't exist")
@ -231,19 +233,22 @@ func (h *Handlers) CreateAccount(ctx context.Context, sym string, input []byte)
return res, nil return res, nil
} }
func (h *Handlers) CheckPinMisMatch(ctx context.Context, sym string, input []byte) (resource.Result, error) { // CheckBlockedNumPinMisMatch checks if the provided PIN matches a temporary PIN stored for a blocked number.
func (h *Handlers) CheckBlockedNumPinMisMatch(ctx context.Context, sym string, input []byte) (resource.Result, error) {
res := resource.Result{} res := resource.Result{}
flag_pin_mismatch, _ := h.flagManager.GetFlag("flag_pin_mismatch") flag_pin_mismatch, _ := h.flagManager.GetFlag("flag_pin_mismatch")
sessionId, ok := ctx.Value("SessionId").(string) sessionId, ok := ctx.Value("SessionId").(string)
if !ok { if !ok {
return res, fmt.Errorf("missing session") return res, fmt.Errorf("missing session")
} }
// Get blocked number from storage.
store := h.userdataStore store := h.userdataStore
blockedNumber, err := store.ReadEntry(ctx, sessionId, common.DATA_BLOCKED_NUMBER) blockedNumber, err := store.ReadEntry(ctx, sessionId, common.DATA_BLOCKED_NUMBER)
if err != nil { if err != nil {
logg.ErrorCtxf(ctx, "failed to read blockedNumber entry with", "key", common.DATA_BLOCKED_NUMBER, "error", err) logg.ErrorCtxf(ctx, "failed to read blockedNumber entry with", "key", common.DATA_BLOCKED_NUMBER, "error", err)
return res, err return res, err
} }
// Get temporary PIN for the blocked number.
temporaryPin, err := store.ReadEntry(ctx, string(blockedNumber), common.DATA_TEMPORARY_VALUE) temporaryPin, err := store.ReadEntry(ctx, string(blockedNumber), common.DATA_TEMPORARY_VALUE)
if err != nil { if err != nil {
logg.ErrorCtxf(ctx, "failed to read temporaryPin entry with", "key", common.DATA_TEMPORARY_VALUE, "error", err) logg.ErrorCtxf(ctx, "failed to read temporaryPin entry with", "key", common.DATA_TEMPORARY_VALUE, "error", err)
@ -257,6 +262,7 @@ func (h *Handlers) CheckPinMisMatch(ctx context.Context, sym string, input []byt
return res, nil return res, nil
} }
// VerifyNewPin checks if a new PIN meets the required format criteria.
func (h *Handlers) VerifyNewPin(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) VerifyNewPin(ctx context.Context, sym string, input []byte) (resource.Result, error) {
res := resource.Result{} res := resource.Result{}
_, ok := ctx.Value("SessionId").(string) _, ok := ctx.Value("SessionId").(string)
@ -265,8 +271,8 @@ func (h *Handlers) VerifyNewPin(ctx context.Context, sym string, input []byte) (
} }
flag_valid_pin, _ := h.flagManager.GetFlag("flag_valid_pin") flag_valid_pin, _ := h.flagManager.GetFlag("flag_valid_pin")
pinInput := string(input) pinInput := string(input)
// Validate that the PIN is a 4-digit number // Validate that the PIN is a 4-digit number.
if isValidPIN(pinInput) { if common.IsValidPIN(pinInput) {
res.FlagSet = append(res.FlagSet, flag_valid_pin) res.FlagSet = append(res.FlagSet, flag_valid_pin)
} else { } else {
res.FlagReset = append(res.FlagReset, flag_valid_pin) res.FlagReset = append(res.FlagReset, flag_valid_pin)
@ -275,9 +281,9 @@ func (h *Handlers) VerifyNewPin(ctx context.Context, sym string, input []byte) (
return res, nil return res, nil
} }
// SaveTemporaryPin saves the valid PIN input to the DATA_TEMPORARY_VALUE // SaveTemporaryPin saves the valid PIN input to the DATA_TEMPORARY_VALUE,
// during the account creation process // during the account creation process
// and during the change PIN process // and during the change PIN process.
func (h *Handlers) SaveTemporaryPin(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) SaveTemporaryPin(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
var err error var err error
@ -290,8 +296,8 @@ func (h *Handlers) SaveTemporaryPin(ctx context.Context, sym string, input []byt
flag_incorrect_pin, _ := h.flagManager.GetFlag("flag_incorrect_pin") flag_incorrect_pin, _ := h.flagManager.GetFlag("flag_incorrect_pin")
accountPIN := string(input) accountPIN := string(input)
// Validate that the PIN is a 4-digit number // Validate that the PIN is a 4-digit number.
if !isValidPIN(accountPIN) { if !common.IsValidPIN(accountPIN) {
res.FlagSet = append(res.FlagSet, flag_incorrect_pin) res.FlagSet = append(res.FlagSet, flag_incorrect_pin)
return res, nil return res, nil
} }
@ -306,6 +312,7 @@ func (h *Handlers) SaveTemporaryPin(ctx context.Context, sym string, input []byt
return res, nil return res, nil
} }
// SaveOthersTemporaryPin allows authorized users to set temporary PINs for blocked numbers.
func (h *Handlers) SaveOthersTemporaryPin(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) SaveOthersTemporaryPin(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
var err error var err error
@ -316,12 +323,14 @@ func (h *Handlers) SaveOthersTemporaryPin(ctx context.Context, sym string, input
return res, fmt.Errorf("missing session") return res, fmt.Errorf("missing session")
} }
temporaryPin := string(input) temporaryPin := string(input)
// First, we retrieve the blocked number associated with this session
blockedNumber, err := store.ReadEntry(ctx, sessionId, common.DATA_BLOCKED_NUMBER) blockedNumber, err := store.ReadEntry(ctx, sessionId, common.DATA_BLOCKED_NUMBER)
if err != nil { if err != nil {
logg.ErrorCtxf(ctx, "failed to read blockedNumber entry with", "key", common.DATA_BLOCKED_NUMBER, "error", err) logg.ErrorCtxf(ctx, "failed to read blockedNumber entry with", "key", common.DATA_BLOCKED_NUMBER, "error", err)
return res, err return res, err
} }
// Then we save the temporary PIN for that blocked number
err = store.WriteEntry(ctx, string(blockedNumber), common.DATA_TEMPORARY_VALUE, []byte(temporaryPin)) err = store.WriteEntry(ctx, string(blockedNumber), common.DATA_TEMPORARY_VALUE, []byte(temporaryPin))
if err != nil { if err != nil {
logg.ErrorCtxf(ctx, "failed to write temporaryPin entry with", "key", common.DATA_TEMPORARY_VALUE, "value", temporaryPin, "error", err) logg.ErrorCtxf(ctx, "failed to write temporaryPin entry with", "key", common.DATA_TEMPORARY_VALUE, "value", temporaryPin, "error", err)
@ -331,6 +340,7 @@ func (h *Handlers) SaveOthersTemporaryPin(ctx context.Context, sym string, input
return res, nil return res, nil
} }
// ConfirmPinChange validates user's new PIN. If input matches the temporary PIN, saves it as the new account PIN.
func (h *Handlers) ConfirmPinChange(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) ConfirmPinChange(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
sessionId, ok := ctx.Value("SessionId").(string) sessionId, ok := ctx.Value("SessionId").(string)
@ -349,10 +359,20 @@ func (h *Handlers) ConfirmPinChange(ctx context.Context, sym string, input []byt
res.FlagReset = append(res.FlagReset, flag_pin_mismatch) res.FlagReset = append(res.FlagReset, flag_pin_mismatch)
} else { } else {
res.FlagSet = append(res.FlagSet, flag_pin_mismatch) res.FlagSet = append(res.FlagSet, flag_pin_mismatch)
return res, nil
} }
err = store.WriteEntry(ctx, sessionId, common.DATA_ACCOUNT_PIN, []byte(temporaryPin))
// Hash the PIN
hashedPIN, err := common.HashPIN(string(temporaryPin))
if err != nil { if err != nil {
logg.ErrorCtxf(ctx, "failed to write temporaryPin entry with", "key", common.DATA_ACCOUNT_PIN, "value", temporaryPin, "error", err) logg.ErrorCtxf(ctx, "failed to hash temporaryPin", "error", err)
return res, err
}
// save the hashed PIN as the new account PIN
err = store.WriteEntry(ctx, sessionId, common.DATA_ACCOUNT_PIN, []byte(hashedPIN))
if err != nil {
logg.ErrorCtxf(ctx, "failed to write DATA_ACCOUNT_PIN entry with", "key", common.DATA_ACCOUNT_PIN, "hashedPIN value", hashedPIN, "error", err)
return res, err return res, err
} }
return res, nil return res, nil
@ -360,7 +380,7 @@ func (h *Handlers) ConfirmPinChange(ctx context.Context, sym string, input []byt
// VerifyCreatePin checks whether the confirmation PIN is similar to the temporary PIN // VerifyCreatePin checks whether the confirmation PIN is similar to the temporary PIN
// If similar, it sets the USERFLAG_PIN_SET flag and writes the account PIN allowing the user // If similar, it sets the USERFLAG_PIN_SET flag and writes the account PIN allowing the user
// to access the main menu // to access the main menu.
func (h *Handlers) VerifyCreatePin(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) VerifyCreatePin(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
@ -384,18 +404,26 @@ func (h *Handlers) VerifyCreatePin(ctx context.Context, sym string, input []byte
res.FlagSet = append(res.FlagSet, flag_pin_set) res.FlagSet = append(res.FlagSet, flag_pin_set)
} else { } else {
res.FlagSet = []uint32{flag_pin_mismatch} res.FlagSet = []uint32{flag_pin_mismatch}
return res, nil
} }
err = store.WriteEntry(ctx, sessionId, common.DATA_ACCOUNT_PIN, []byte(temporaryPin)) // Hash the PIN
hashedPIN, err := common.HashPIN(string(temporaryPin))
if err != nil { if err != nil {
logg.ErrorCtxf(ctx, "failed to write temporaryPin entry with", "key", common.DATA_ACCOUNT_PIN, "value", temporaryPin, "error", err) logg.ErrorCtxf(ctx, "failed to hash temporaryPin", "error", err)
return res, err
}
err = store.WriteEntry(ctx, sessionId, common.DATA_ACCOUNT_PIN, []byte(hashedPIN))
if err != nil {
logg.ErrorCtxf(ctx, "failed to write DATA_ACCOUNT_PIN entry with", "key", common.DATA_ACCOUNT_PIN, "value", hashedPIN, "error", err)
return res, err return res, err
} }
return res, nil return res, nil
} }
// codeFromCtx retrieves language codes from the context that can be used for handling translations // retrieves language codes from the context that can be used for handling translations.
func codeFromCtx(ctx context.Context) string { func codeFromCtx(ctx context.Context) string {
var code string var code string
if ctx.Value("Language") != nil { if ctx.Value("Language") != nil {
@ -702,15 +730,27 @@ func (h *Handlers) Authorize(ctx context.Context, sym string, input []byte) (res
return res, err return res, err
} }
if len(input) == 4 { if len(input) == 4 {
if bytes.Equal(input, AccountPin) { if common.VerifyPIN(string(AccountPin), string(input)) {
if h.st.MatchFlag(flag_account_authorized, false) { if h.st.MatchFlag(flag_account_authorized, false) {
res.FlagReset = append(res.FlagReset, flag_incorrect_pin) res.FlagReset = append(res.FlagReset, flag_incorrect_pin)
res.FlagSet = append(res.FlagSet, flag_allow_update, flag_account_authorized) res.FlagSet = append(res.FlagSet, flag_allow_update, flag_account_authorized)
err := h.resetIncorrectPINAttempts(ctx, sessionId)
if err != nil {
return res, err
}
} else { } else {
res.FlagSet = append(res.FlagSet, flag_allow_update) res.FlagSet = append(res.FlagSet, flag_allow_update)
res.FlagReset = append(res.FlagReset, flag_account_authorized) res.FlagReset = append(res.FlagReset, flag_account_authorized)
err := h.resetIncorrectPINAttempts(ctx, sessionId)
if err != nil {
return res, err
}
} }
} else { } else {
err := h.incrementIncorrectPINAttempts(ctx, sessionId)
if err != nil {
return res, err
}
res.FlagSet = append(res.FlagSet, flag_incorrect_pin) res.FlagSet = append(res.FlagSet, flag_incorrect_pin)
res.FlagReset = append(res.FlagReset, flag_account_authorized) res.FlagReset = append(res.FlagReset, flag_account_authorized)
return res, nil return res, nil
@ -724,12 +764,38 @@ func (h *Handlers) Authorize(ctx context.Context, sym string, input []byte) (res
// ResetIncorrectPin resets the incorrect pin flag after a new PIN attempt. // ResetIncorrectPin resets the incorrect pin flag after a new PIN attempt.
func (h *Handlers) ResetIncorrectPin(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) ResetIncorrectPin(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
store := h.userdataStore
flag_incorrect_pin, _ := h.flagManager.GetFlag("flag_incorrect_pin") flag_incorrect_pin, _ := h.flagManager.GetFlag("flag_incorrect_pin")
flag_account_blocked, _ := h.flagManager.GetFlag("flag_account_blocked")
sessionId, ok := ctx.Value("SessionId").(string)
if !ok {
return res, fmt.Errorf("missing session")
}
res.FlagReset = append(res.FlagReset, flag_incorrect_pin) res.FlagReset = append(res.FlagReset, flag_incorrect_pin)
currentWrongPinAttempts, err := store.ReadEntry(ctx, sessionId, common.DATA_INCORRECT_PIN_ATTEMPTS)
if err != nil {
if !db.IsNotFound(err) {
return res, err
}
}
pinAttemptsValue, _ := strconv.ParseUint(string(currentWrongPinAttempts), 0, 64)
remainingPINAttempts := common.AllowedPINAttempts - uint8(pinAttemptsValue)
if remainingPINAttempts == 0 {
res.FlagSet = append(res.FlagSet, flag_account_blocked)
return res, nil
}
if remainingPINAttempts < common.AllowedPINAttempts {
res.Content = strconv.Itoa(int(remainingPINAttempts))
}
return res, nil return res, nil
} }
// Setback sets the flag_back_set flag when the navigation is back // Setback sets the flag_back_set flag when the navigation is back.
func (h *Handlers) SetBack(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) SetBack(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
//TODO: //TODO:
@ -742,7 +808,7 @@ func (h *Handlers) SetBack(ctx context.Context, sym string, input []byte) (resou
} }
// CheckAccountStatus queries the API using the TrackingId and sets flags // CheckAccountStatus queries the API using the TrackingId and sets flags
// based on the account status // based on the account status.
func (h *Handlers) CheckAccountStatus(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) CheckAccountStatus(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
@ -782,7 +848,7 @@ func (h *Handlers) CheckAccountStatus(ctx context.Context, sym string, input []b
return res, nil return res, nil
} }
// Quit displays the Thank you message and exits the menu // Quit displays the Thank you message and exits the menu.
func (h *Handlers) Quit(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) Quit(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
@ -797,7 +863,7 @@ func (h *Handlers) Quit(ctx context.Context, sym string, input []byte) (resource
return res, nil return res, nil
} }
// QuitWithHelp displays helpline information then exits the menu // QuitWithHelp displays helpline information then exits the menu.
func (h *Handlers) QuitWithHelp(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) QuitWithHelp(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
@ -807,12 +873,22 @@ func (h *Handlers) QuitWithHelp(ctx context.Context, sym string, input []byte) (
l := gotext.NewLocale(translationDir, code) l := gotext.NewLocale(translationDir, code)
l.AddDomain("default") l.AddDomain("default")
res.Content = l.Get("For more help,please call: 0757628885") res.Content = l.Get("For more help, please call: 0757628885")
res.FlagReset = append(res.FlagReset, flag_account_authorized) res.FlagReset = append(res.FlagReset, flag_account_authorized)
return res, nil return res, nil
} }
// VerifyYob verifies the length of the given input // ShowBlockedAccount displays a message after an account has been blocked and how to reach support.
func (h *Handlers) ShowBlockedAccount(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result
code := codeFromCtx(ctx)
l := gotext.NewLocale(translationDir, code)
l.AddDomain("default")
res.Content = l.Get("Your account has been locked. For help on how to unblock your account, contact support at: 0757628885")
return res, nil
}
// VerifyYob verifies the length of the given input.
func (h *Handlers) VerifyYob(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) VerifyYob(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
var err error var err error
@ -834,7 +910,7 @@ func (h *Handlers) VerifyYob(ctx context.Context, sym string, input []byte) (res
return res, nil return res, nil
} }
// ResetIncorrectYob resets the incorrect date format flag after a new attempt // ResetIncorrectYob resets the incorrect date format flag after a new attempt.
func (h *Handlers) ResetIncorrectYob(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) ResetIncorrectYob(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
@ -844,7 +920,7 @@ func (h *Handlers) ResetIncorrectYob(ctx context.Context, sym string, input []by
} }
// CheckBalance retrieves the balance of the active voucher and sets // CheckBalance retrieves the balance of the active voucher and sets
// the balance as the result content // the balance as the result content.
func (h *Handlers) CheckBalance(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) CheckBalance(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
var err error var err error
@ -894,9 +970,12 @@ func (h *Handlers) CheckBalance(ctx context.Context, sym string, input []byte) (
return res, nil return res, nil
} }
// FetchCommunityBalance retrieves and displays the balance for community accounts in user's preferred language.
func (h *Handlers) FetchCommunityBalance(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) FetchCommunityBalance(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
// retrieve the language code from the context
code := codeFromCtx(ctx) code := codeFromCtx(ctx)
// Initialize the localization system with the appropriate translation directory
l := gotext.NewLocale(translationDir, code) l := gotext.NewLocale(translationDir, code)
l.AddDomain("default") l.AddDomain("default")
//TODO: //TODO:
@ -905,6 +984,10 @@ func (h *Handlers) FetchCommunityBalance(ctx context.Context, sym string, input
return res, nil return res, nil
} }
// ResetOthersPin handles the PIN reset process for other users' accounts by:
// 1. Retrieving the blocked phone number from the session
// 2. Fetching the temporary PIN associated with that number
// 3. Updating the account PIN with the temporary PIN
func (h *Handlers) ResetOthersPin(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) ResetOthersPin(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
store := h.userdataStore store := h.userdataStore
@ -922,7 +1005,15 @@ func (h *Handlers) ResetOthersPin(ctx context.Context, sym string, input []byte)
logg.ErrorCtxf(ctx, "failed to read temporaryPin entry with", "key", common.DATA_TEMPORARY_VALUE, "error", err) logg.ErrorCtxf(ctx, "failed to read temporaryPin entry with", "key", common.DATA_TEMPORARY_VALUE, "error", err)
return res, err return res, err
} }
err = store.WriteEntry(ctx, string(blockedPhonenumber), common.DATA_ACCOUNT_PIN, []byte(temporaryPin))
// Hash the PIN
hashedPIN, err := common.HashPIN(string(temporaryPin))
if err != nil {
logg.ErrorCtxf(ctx, "failed to hash temporaryPin", "error", err)
return res, err
}
err = store.WriteEntry(ctx, string(blockedPhonenumber), common.DATA_ACCOUNT_PIN, []byte(hashedPIN))
if err != nil { if err != nil {
return res, nil return res, nil
} }
@ -930,6 +1021,8 @@ func (h *Handlers) ResetOthersPin(ctx context.Context, sym string, input []byte)
return res, nil return res, nil
} }
// ResetUnregisteredNumber clears the unregistered number flag in the system,
// indicating that a number's registration status should no longer be marked as unregistered.
func (h *Handlers) ResetUnregisteredNumber(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) ResetUnregisteredNumber(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
flag_unregistered_number, _ := h.flagManager.GetFlag("flag_unregistered_number") flag_unregistered_number, _ := h.flagManager.GetFlag("flag_unregistered_number")
@ -937,6 +1030,8 @@ func (h *Handlers) ResetUnregisteredNumber(ctx context.Context, sym string, inpu
return res, nil return res, nil
} }
// ValidateBlockedNumber performs validation of phone numbers, specifically for blocked numbers in the system.
// It checks phone number format and verifies registration status.
func (h *Handlers) ValidateBlockedNumber(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) ValidateBlockedNumber(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
var err error var err error
@ -1065,7 +1160,7 @@ func (h *Handlers) ValidateRecipient(ctx context.Context, sym string, input []by
} }
// TransactionReset resets the previous transaction data (Recipient and Amount) // TransactionReset resets the previous transaction data (Recipient and Amount)
// as well as the invalid flags // as well as the invalid flags.
func (h *Handlers) TransactionReset(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) TransactionReset(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
var err error var err error
@ -1118,7 +1213,7 @@ func (h *Handlers) InviteValidRecipient(ctx context.Context, sym string, input [
return res, nil return res, nil
} }
// ResetTransactionAmount resets the transaction amount and invalid flag // ResetTransactionAmount resets the transaction amount and invalid flag.
func (h *Handlers) ResetTransactionAmount(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) ResetTransactionAmount(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
var err error var err error
@ -1248,7 +1343,7 @@ func (h *Handlers) RetrieveBlockedNumber(ctx context.Context, sym string, input
return res, nil return res, nil
} }
// GetSender returns the sessionId (phoneNumber) // GetSender returns the sessionId (phoneNumber).
func (h *Handlers) GetSender(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) GetSender(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
@ -1262,7 +1357,7 @@ func (h *Handlers) GetSender(ctx context.Context, sym string, input []byte) (res
return res, nil return res, nil
} }
// GetAmount retrieves the amount from teh Gdbm Db // GetAmount retrieves the amount from teh Gdbm Db.
func (h *Handlers) GetAmount(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) GetAmount(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
@ -1286,7 +1381,7 @@ func (h *Handlers) GetAmount(ctx context.Context, sym string, input []byte) (res
return res, nil return res, nil
} }
// InitiateTransaction calls the TokenTransfer and returns a confirmation based on the result // InitiateTransaction calls the TokenTransfer and returns a confirmation based on the result.
func (h *Handlers) InitiateTransaction(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) InitiateTransaction(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
var err error var err error
@ -1336,9 +1431,12 @@ func (h *Handlers) InitiateTransaction(ctx context.Context, sym string, input []
return res, nil return res, nil
} }
// GetCurrentProfileInfo retrieves specific profile fields based on the current state of the USSD session.
// Uses flag management system to track profile field status and handle menu navigation.
func (h *Handlers) GetCurrentProfileInfo(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) GetCurrentProfileInfo(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
var profileInfo []byte var profileInfo []byte
var defaultValue string
var err error var err error
flag_firstname_set, _ := h.flagManager.GetFlag("flag_firstname_set") flag_firstname_set, _ := h.flagManager.GetFlag("flag_firstname_set")
@ -1355,6 +1453,17 @@ func (h *Handlers) GetCurrentProfileInfo(ctx context.Context, sym string, input
if !ok { if !ok {
return res, fmt.Errorf("missing session") return res, fmt.Errorf("missing session")
} }
language, ok := ctx.Value("Language").(lang.Language)
if !ok {
return res, fmt.Errorf("value for 'Language' is not of type lang.Language")
}
code := language.Code
if code == "swa" {
defaultValue = "Haipo"
} else {
defaultValue = "Not Provided"
}
sm, _ := h.st.Where() sm, _ := h.st.Where()
parts := strings.SplitN(sm, "_", 2) parts := strings.SplitN(sm, "_", 2)
filename := parts[1] filename := parts[1]
@ -1371,7 +1480,7 @@ func (h *Handlers) GetCurrentProfileInfo(ctx context.Context, sym string, input
profileInfo, err = store.ReadEntry(ctx, sessionId, common.DATA_FIRST_NAME) profileInfo, err = store.ReadEntry(ctx, sessionId, common.DATA_FIRST_NAME)
if err != nil { if err != nil {
if db.IsNotFound(err) { if db.IsNotFound(err) {
res.Content = "Not provided" res.Content = defaultValue
break break
} }
logg.ErrorCtxf(ctx, "Failed to read first name entry with", "key", "error", common.DATA_FIRST_NAME, err) logg.ErrorCtxf(ctx, "Failed to read first name entry with", "key", "error", common.DATA_FIRST_NAME, err)
@ -1383,7 +1492,7 @@ func (h *Handlers) GetCurrentProfileInfo(ctx context.Context, sym string, input
profileInfo, err = store.ReadEntry(ctx, sessionId, common.DATA_FAMILY_NAME) profileInfo, err = store.ReadEntry(ctx, sessionId, common.DATA_FAMILY_NAME)
if err != nil { if err != nil {
if db.IsNotFound(err) { if db.IsNotFound(err) {
res.Content = "Not provided" res.Content = defaultValue
break break
} }
logg.ErrorCtxf(ctx, "Failed to read family name entry with", "key", "error", common.DATA_FAMILY_NAME, err) logg.ErrorCtxf(ctx, "Failed to read family name entry with", "key", "error", common.DATA_FAMILY_NAME, err)
@ -1396,7 +1505,7 @@ func (h *Handlers) GetCurrentProfileInfo(ctx context.Context, sym string, input
profileInfo, err = store.ReadEntry(ctx, sessionId, common.DATA_GENDER) profileInfo, err = store.ReadEntry(ctx, sessionId, common.DATA_GENDER)
if err != nil { if err != nil {
if db.IsNotFound(err) { if db.IsNotFound(err) {
res.Content = "Not provided" res.Content = defaultValue
break break
} }
logg.ErrorCtxf(ctx, "Failed to read gender entry with", "key", "error", common.DATA_GENDER, err) logg.ErrorCtxf(ctx, "Failed to read gender entry with", "key", "error", common.DATA_GENDER, err)
@ -1408,7 +1517,7 @@ func (h *Handlers) GetCurrentProfileInfo(ctx context.Context, sym string, input
profileInfo, err = store.ReadEntry(ctx, sessionId, common.DATA_YOB) profileInfo, err = store.ReadEntry(ctx, sessionId, common.DATA_YOB)
if err != nil { if err != nil {
if db.IsNotFound(err) { if db.IsNotFound(err) {
res.Content = "Not provided" res.Content = defaultValue
break break
} }
logg.ErrorCtxf(ctx, "Failed to read year of birth(yob) entry with", "key", "error", common.DATA_YOB, err) logg.ErrorCtxf(ctx, "Failed to read year of birth(yob) entry with", "key", "error", common.DATA_YOB, err)
@ -1420,7 +1529,7 @@ func (h *Handlers) GetCurrentProfileInfo(ctx context.Context, sym string, input
profileInfo, err = store.ReadEntry(ctx, sessionId, common.DATA_LOCATION) profileInfo, err = store.ReadEntry(ctx, sessionId, common.DATA_LOCATION)
if err != nil { if err != nil {
if db.IsNotFound(err) { if db.IsNotFound(err) {
res.Content = "Not provided" res.Content = defaultValue
break break
} }
logg.ErrorCtxf(ctx, "Failed to read location entry with", "key", "error", common.DATA_LOCATION, err) logg.ErrorCtxf(ctx, "Failed to read location entry with", "key", "error", common.DATA_LOCATION, err)
@ -1432,7 +1541,7 @@ func (h *Handlers) GetCurrentProfileInfo(ctx context.Context, sym string, input
profileInfo, err = store.ReadEntry(ctx, sessionId, common.DATA_OFFERINGS) profileInfo, err = store.ReadEntry(ctx, sessionId, common.DATA_OFFERINGS)
if err != nil { if err != nil {
if db.IsNotFound(err) { if db.IsNotFound(err) {
res.Content = "Not provided" res.Content = defaultValue
break break
} }
logg.ErrorCtxf(ctx, "Failed to read offerings entry with", "key", "error", common.DATA_OFFERINGS, err) logg.ErrorCtxf(ctx, "Failed to read offerings entry with", "key", "error", common.DATA_OFFERINGS, err)
@ -1447,6 +1556,7 @@ func (h *Handlers) GetCurrentProfileInfo(ctx context.Context, sym string, input
return res, nil return res, nil
} }
// GetProfileInfo provides a comprehensive view of a user's profile.
func (h *Handlers) GetProfileInfo(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) GetProfileInfo(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
var defaultValue string var defaultValue string
@ -1515,7 +1625,7 @@ func (h *Handlers) GetProfileInfo(ctx context.Context, sym string, input []byte)
} }
// SetDefaultVoucher retrieves the current vouchers // SetDefaultVoucher retrieves the current vouchers
// and sets the first as the default voucher, if no active voucher is set // and sets the first as the default voucher, if no active voucher is set.
func (h *Handlers) SetDefaultVoucher(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) SetDefaultVoucher(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
var err error var err error
@ -1600,7 +1710,7 @@ func (h *Handlers) SetDefaultVoucher(ctx context.Context, sym string, input []by
} }
// CheckVouchers retrieves the token holdings from the API using the "PublicKey" and stores // CheckVouchers retrieves the token holdings from the API using the "PublicKey" and stores
// them to gdbm // them to gdbm.
func (h *Handlers) CheckVouchers(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) CheckVouchers(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
sessionId, ok := ctx.Value("SessionId").(string) sessionId, ok := ctx.Value("SessionId").(string)
@ -1672,7 +1782,7 @@ func (h *Handlers) CheckVouchers(ctx context.Context, sym string, input []byte)
return res, nil return res, nil
} }
// GetVoucherList fetches the list of vouchers and formats them // GetVoucherList fetches the list of vouchers and formats them.
func (h *Handlers) GetVoucherList(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) GetVoucherList(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
@ -1683,13 +1793,15 @@ func (h *Handlers) GetVoucherList(ctx context.Context, sym string, input []byte)
return res, err return res, err
} }
res.Content = string(voucherData) formattedData := h.ReplaceSeparatorFunc(string(voucherData))
res.Content = string(formattedData)
return res, nil return res, nil
} }
// ViewVoucher retrieves the token holding and balance from the subprefixDB // ViewVoucher retrieves the token holding and balance from the subprefixDB
// and displays it to the user for them to select it // and displays it to the user for them to select it.
func (h *Handlers) ViewVoucher(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) ViewVoucher(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
sessionId, ok := ctx.Value("SessionId").(string) sessionId, ok := ctx.Value("SessionId").(string)
@ -1730,7 +1842,7 @@ func (h *Handlers) ViewVoucher(ctx context.Context, sym string, input []byte) (r
return res, nil return res, nil
} }
// SetVoucher retrieves the temp voucher data and sets it as the active data // SetVoucher retrieves the temp voucher data and sets it as the active data.
func (h *Handlers) SetVoucher(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) SetVoucher(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
@ -1756,7 +1868,7 @@ func (h *Handlers) SetVoucher(ctx context.Context, sym string, input []byte) (re
return res, nil return res, nil
} }
// GetVoucherDetails retrieves the voucher details // GetVoucherDetails retrieves the voucher details.
func (h *Handlers) GetVoucherDetails(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) GetVoucherDetails(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
store := h.userdataStore store := h.userdataStore
@ -1788,7 +1900,7 @@ func (h *Handlers) GetVoucherDetails(ctx context.Context, sym string, input []by
return res, nil return res, nil
} }
// CheckTransactions retrieves the transactions from the API using the "PublicKey" and stores to prefixDb // CheckTransactions retrieves the transactions from the API using the "PublicKey" and stores to prefixDb.
func (h *Handlers) CheckTransactions(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) CheckTransactions(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
sessionId, ok := ctx.Value("SessionId").(string) sessionId, ok := ctx.Value("SessionId").(string)
@ -1846,13 +1958,14 @@ func (h *Handlers) CheckTransactions(ctx context.Context, sym string, input []by
return res, nil return res, nil
} }
// GetTransactionsList fetches the list of transactions and formats them // GetTransactionsList fetches the list of transactions and formats them.
func (h *Handlers) GetTransactionsList(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) GetTransactionsList(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
sessionId, ok := ctx.Value("SessionId").(string) sessionId, ok := ctx.Value("SessionId").(string)
if !ok { if !ok {
return res, fmt.Errorf("missing session") return res, fmt.Errorf("missing session")
} }
store := h.userdataStore store := h.userdataStore
publicKey, err := store.ReadEntry(ctx, sessionId, common.DATA_PUBLIC_KEY) publicKey, err := store.ReadEntry(ctx, sessionId, common.DATA_PUBLIC_KEY)
if err != nil { if err != nil {
@ -1895,12 +2008,14 @@ func (h *Handlers) GetTransactionsList(ctx context.Context, sym string, input []
value := strings.TrimSpace(values[i]) value := strings.TrimSpace(values[i])
date := strings.Split(strings.TrimSpace(dates[i]), " ")[0] date := strings.Split(strings.TrimSpace(dates[i]), " ")[0]
status := "received" status := "Received"
if sender == string(publicKey) { if sender == string(publicKey) {
status = "sent" status = "Sent"
} }
formattedTransactions = append(formattedTransactions, fmt.Sprintf("%d:%s %s %s %s", i+1, status, value, sym, date)) // Use the ReplaceSeparator function for the menu separator
transactionLine := fmt.Sprintf("%d%s%s %s %s %s", i+1, h.ReplaceSeparatorFunc(":"), status, value, sym, date)
formattedTransactions = append(formattedTransactions, transactionLine)
} }
res.Content = strings.Join(formattedTransactions, "\n") res.Content = strings.Join(formattedTransactions, "\n")
@ -1909,7 +2024,7 @@ func (h *Handlers) GetTransactionsList(ctx context.Context, sym string, input []
} }
// ViewTransactionStatement retrieves the transaction statement // ViewTransactionStatement retrieves the transaction statement
// and displays it to the user // and displays it to the user.
func (h *Handlers) ViewTransactionStatement(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) ViewTransactionStatement(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
sessionId, ok := ctx.Value("SessionId").(string) sessionId, ok := ctx.Value("SessionId").(string)
@ -1957,6 +2072,7 @@ func (h *Handlers) ViewTransactionStatement(ctx context.Context, sym string, inp
return res, nil return res, nil
} }
// handles bulk updates of profile information.
func (h *Handlers) insertProfileItems(ctx context.Context, sessionId string, res *resource.Result) error { func (h *Handlers) insertProfileItems(ctx context.Context, sessionId string, res *resource.Result) error {
var err error var err error
store := h.userdataStore store := h.userdataStore
@ -1979,21 +2095,22 @@ func (h *Handlers) insertProfileItems(ctx context.Context, sessionId string, res
for index, profileItem := range h.profile.ProfileItems { for index, profileItem := range h.profile.ProfileItems {
// Ensure the profileItem is not "0"(is set) // Ensure the profileItem is not "0"(is set)
if profileItem != "0" { if profileItem != "0" {
flag, _ := h.flagManager.GetFlag(profileFlagNames[index])
isProfileItemSet := h.st.MatchFlag(flag, true)
if !isProfileItemSet {
err = store.WriteEntry(ctx, sessionId, profileDataKeys[index], []byte(profileItem)) err = store.WriteEntry(ctx, sessionId, profileDataKeys[index], []byte(profileItem))
if err != nil { if err != nil {
logg.ErrorCtxf(ctx, "failed to write profile entry with", "key", profileDataKeys[index], "value", profileItem, "error", err) logg.ErrorCtxf(ctx, "failed to write profile entry with", "key", profileDataKeys[index], "value", profileItem, "error", err)
return err return err
} }
// Get the flag for the current index
flag, _ := h.flagManager.GetFlag(profileFlagNames[index])
res.FlagSet = append(res.FlagSet, flag) res.FlagSet = append(res.FlagSet, flag)
} }
} }
}
return nil return nil
} }
// UpdateAllProfileItems is used to persist all the new profile information and setup the required profile flags // UpdateAllProfileItems is used to persist all the new profile information and setup the required profile flags.
func (h *Handlers) UpdateAllProfileItems(ctx context.Context, sym string, input []byte) (resource.Result, error) { func (h *Handlers) UpdateAllProfileItems(ctx context.Context, sym string, input []byte) (resource.Result, error) {
var res resource.Result var res resource.Result
sessionId, ok := ctx.Value("SessionId").(string) sessionId, ok := ctx.Value("SessionId").(string)
@ -2006,3 +2123,53 @@ func (h *Handlers) UpdateAllProfileItems(ctx context.Context, sym string, input
} }
return res, nil return res, nil
} }
// incrementIncorrectPINAttempts keeps track of the number of incorrect PIN attempts
func (h *Handlers) incrementIncorrectPINAttempts(ctx context.Context, sessionId string) error {
var pinAttemptsCount uint8
store := h.userdataStore
currentWrongPinAttempts, err := store.ReadEntry(ctx, sessionId, common.DATA_INCORRECT_PIN_ATTEMPTS)
if err != nil {
if db.IsNotFound(err) {
//First time Wrong PIN attempt: initialize with a count of 1
pinAttemptsCount = 1
err = store.WriteEntry(ctx, sessionId, common.DATA_INCORRECT_PIN_ATTEMPTS, []byte(strconv.Itoa(int(pinAttemptsCount))))
if err != nil {
logg.ErrorCtxf(ctx, "failed to write incorrect PIN attempts ", "key", common.DATA_INCORRECT_PIN_ATTEMPTS, "value", currentWrongPinAttempts, "error", err)
return err
}
return nil
}
}
pinAttemptsValue, _ := strconv.ParseUint(string(currentWrongPinAttempts), 0, 64)
pinAttemptsCount = uint8(pinAttemptsValue) + 1
err = store.WriteEntry(ctx, sessionId, common.DATA_INCORRECT_PIN_ATTEMPTS, []byte(strconv.Itoa(int(pinAttemptsCount))))
if err != nil {
logg.ErrorCtxf(ctx, "failed to write incorrect PIN attempts ", "key", common.DATA_INCORRECT_PIN_ATTEMPTS, "value", pinAttemptsCount, "error", err)
return err
}
return nil
}
// resetIncorrectPINAttempts resets the number of incorrect PIN attempts after a correct PIN entry
func (h *Handlers) resetIncorrectPINAttempts(ctx context.Context, sessionId string) error {
store := h.userdataStore
currentWrongPinAttempts, err := store.ReadEntry(ctx, sessionId, common.DATA_INCORRECT_PIN_ATTEMPTS)
if err != nil {
if db.IsNotFound(err) {
return nil
}
return err
}
currentWrongPinAttemptsCount, _ := strconv.ParseUint(string(currentWrongPinAttempts), 0, 64)
if currentWrongPinAttemptsCount <= uint64(common.AllowedPINAttempts) {
err = store.WriteEntry(ctx, sessionId, common.DATA_INCORRECT_PIN_ATTEMPTS, []byte(string("0")))
if err != nil {
logg.ErrorCtxf(ctx, "failed to reset incorrect PIN attempts ", "key", common.DATA_INCORRECT_PIN_ATTEMPTS, "value", common.AllowedPINAttempts, "error", err)
return err
}
}
return nil
}

View File

@ -5,15 +5,19 @@ import (
"fmt" "fmt"
"log" "log"
"path" "path"
"strconv"
"strings"
"testing" "testing"
"git.defalsify.org/vise.git/cache"
"git.defalsify.org/vise.git/lang" "git.defalsify.org/vise.git/lang"
"git.defalsify.org/vise.git/persist" "git.defalsify.org/vise.git/persist"
"git.defalsify.org/vise.git/resource" "git.defalsify.org/vise.git/resource"
"git.defalsify.org/vise.git/state" "git.defalsify.org/vise.git/state"
"git.grassecon.net/urdt/ussd/internal/storage" dbstorage "git.grassecon.net/urdt/ussd/internal/storage/db"
"git.grassecon.net/urdt/ussd/internal/testutil/mocks" "git.grassecon.net/urdt/ussd/internal/testutil/mocks"
"git.grassecon.net/urdt/ussd/internal/testutil/testservice" "git.grassecon.net/urdt/ussd/internal/testutil/testservice"
"git.grassecon.net/urdt/ussd/internal/utils"
"git.grassecon.net/urdt/ussd/models" "git.grassecon.net/urdt/ussd/models"
"git.grassecon.net/urdt/ussd/common" "git.grassecon.net/urdt/ussd/common"
@ -32,6 +36,11 @@ var (
flagsPath = path.Join(baseDir, "services", "registration", "pp.csv") flagsPath = path.Join(baseDir, "services", "registration", "pp.csv")
) )
// mockReplaceSeparator function
var mockReplaceSeparator = func(input string) string {
return strings.ReplaceAll(input, ":", ": ")
}
// InitializeTestStore sets up and returns an in-memory database and store. // InitializeTestStore sets up and returns an in-memory database and store.
func InitializeTestStore(t *testing.T) (context.Context, *common.UserDataStore) { func InitializeTestStore(t *testing.T) (context.Context, *common.UserDataStore) {
ctx := context.Background() ctx := context.Background()
@ -51,14 +60,14 @@ func InitializeTestStore(t *testing.T) (context.Context, *common.UserDataStore)
return ctx, store return ctx, store
} }
func InitializeTestSubPrefixDb(t *testing.T, ctx context.Context) *storage.SubPrefixDb { func InitializeTestSubPrefixDb(t *testing.T, ctx context.Context) *dbstorage.SubPrefixDb {
db := memdb.NewMemDb() db := memdb.NewMemDb()
err := db.Connect(ctx, "") err := db.Connect(ctx, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
prefix := common.ToBytes(visedb.DATATYPE_USERDATA) prefix := common.ToBytes(visedb.DATATYPE_USERDATA)
spdb := storage.NewSubPrefixDb(db, prefix) spdb := dbstorage.NewSubPrefixDb(db, prefix)
return spdb return spdb
} }
@ -67,12 +76,15 @@ func TestNewHandlers(t *testing.T) {
_, store := InitializeTestStore(t) _, store := InitializeTestStore(t)
fm, err := NewFlagManager(flagsPath) fm, err := NewFlagManager(flagsPath)
accountService := testservice.TestAccountService{}
if err != nil { if err != nil {
t.Logf(err.Error()) log.Fatal(err)
} }
accountService := testservice.TestAccountService{}
// Test case for valid UserDataStore
t.Run("Valid UserDataStore", func(t *testing.T) { t.Run("Valid UserDataStore", func(t *testing.T) {
handlers, err := NewHandlers(fm.parser, store, nil, &accountService) handlers, err := NewHandlers(fm.parser, store, nil, &accountService, mockReplaceSeparator)
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
@ -82,23 +94,130 @@ func TestNewHandlers(t *testing.T) {
if handlers.userdataStore == nil { if handlers.userdataStore == nil {
t.Fatal("expected userdataStore to be set in handlers") t.Fatal("expected userdataStore to be set in handlers")
} }
if handlers.ReplaceSeparatorFunc == nil {
t.Fatal("expected ReplaceSeparatorFunc to be set in handlers")
}
// Test ReplaceSeparatorFunc functionality
input := "1:Menu item"
expectedOutput := "1: Menu item"
if handlers.ReplaceSeparatorFunc(input) != expectedOutput {
t.Fatalf("ReplaceSeparatorFunc function did not return expected output: got %v, want %v", handlers.ReplaceSeparatorFunc(input), expectedOutput)
}
}) })
// Test case for nil userdataStore // Test case for nil UserDataStore
t.Run("Nil UserDataStore", func(t *testing.T) { t.Run("Nil UserDataStore", func(t *testing.T) {
handlers, err := NewHandlers(fm.parser, nil, nil, &accountService) handlers, err := NewHandlers(fm.parser, nil, nil, &accountService, mockReplaceSeparator)
if err == nil { if err == nil {
t.Fatal("expected an error, got none") t.Fatal("expected an error, got none")
} }
if handlers != nil { if handlers != nil {
t.Fatal("expected handlers to be nil") t.Fatal("expected handlers to be nil")
} }
if err.Error() != "cannot create handler with nil userdata store" { expectedError := "cannot create handler with nil userdata store"
t.Fatalf("expected specific error, got %v", err) if err.Error() != expectedError {
t.Fatalf("expected error '%s', got '%v'", expectedError, err)
} }
}) })
} }
func TestInit(t *testing.T) {
sessionId := "session123"
ctx, store := InitializeTestStore(t)
ctx = context.WithValue(ctx, "SessionId", sessionId)
fm, err := NewFlagManager(flagsPath)
if err != nil {
t.Fatal(err.Error())
}
adminstore, err := utils.NewAdminStore(ctx, "admin_numbers")
if err != nil {
t.Fatal(err.Error())
}
st := state.NewState(128)
ca := cache.NewCache()
flag_admin_privilege, _ := fm.GetFlag("flag_admin_privilege")
tests := []struct {
name string
setup func() (*Handlers, context.Context)
input []byte
expectedResult resource.Result
}{
{
name: "Handler not ready",
setup: func() (*Handlers, context.Context) {
return &Handlers{}, ctx
},
input: []byte("1"),
expectedResult: resource.Result{},
},
{
name: "State and memory initialization",
setup: func() (*Handlers, context.Context) {
pe := persist.NewPersister(store).WithSession(sessionId).WithContent(st, ca)
h := &Handlers{
flagManager: fm.parser,
adminstore: adminstore,
pe: pe,
}
return h, context.WithValue(ctx, "SessionId", sessionId)
},
input: []byte("1"),
expectedResult: resource.Result{
FlagReset: []uint32{flag_admin_privilege},
},
},
{
name: "Non-admin session initialization",
setup: func() (*Handlers, context.Context) {
pe := persist.NewPersister(store).WithSession("0712345678").WithContent(st, ca)
h := &Handlers{
flagManager: fm.parser,
adminstore: adminstore,
pe: pe,
}
return h, context.WithValue(context.Background(), "SessionId", "0712345678")
},
input: []byte("1"),
expectedResult: resource.Result{
FlagReset: []uint32{flag_admin_privilege},
},
},
{
name: "Move to top node on empty input",
setup: func() (*Handlers, context.Context) {
pe := persist.NewPersister(store).WithSession(sessionId).WithContent(st, ca)
h := &Handlers{
flagManager: fm.parser,
adminstore: adminstore,
pe: pe,
}
st.Code = []byte("some pending bytecode")
return h, context.WithValue(ctx, "SessionId", sessionId)
},
input: []byte(""),
expectedResult: resource.Result{
FlagReset: []uint32{flag_admin_privilege},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h, testCtx := tt.setup()
res, err := h.Init(testCtx, "", tt.input)
assert.NoError(t, err, "Unexpected error occurred")
assert.Equal(t, res, tt.expectedResult, "Expected result should match actual result")
})
}
}
func TestCreateAccount(t *testing.T) { func TestCreateAccount(t *testing.T) {
sessionId := "session123" sessionId := "session123"
ctx, store := InitializeTestStore(t) ctx, store := InitializeTestStore(t)
@ -789,37 +908,79 @@ func TestResetAccountAuthorized(t *testing.T) {
} }
func TestIncorrectPinReset(t *testing.T) { func TestIncorrectPinReset(t *testing.T) {
sessionId := "session123"
ctx, store := InitializeTestStore(t)
fm, err := NewFlagManager(flagsPath) fm, err := NewFlagManager(flagsPath)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
flag_incorrect_pin, _ := fm.parser.GetFlag("flag_incorrect_pin") flag_incorrect_pin, _ := fm.parser.GetFlag("flag_incorrect_pin")
flag_account_blocked, _ := fm.parser.GetFlag("flag_account_blocked")
ctx = context.WithValue(ctx, "SessionId", sessionId)
// Define test cases // Define test cases
tests := []struct { tests := []struct {
name string name string
input []byte input []byte
attempts uint8
expectedResult resource.Result expectedResult resource.Result
}{ }{
{ {
name: "Test incorrect pin reset", name: "Test when incorrect PIN attempts is 2",
input: []byte(""), input: []byte(""),
expectedResult: resource.Result{ expectedResult: resource.Result{
FlagReset: []uint32{flag_incorrect_pin}, FlagReset: []uint32{flag_incorrect_pin},
Content: "1", //Expected remaining PIN attempts
}, },
attempts: 2,
},
{
name: "Test incorrect pin reset when incorrect PIN attempts is 1",
input: []byte(""),
expectedResult: resource.Result{
FlagReset: []uint32{flag_incorrect_pin},
Content: "2", //Expected remaining PIN attempts
},
attempts: 1,
},
{
name: "Test incorrect pin reset when incorrect PIN attempts is 1",
input: []byte(""),
expectedResult: resource.Result{
FlagReset: []uint32{flag_incorrect_pin},
Content: "2", //Expected remaining PIN attempts
},
attempts: 1,
},
{
name: "Test incorrect pin reset when incorrect PIN attempts is 3(account expected to be blocked)",
input: []byte(""),
expectedResult: resource.Result{
FlagReset: []uint32{flag_incorrect_pin},
FlagSet: []uint32{flag_account_blocked},
},
attempts: 3,
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if err := store.WriteEntry(ctx, sessionId, common.DATA_INCORRECT_PIN_ATTEMPTS, []byte(strconv.Itoa(int(tt.attempts)))); err != nil {
t.Fatal(err)
}
// Create the Handlers instance with the mock flag manager // Create the Handlers instance with the mock flag manager
h := &Handlers{ h := &Handlers{
flagManager: fm.parser, flagManager: fm.parser,
userdataStore: store,
} }
// Call the method // Call the method
res, err := h.ResetIncorrectPin(context.Background(), "reset_incorrect_pin", tt.input) res, err := h.ResetIncorrectPin(ctx, "reset_incorrect_pin", tt.input)
if err != nil { if err != nil {
t.Error(err) t.Error(err)
} }
@ -929,7 +1090,14 @@ func TestAuthorize(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
err = store.WriteEntry(ctx, sessionId, common.DATA_ACCOUNT_PIN, []byte(accountPIN)) // Hash the PIN
hashedPIN, err := common.HashPIN(accountPIN)
if err != nil {
logg.ErrorCtxf(ctx, "failed to hash temporaryPin", "error", err)
t.Fatal(err)
}
err = store.WriteEntry(ctx, sessionId, common.DATA_ACCOUNT_PIN, []byte(hashedPIN))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -1381,59 +1549,6 @@ func TestQuit(t *testing.T) {
} }
} }
func TestIsValidPIN(t *testing.T) {
tests := []struct {
name string
pin string
expected bool
}{
{
name: "Valid PIN with 4 digits",
pin: "1234",
expected: true,
},
{
name: "Valid PIN with leading zeros",
pin: "0001",
expected: true,
},
{
name: "Invalid PIN with less than 4 digits",
pin: "123",
expected: false,
},
{
name: "Invalid PIN with more than 4 digits",
pin: "12345",
expected: false,
},
{
name: "Invalid PIN with letters",
pin: "abcd",
expected: false,
},
{
name: "Invalid PIN with special characters",
pin: "12@#",
expected: false,
},
{
name: "Empty PIN",
pin: "",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
actual := isValidPIN(tt.pin)
if actual != tt.expected {
t.Errorf("isValidPIN(%q) = %v; expected %v", tt.pin, actual, tt.expected)
}
})
}
}
func TestValidateAmount(t *testing.T) { func TestValidateAmount(t *testing.T) {
fm, err := NewFlagManager(flagsPath) fm, err := NewFlagManager(flagsPath)
if err != nil { if err != nil {
@ -1680,7 +1795,7 @@ func TestGetProfile(t *testing.T) {
result: resource.Result{ result: resource.Result{
Content: fmt.Sprintf( Content: fmt.Sprintf(
"Name: %s\nGender: %s\nAge: %s\nLocation: %s\nYou provide: %s\n", "Name: %s\nGender: %s\nAge: %s\nLocation: %s\nYou provide: %s\n",
"John Doee", "Male", "48", "Kilifi", "Bananas", "John Doee", "Male", "49", "Kilifi", "Bananas",
), ),
}, },
}, },
@ -1692,7 +1807,7 @@ func TestGetProfile(t *testing.T) {
result: resource.Result{ result: resource.Result{
Content: fmt.Sprintf( Content: fmt.Sprintf(
"Jina: %s\nJinsia: %s\nUmri: %s\nEneo: %s\nUnauza: %s\n", "Jina: %s\nJinsia: %s\nUmri: %s\nEneo: %s\nUnauza: %s\n",
"John Doee", "Male", "48", "Kilifi", "Bananas", "John Doee", "Male", "49", "Kilifi", "Bananas",
), ),
}, },
}, },
@ -1704,7 +1819,7 @@ func TestGetProfile(t *testing.T) {
result: resource.Result{ result: resource.Result{
Content: fmt.Sprintf( Content: fmt.Sprintf(
"Name: %s\nGender: %s\nAge: %s\nLocation: %s\nYou provide: %s\n", "Name: %s\nGender: %s\nAge: %s\nLocation: %s\nYou provide: %s\n",
"John Doee", "Male", "48", "Kilifi", "Bananas", "John Doee", "Male", "49", "Kilifi", "Bananas",
), ),
}, },
}, },
@ -1982,26 +2097,31 @@ func TestCheckVouchers(t *testing.T) {
func TestGetVoucherList(t *testing.T) { func TestGetVoucherList(t *testing.T) {
sessionId := "session123" sessionId := "session123"
ctx := context.WithValue(context.Background(), "SessionId", sessionId) ctx := context.WithValue(context.Background(), "SessionId", sessionId)
spdb := InitializeTestSubPrefixDb(t, ctx) spdb := InitializeTestSubPrefixDb(t, ctx)
// Initialize Handlers
h := &Handlers{ h := &Handlers{
prefixDb: spdb, prefixDb: spdb,
ReplaceSeparatorFunc: mockReplaceSeparator,
} }
expectedSym := []byte("1:SRF\n2:MILO") mockSyms := []byte("1:SRF\n2:MILO")
// Put voucher sym data from the store // Put voucher sym data from the store
err := spdb.Put(ctx, common.ToBytes(common.DATA_VOUCHER_SYMBOLS), expectedSym) err := spdb.Put(ctx, common.ToBytes(common.DATA_VOUCHER_SYMBOLS), mockSyms)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
expectedSyms := []byte("1: SRF\n2: MILO")
res, err := h.GetVoucherList(ctx, "", []byte("")) res, err := h.GetVoucherList(ctx, "", []byte(""))
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, res.Content, string(expectedSym)) assert.Equal(t, res.Content, string(expectedSyms))
} }
func TestViewVoucher(t *testing.T) { func TestViewVoucher(t *testing.T) {
@ -2113,3 +2233,55 @@ func TestGetVoucherDetails(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, expectedResult, res) assert.Equal(t, expectedResult, res)
} }
func TestCountIncorrectPINAttempts(t *testing.T) {
ctx, store := InitializeTestStore(t)
sessionId := "session123"
ctx = context.WithValue(ctx, "SessionId", sessionId)
attempts := uint8(2)
h := &Handlers{
userdataStore: store,
}
err := store.WriteEntry(ctx, sessionId, common.DATA_INCORRECT_PIN_ATTEMPTS, []byte(strconv.Itoa(int(attempts))))
if err != nil {
t.Logf(err.Error())
}
err = h.incrementIncorrectPINAttempts(ctx, sessionId)
if err != nil {
t.Logf(err.Error())
}
attemptsAfterCount, err := store.ReadEntry(ctx, sessionId, common.DATA_INCORRECT_PIN_ATTEMPTS)
if err != nil {
t.Logf(err.Error())
}
pinAttemptsValue, _ := strconv.ParseUint(string(attemptsAfterCount), 0, 64)
pinAttemptsCount := uint8(pinAttemptsValue)
expectedAttempts := attempts + 1
assert.Equal(t, pinAttemptsCount, expectedAttempts)
}
func TestResetIncorrectPINAttempts(t *testing.T) {
ctx, store := InitializeTestStore(t)
sessionId := "session123"
ctx = context.WithValue(ctx, "SessionId", sessionId)
err := store.WriteEntry(ctx, sessionId, common.DATA_INCORRECT_PIN_ATTEMPTS, []byte(string("2")))
if err != nil {
t.Logf(err.Error())
}
h := &Handlers{
userdataStore: store,
}
h.resetIncorrectPINAttempts(ctx, sessionId)
incorrectAttempts, err := store.ReadEntry(ctx, sessionId, common.DATA_INCORRECT_PIN_ATTEMPTS)
if err != nil {
t.Logf(err.Error())
}
assert.Equal(t, "0", string(incorrectAttempts))
}

120
internal/http/at/parse.go Normal file
View File

@ -0,0 +1,120 @@
package at
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"git.grassecon.net/urdt/ussd/common"
"git.grassecon.net/urdt/ussd/internal/handlers"
)
type ATRequestParser struct {
}
func (arp *ATRequestParser) GetSessionId(ctx context.Context, rq any) (string, error) {
rqv, ok := rq.(*http.Request)
if !ok {
logg.Warnf("got an invalid request", "req", rq)
return "", handlers.ErrInvalidRequest
}
// Capture body (if any) for logging
body, err := io.ReadAll(rqv.Body)
if err != nil {
logg.Warnf("failed to read request body", "err", err)
return "", fmt.Errorf("failed to read request body: %v", err)
}
// Reset the body for further reading
rqv.Body = io.NopCloser(bytes.NewReader(body))
// Log the body as JSON
bodyLog := map[string]string{"body": string(body)}
logBytes, err := json.Marshal(bodyLog)
if err != nil {
logg.Warnf("failed to marshal request body", "err", err)
} else {
decodedStr := string(logBytes)
sessionId, err := extractATSessionId(decodedStr)
if err != nil {
ctx = context.WithValue(ctx, "AT-SessionId", sessionId)
}
logg.DebugCtxf(ctx, "Received request:", decodedStr)
}
if err := rqv.ParseForm(); err != nil {
logg.Warnf("failed to parse form data", "err", err)
return "", fmt.Errorf("failed to parse form data: %v", err)
}
phoneNumber := rqv.FormValue("phoneNumber")
if phoneNumber == "" {
return "", fmt.Errorf("no phone number found")
}
formattedNumber, err := common.FormatPhoneNumber(phoneNumber)
if err != nil {
logg.Warnf("failed to format phone number", "err", err)
return "", fmt.Errorf("failed to format number")
}
return formattedNumber, nil
}
func (arp *ATRequestParser) GetInput(rq any) ([]byte, error) {
rqv, ok := rq.(*http.Request)
if !ok {
return nil, handlers.ErrInvalidRequest
}
if err := rqv.ParseForm(); err != nil {
return nil, fmt.Errorf("failed to parse form data: %v", err)
}
text := rqv.FormValue("text")
parts := strings.Split(text, "*")
if len(parts) == 0 {
return nil, fmt.Errorf("no input found")
}
trimmedInput := strings.TrimSpace(parts[len(parts)-1])
return []byte(trimmedInput), nil
}
func parseQueryParams(query string) map[string]string {
params := make(map[string]string)
queryParams := strings.Split(query, "&")
for _, param := range queryParams {
// Split each key-value pair by '='
parts := strings.SplitN(param, "=", 2)
if len(parts) == 2 {
params[parts[0]] = parts[1]
}
}
return params
}
func extractATSessionId(decodedStr string) (string, error) {
var data map[string]string
err := json.Unmarshal([]byte(decodedStr), &data)
if err != nil {
logg.Errorf("Error unmarshalling JSON: %v", err)
return "", nil
}
decodedBody, err := url.QueryUnescape(data["body"])
if err != nil {
logg.Errorf("Error URL-decoding body: %v", err)
return "", nil
}
params := parseQueryParams(decodedBody)
sessionId := params["sessionId"]
return sessionId, nil
}

View File

@ -1,19 +1,25 @@
package http package at
import ( import (
"io" "io"
"net/http" "net/http"
"git.defalsify.org/vise.git/logging"
"git.grassecon.net/urdt/ussd/internal/handlers" "git.grassecon.net/urdt/ussd/internal/handlers"
httpserver "git.grassecon.net/urdt/ussd/internal/http"
)
var (
logg = logging.NewVanilla().WithDomain("atserver").WithContextKey("SessionId").WithContextKey("AT-SessionId")
) )
type ATSessionHandler struct { type ATSessionHandler struct {
*SessionHandler *httpserver.SessionHandler
} }
func NewATSessionHandler(h handlers.RequestHandler) *ATSessionHandler { func NewATSessionHandler(h handlers.RequestHandler) *ATSessionHandler {
return &ATSessionHandler{ return &ATSessionHandler{
SessionHandler: ToSessionHandler(h), SessionHandler: httpserver.ToSessionHandler(h),
} }
} }
@ -28,17 +34,17 @@ func (ash *ATSessionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request)
rp := ash.GetRequestParser() rp := ash.GetRequestParser()
cfg := ash.GetConfig() cfg := ash.GetConfig()
cfg.SessionId, err = rp.GetSessionId(req) cfg.SessionId, err = rp.GetSessionId(req.Context(), req)
if err != nil { if err != nil {
logg.ErrorCtxf(rqs.Ctx, "", "header processing error", err) logg.ErrorCtxf(rqs.Ctx, "", "header processing error", err)
ash.writeError(w, 400, err) ash.WriteError(w, 400, err)
return return
} }
rqs.Config = cfg rqs.Config = cfg
rqs.Input, err = rp.GetInput(req) rqs.Input, err = rp.GetInput(req)
if err != nil { if err != nil {
logg.ErrorCtxf(rqs.Ctx, "", "header processing error", err) logg.ErrorCtxf(rqs.Ctx, "", "header processing error", err)
ash.writeError(w, 400, err) ash.WriteError(w, 400, err)
return return
} }
@ -53,7 +59,7 @@ func (ash *ATSessionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request)
} }
if code != 200 { if code != 200 {
ash.writeError(w, 500, err) ash.WriteError(w, 500, err)
return return
} }
@ -61,13 +67,13 @@ func (ash *ATSessionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request)
w.Header().Set("Content-Type", "text/plain") w.Header().Set("Content-Type", "text/plain")
rqs, err = ash.Output(rqs) rqs, err = ash.Output(rqs)
if err != nil { if err != nil {
ash.writeError(w, 500, err) ash.WriteError(w, 500, err)
return return
} }
rqs, err = ash.Reset(rqs) rqs, err = ash.Reset(rqs)
if err != nil { if err != nil {
ash.writeError(w, 500, err) ash.WriteError(w, 500, err)
return return
} }
} }

View File

@ -1,7 +1,6 @@
package http package at
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"io" "io"
@ -16,16 +15,6 @@ import (
"git.grassecon.net/urdt/ussd/internal/testutil/mocks/httpmocks" "git.grassecon.net/urdt/ussd/internal/testutil/mocks/httpmocks"
) )
// invalidRequestType is a custom type to test invalid request scenarios
type invalidRequestType struct{}
// errorReader is a helper type that always returns an error when Read is called
type errorReader struct{}
func (e *errorReader) Read(p []byte) (n int, err error) {
return 0, errors.New("read error")
}
func TestNewATSessionHandler(t *testing.T) { func TestNewATSessionHandler(t *testing.T) {
mockHandler := &httpmocks.MockRequestHandler{} mockHandler := &httpmocks.MockRequestHandler{}
ash := NewATSessionHandler(mockHandler) ash := NewATSessionHandler(mockHandler)
@ -242,208 +231,4 @@ func TestATSessionHandler_Output(t *testing.T) {
} }
} }
func TestSessionHandler_ServeHTTP(t *testing.T) {
tests := []struct {
name string
sessionID string
input []byte
parserErr error
processErr error
outputErr error
resetErr error
expectedStatus int
}{
{
name: "Success",
sessionID: "123",
input: []byte("test input"),
expectedStatus: http.StatusOK,
},
{
name: "Missing Session ID",
sessionID: "",
parserErr: handlers.ErrSessionMissing,
expectedStatus: http.StatusBadRequest,
},
{
name: "Process Error",
sessionID: "123",
input: []byte("test input"),
processErr: handlers.ErrStorage,
expectedStatus: http.StatusInternalServerError,
},
{
name: "Output Error",
sessionID: "123",
input: []byte("test input"),
outputErr: errors.New("output error"),
expectedStatus: http.StatusOK,
},
{
name: "Reset Error",
sessionID: "123",
input: []byte("test input"),
resetErr: errors.New("reset error"),
expectedStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockRequestParser := &httpmocks.MockRequestParser{
GetSessionIdFunc: func(any) (string, error) {
return tt.sessionID, tt.parserErr
},
GetInputFunc: func(any) ([]byte, error) {
return tt.input, nil
},
}
mockRequestHandler := &httpmocks.MockRequestHandler{
ProcessFunc: func(rs handlers.RequestSession) (handlers.RequestSession, error) {
return rs, tt.processErr
},
OutputFunc: func(rs handlers.RequestSession) (handlers.RequestSession, error) {
return rs, tt.outputErr
},
ResetFunc: func(rs handlers.RequestSession) (handlers.RequestSession, error) {
return rs, tt.resetErr
},
GetRequestParserFunc: func() handlers.RequestParser {
return mockRequestParser
},
GetConfigFunc: func() engine.Config {
return engine.Config{}
},
}
sessionHandler := ToSessionHandler(mockRequestHandler)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(tt.input))
req.Header.Set("X-Vise-Session", tt.sessionID)
rr := httptest.NewRecorder()
sessionHandler.ServeHTTP(rr, req)
if status := rr.Code; status != tt.expectedStatus {
t.Errorf("handler returned wrong status code: got %v want %v",
status, tt.expectedStatus)
}
})
}
}
func TestSessionHandler_writeError(t *testing.T) {
handler := &SessionHandler{}
mockWriter := &httpmocks.MockWriter{}
err := errors.New("test error")
handler.writeError(mockWriter, http.StatusBadRequest, err)
if mockWriter.WrittenString != "" {
t.Errorf("Expected empty body, got %s", mockWriter.WrittenString)
}
}
func TestDefaultRequestParser_GetSessionId(t *testing.T) {
tests := []struct {
name string
request any
expectedID string
expectedError error
}{
{
name: "Valid Session ID",
request: func() *http.Request {
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("X-Vise-Session", "123456")
return req
}(),
expectedID: "123456",
expectedError: nil,
},
{
name: "Missing Session ID",
request: httptest.NewRequest(http.MethodPost, "/", nil),
expectedID: "",
expectedError: handlers.ErrSessionMissing,
},
{
name: "Invalid Request Type",
request: invalidRequestType{},
expectedID: "",
expectedError: handlers.ErrInvalidRequest,
},
}
parser := &DefaultRequestParser{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
id, err := parser.GetSessionId(tt.request)
if id != tt.expectedID {
t.Errorf("Expected session ID %s, got %s", tt.expectedID, id)
}
if err != tt.expectedError {
t.Errorf("Expected error %v, got %v", tt.expectedError, err)
}
})
}
}
func TestDefaultRequestParser_GetInput(t *testing.T) {
tests := []struct {
name string
request any
expectedInput []byte
expectedError error
}{
{
name: "Valid Input",
request: func() *http.Request {
return httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString("test input"))
}(),
expectedInput: []byte("test input"),
expectedError: nil,
},
{
name: "Empty Input",
request: httptest.NewRequest(http.MethodPost, "/", nil),
expectedInput: []byte{},
expectedError: nil,
},
{
name: "Invalid Request Type",
request: invalidRequestType{},
expectedInput: nil,
expectedError: handlers.ErrInvalidRequest,
},
{
name: "Read Error",
request: func() *http.Request {
return httptest.NewRequest(http.MethodPost, "/", &errorReader{})
}(),
expectedInput: nil,
expectedError: errors.New("read error"),
},
}
parser := &DefaultRequestParser{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input, err := parser.GetInput(tt.request)
if !bytes.Equal(input, tt.expectedInput) {
t.Errorf("Expected input %s, got %s", tt.expectedInput, input)
}
if err != tt.expectedError && (err == nil || err.Error() != tt.expectedError.Error()) {
t.Errorf("Expected error %v, got %v", tt.expectedError, err)
}
})
}
}

37
internal/http/parse.go Normal file
View File

@ -0,0 +1,37 @@
package http
import (
"context"
"io/ioutil"
"net/http"
"git.grassecon.net/urdt/ussd/internal/handlers"
)
type DefaultRequestParser struct {
}
func (rp *DefaultRequestParser) GetSessionId(ctx context.Context, rq any) (string, error) {
rqv, ok := rq.(*http.Request)
if !ok {
return "", handlers.ErrInvalidRequest
}
v := rqv.Header.Get("X-Vise-Session")
if v == "" {
return "", handlers.ErrSessionMissing
}
return v, nil
}
func (rp *DefaultRequestParser) GetInput(rq any) ([]byte, error) {
rqv, ok := rq.(*http.Request)
if !ok {
return nil, handlers.ErrInvalidRequest
}
defer rqv.Body.Close()
v, err := ioutil.ReadAll(rqv.Body)
if err != nil {
return nil, err
}
return v, nil
}

View File

@ -1,7 +1,6 @@
package http package http
import ( import (
"io/ioutil"
"net/http" "net/http"
"strconv" "strconv"
@ -14,35 +13,6 @@ var (
logg = logging.NewVanilla().WithDomain("httpserver") logg = logging.NewVanilla().WithDomain("httpserver")
) )
type DefaultRequestParser struct {
}
func(rp *DefaultRequestParser) GetSessionId(rq any) (string, error) {
rqv, ok := rq.(*http.Request)
if !ok {
return "", handlers.ErrInvalidRequest
}
v := rqv.Header.Get("X-Vise-Session")
if v == "" {
return "", handlers.ErrSessionMissing
}
return v, nil
}
func(rp *DefaultRequestParser) GetInput(rq any) ([]byte, error) {
rqv, ok := rq.(*http.Request)
if !ok {
return nil, handlers.ErrInvalidRequest
}
defer rqv.Body.Close()
v, err := ioutil.ReadAll(rqv.Body)
if err != nil {
return nil, err
}
return v, nil
}
type SessionHandler struct { type SessionHandler struct {
handlers.RequestHandler handlers.RequestHandler
} }
@ -53,19 +23,18 @@ func ToSessionHandler(h handlers.RequestHandler) *SessionHandler {
} }
} }
func(f *SessionHandler) writeError(w http.ResponseWriter, code int, err error) { func (f *SessionHandler) WriteError(w http.ResponseWriter, code int, err error) {
s := err.Error() s := err.Error()
w.Header().Set("Content-Length", strconv.Itoa(len(s))) w.Header().Set("Content-Length", strconv.Itoa(len(s)))
w.WriteHeader(code) w.WriteHeader(code)
_, err = w.Write([]byte{}) _, err = w.Write([]byte(s))
if err != nil { if err != nil {
logg.Errorf("error writing error!!", "err", err, "olderr", s) logg.Errorf("error writing error!!", "err", err, "olderr", s)
w.WriteHeader(500) w.WriteHeader(500)
} }
return
} }
func(f *SessionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { func (f *SessionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
var code int var code int
var err error var err error
var perr error var perr error
@ -77,16 +46,16 @@ func(f *SessionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
rp := f.GetRequestParser() rp := f.GetRequestParser()
cfg := f.GetConfig() cfg := f.GetConfig()
cfg.SessionId, err = rp.GetSessionId(req) cfg.SessionId, err = rp.GetSessionId(req.Context(), req)
if err != nil { if err != nil {
logg.ErrorCtxf(rqs.Ctx, "", "header processing error", err) logg.ErrorCtxf(rqs.Ctx, "", "header processing error", err)
f.writeError(w, 400, err) f.WriteError(w, 400, err)
} }
rqs.Config = cfg rqs.Config = cfg
rqs.Input, err = rp.GetInput(req) rqs.Input, err = rp.GetInput(req)
if err != nil { if err != nil {
logg.ErrorCtxf(rqs.Ctx, "", "header processing error", err) logg.ErrorCtxf(rqs.Ctx, "", "header processing error", err)
f.writeError(w, 400, err) f.WriteError(w, 400, err)
return return
} }
@ -103,7 +72,7 @@ func(f *SessionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
} }
if code != 200 { if code != 200 {
f.writeError(w, 500, err) f.WriteError(w, 500, err)
return return
} }
@ -112,11 +81,11 @@ func(f *SessionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
rqs, err = f.Output(rqs) rqs, err = f.Output(rqs)
rqs, perr = f.Reset(rqs) rqs, perr = f.Reset(rqs)
if err != nil { if err != nil {
f.writeError(w, 500, err) f.WriteError(w, 500, err)
return return
} }
if perr != nil { if perr != nil {
f.writeError(w, 500, perr) f.WriteError(w, 500, perr)
return return
} }
} }

View File

@ -0,0 +1,230 @@
package http
import (
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"git.defalsify.org/vise.git/engine"
"git.grassecon.net/urdt/ussd/internal/handlers"
"git.grassecon.net/urdt/ussd/internal/testutil/mocks/httpmocks"
)
// invalidRequestType is a custom type to test invalid request scenarios
type invalidRequestType struct{}
// errorReader is a helper type that always returns an error when Read is called
type errorReader struct{}
func (e *errorReader) Read(p []byte) (n int, err error) {
return 0, errors.New("read error")
}
func TestSessionHandler_ServeHTTP(t *testing.T) {
tests := []struct {
name string
sessionID string
input []byte
parserErr error
processErr error
outputErr error
resetErr error
expectedStatus int
}{
{
name: "Success",
sessionID: "123",
input: []byte("test input"),
expectedStatus: http.StatusOK,
},
{
name: "Missing Session ID",
sessionID: "",
parserErr: handlers.ErrSessionMissing,
expectedStatus: http.StatusBadRequest,
},
{
name: "Process Error",
sessionID: "123",
input: []byte("test input"),
processErr: handlers.ErrStorage,
expectedStatus: http.StatusInternalServerError,
},
{
name: "Output Error",
sessionID: "123",
input: []byte("test input"),
outputErr: errors.New("output error"),
expectedStatus: http.StatusOK,
},
{
name: "Reset Error",
sessionID: "123",
input: []byte("test input"),
resetErr: errors.New("reset error"),
expectedStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockRequestParser := &httpmocks.MockRequestParser{
GetSessionIdFunc: func(any) (string, error) {
return tt.sessionID, tt.parserErr
},
GetInputFunc: func(any) ([]byte, error) {
return tt.input, nil
},
}
mockRequestHandler := &httpmocks.MockRequestHandler{
ProcessFunc: func(rs handlers.RequestSession) (handlers.RequestSession, error) {
return rs, tt.processErr
},
OutputFunc: func(rs handlers.RequestSession) (handlers.RequestSession, error) {
return rs, tt.outputErr
},
ResetFunc: func(rs handlers.RequestSession) (handlers.RequestSession, error) {
return rs, tt.resetErr
},
GetRequestParserFunc: func() handlers.RequestParser {
return mockRequestParser
},
GetConfigFunc: func() engine.Config {
return engine.Config{}
},
}
sessionHandler := ToSessionHandler(mockRequestHandler)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(tt.input))
req.Header.Set("X-Vise-Session", tt.sessionID)
rr := httptest.NewRecorder()
sessionHandler.ServeHTTP(rr, req)
if status := rr.Code; status != tt.expectedStatus {
t.Errorf("handler returned wrong status code: got %v want %v",
status, tt.expectedStatus)
}
})
}
}
func TestSessionHandler_WriteError(t *testing.T) {
handler := &SessionHandler{}
mockWriter := &httpmocks.MockWriter{}
err := errors.New("test error")
handler.WriteError(mockWriter, http.StatusBadRequest, err)
if mockWriter.WrittenString != "" {
t.Errorf("Expected empty body, got %s", mockWriter.WrittenString)
}
}
func TestDefaultRequestParser_GetSessionId(t *testing.T) {
tests := []struct {
name string
request any
expectedID string
expectedError error
}{
{
name: "Valid Session ID",
request: func() *http.Request {
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("X-Vise-Session", "123456")
return req
}(),
expectedID: "123456",
expectedError: nil,
},
{
name: "Missing Session ID",
request: httptest.NewRequest(http.MethodPost, "/", nil),
expectedID: "",
expectedError: handlers.ErrSessionMissing,
},
{
name: "Invalid Request Type",
request: invalidRequestType{},
expectedID: "",
expectedError: handlers.ErrInvalidRequest,
},
}
parser := &DefaultRequestParser{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
id, err := parser.GetSessionId(context.Background(),tt.request)
if id != tt.expectedID {
t.Errorf("Expected session ID %s, got %s", tt.expectedID, id)
}
if err != tt.expectedError {
t.Errorf("Expected error %v, got %v", tt.expectedError, err)
}
})
}
}
func TestDefaultRequestParser_GetInput(t *testing.T) {
tests := []struct {
name string
request any
expectedInput []byte
expectedError error
}{
{
name: "Valid Input",
request: func() *http.Request {
return httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString("test input"))
}(),
expectedInput: []byte("test input"),
expectedError: nil,
},
{
name: "Empty Input",
request: httptest.NewRequest(http.MethodPost, "/", nil),
expectedInput: []byte{},
expectedError: nil,
},
{
name: "Invalid Request Type",
request: invalidRequestType{},
expectedInput: nil,
expectedError: handlers.ErrInvalidRequest,
},
{
name: "Read Error",
request: func() *http.Request {
return httptest.NewRequest(http.MethodPost, "/", &errorReader{})
}(),
expectedInput: nil,
expectedError: errors.New("read error"),
},
}
parser := &DefaultRequestParser{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input, err := parser.GetInput(tt.request)
if !bytes.Equal(input, tt.expectedInput) {
t.Errorf("Expected input %s, got %s", tt.expectedInput, input)
}
if err != tt.expectedError && (err == nil || err.Error() != tt.expectedError.Error()) {
t.Errorf("Expected error %v, got %v", tt.expectedError, err)
}
})
}
}

65
internal/ssh/keystore.go Normal file
View File

@ -0,0 +1,65 @@
package ssh
import (
"context"
"fmt"
"os"
"path"
"golang.org/x/crypto/ssh"
"git.defalsify.org/vise.git/db"
"git.grassecon.net/urdt/ussd/internal/storage"
dbstorage "git.grassecon.net/urdt/ussd/internal/storage/db/gdbm"
)
type SshKeyStore struct {
store db.Db
}
func NewSshKeyStore(ctx context.Context, dbDir string) (*SshKeyStore, error) {
keyStore := &SshKeyStore{}
keyStoreFile := path.Join(dbDir, "ssh_authorized_keys.gdbm")
keyStore.store = dbstorage.NewThreadGdbmDb()
err := keyStore.store.Connect(ctx, keyStoreFile)
if err != nil {
return nil, err
}
return keyStore, nil
}
func(s *SshKeyStore) AddFromFile(ctx context.Context, fp string, sessionId string) error {
_, err := os.Stat(fp)
if err != nil {
return fmt.Errorf("cannot open ssh server public key file: %v\n", err)
}
publicBytes, err := os.ReadFile(fp)
if err != nil {
return fmt.Errorf("Failed to load public key: %v", err)
}
pubKey, _, _, _, err := ssh.ParseAuthorizedKey(publicBytes)
if err != nil {
return fmt.Errorf("Failed to parse public key: %v", err)
}
k := append([]byte{0x01}, pubKey.Marshal()...)
s.store.SetPrefix(storage.DATATYPE_EXTEND)
logg.Infof("Added key", "sessionId", sessionId, "public key", string(publicBytes))
return s.store.Put(ctx, k, []byte(sessionId))
}
func(s *SshKeyStore) Get(ctx context.Context, pubKey ssh.PublicKey) (string, error) {
s.store.SetLanguage(nil)
s.store.SetPrefix(storage.DATATYPE_EXTEND)
k := append([]byte{0x01}, pubKey.Marshal()...)
v, err := s.store.Get(ctx, k)
if err != nil {
return "", err
}
return string(v), nil
}
func(s *SshKeyStore) Close() error {
return s.store.Close()
}

287
internal/ssh/ssh.go Normal file
View File

@ -0,0 +1,287 @@
package ssh
import (
"context"
"encoding/hex"
"encoding/base64"
"errors"
"fmt"
"net"
"os"
"sync"
"golang.org/x/crypto/ssh"
"git.defalsify.org/vise.git/engine"
"git.defalsify.org/vise.git/logging"
"git.defalsify.org/vise.git/resource"
"git.defalsify.org/vise.git/state"
"git.grassecon.net/urdt/ussd/internal/handlers"
"git.grassecon.net/urdt/ussd/internal/storage"
"git.grassecon.net/urdt/ussd/remote"
)
var (
logg = logging.NewVanilla().WithDomain("ssh")
)
type auther struct {
Ctx context.Context
keyStore *SshKeyStore
auth map[string]string
}
func NewAuther(ctx context.Context, keyStore *SshKeyStore) *auther {
return &auther{
Ctx: ctx,
keyStore: keyStore,
auth: make(map[string]string),
}
}
func(a *auther) Check(conn ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) {
va, err := a.keyStore.Get(a.Ctx, pubKey)
if err != nil {
return nil, err
}
ka := hex.EncodeToString(conn.SessionID())
a.auth[ka] = va
fmt.Fprintf(os.Stderr, "connect: %s -> %s\n", ka, va)
return nil, nil
}
func(a *auther) FromConn(c *ssh.ServerConn) (string, error) {
if c == nil {
return "", errors.New("nil server conn")
}
if c.Conn == nil {
return "", errors.New("nil underlying conn")
}
return a.Get(c.Conn.SessionID())
}
func(a *auther) Get(k []byte) (string, error) {
ka := hex.EncodeToString(k)
v, ok := a.auth[ka]
if !ok {
return "", errors.New("not found")
}
return v, nil
}
func(s *SshRunner) serve(ctx context.Context, sessionId string, ch ssh.NewChannel, en engine.Engine) error {
if ch == nil {
return errors.New("nil channel")
}
if ch.ChannelType() != "session" {
ch.Reject(ssh.UnknownChannelType, "that is not the channel you are looking for")
return errors.New("not a session")
}
channel, requests, err := ch.Accept()
if err != nil {
panic(err)
}
defer channel.Close()
s.wg.Add(1)
go func(reqIn <-chan *ssh.Request) {
defer s.wg.Done()
for req := range reqIn {
req.Reply(req.Type == "shell", nil)
}
_ = requests
}(requests)
cont, err := en.Exec(ctx, []byte{})
if err != nil {
return fmt.Errorf("initial engine exec err: %v", err)
}
var input [state.INPUT_LIMIT]byte
for cont {
c, err := en.Flush(ctx, channel)
if err != nil {
return fmt.Errorf("flush err: %v", err)
}
_, err = channel.Write([]byte{0x0a})
if err != nil {
return fmt.Errorf("newline err: %v", err)
}
c, err = channel.Read(input[:])
if err != nil {
return fmt.Errorf("read input fail: %v", err)
}
logg.TraceCtxf(ctx, "input read", "c", c, "input", input[:c-1])
cont, err = en.Exec(ctx, input[:c-1])
if err != nil {
return fmt.Errorf("engine exec err: %v", err)
}
logg.TraceCtxf(ctx, "exec cont", "cont", cont, "en", en)
_ = c
}
c, err := en.Flush(ctx, channel)
if err != nil {
return fmt.Errorf("last flush err: %v", err)
}
_ = c
return nil
}
type SshRunner struct {
Ctx context.Context
Cfg engine.Config
FlagFile string
DbDir string
ResourceDir string
Debug bool
SrvKeyFile string
Host string
Port uint
wg sync.WaitGroup
lst net.Listener
}
func(s *SshRunner) Stop() error {
return s.lst.Close()
}
func(s *SshRunner) GetEngine(sessionId string) (engine.Engine, func(), error) {
ctx := s.Ctx
menuStorageService := storage.NewMenuStorageService(s.DbDir, s.ResourceDir)
err := menuStorageService.EnsureDbDir()
if err != nil {
return nil, nil, err
}
rs, err := menuStorageService.GetResource(ctx)
if err != nil {
return nil, nil, err
}
pe, err := menuStorageService.GetPersister(ctx)
if err != nil {
return nil, nil, err
}
userdatastore, err := menuStorageService.GetUserdataDb(ctx)
if err != nil {
return nil, nil, err
}
dbResource, ok := rs.(*resource.DbResource)
if !ok {
return nil, nil, err
}
lhs, err := handlers.NewLocalHandlerService(ctx, s.FlagFile, true, dbResource, s.Cfg, rs)
lhs.SetDataStore(&userdatastore)
lhs.SetPersister(pe)
lhs.Cfg.SessionId = sessionId
if err != nil {
return nil, nil, err
}
// TODO: clear up why pointer here and by-value other cmds
accountService := &remote.AccountService{}
hl, err := lhs.GetHandler(accountService)
if err != nil {
return nil, nil, err
}
en := lhs.GetEngine()
en = en.WithFirst(hl.Init)
if s.Debug {
en = en.WithDebug(nil)
}
// TODO: this is getting very hacky!
closer := func() {
err := menuStorageService.Close()
if err != nil {
logg.ErrorCtxf(ctx, "menu storage service cleanup fail", "err", err)
}
}
return en, closer, nil
}
// adapted example from crypto/ssh package, NewServerConn doc
func(s *SshRunner) Run(ctx context.Context, keyStore *SshKeyStore) {
running := true
// TODO: waitgroup should probably not be global
defer s.wg.Wait()
auth := NewAuther(ctx, keyStore)
cfg := ssh.ServerConfig{
PublicKeyCallback: auth.Check,
}
privateBytes, err := os.ReadFile(s.SrvKeyFile)
if err != nil {
logg.ErrorCtxf(ctx, "Failed to load private key", "err", err)
}
private, err := ssh.ParsePrivateKey(privateBytes)
if err != nil {
logg.ErrorCtxf(ctx, "Failed to parse private key", "err", err)
}
srvPub := private.PublicKey()
srvPubStr := base64.StdEncoding.EncodeToString(srvPub.Marshal())
logg.InfoCtxf(ctx, "have server key", "type", srvPub.Type(), "public", srvPubStr)
cfg.AddHostKey(private)
s.lst, err = net.Listen("tcp", fmt.Sprintf("%s:%d", s.Host, s.Port))
if err != nil {
panic(err)
}
for running {
conn, err := s.lst.Accept()
if err != nil {
logg.ErrorCtxf(ctx, "ssh accept error", "err", err)
running = false
continue
}
go func(conn net.Conn) {
defer conn.Close()
for true {
srvConn, nC, rC, err := ssh.NewServerConn(conn, &cfg)
if err != nil {
logg.InfoCtxf(ctx, "rejected client", "err", err)
return
}
logg.DebugCtxf(ctx, "ssh client connected", "conn", srvConn)
s.wg.Add(1)
go func() {
ssh.DiscardRequests(rC)
s.wg.Done()
}()
sessionId, err := auth.FromConn(srvConn)
if err != nil {
logg.ErrorCtxf(ctx, "Cannot find authentication")
return
}
en, closer, err := s.GetEngine(sessionId)
if err != nil {
logg.ErrorCtxf(ctx, "engine won't start", "err", err)
return
}
defer func() {
err := en.Finish()
if err != nil {
logg.ErrorCtxf(ctx, "engine won't stop", "err", err)
}
closer()
}()
for ch := range nC {
err = s.serve(ctx, sessionId, ch, en)
logg.ErrorCtxf(ctx, "ssh server finish", "err", err)
}
}
}(conn)
}
}

View File

@ -6,6 +6,11 @@ import (
"git.defalsify.org/vise.git/db" "git.defalsify.org/vise.git/db"
gdbmdb "git.defalsify.org/vise.git/db/gdbm" gdbmdb "git.defalsify.org/vise.git/db/gdbm"
"git.defalsify.org/vise.git/lang" "git.defalsify.org/vise.git/lang"
"git.defalsify.org/vise.git/logging"
)
var (
logg = logging.NewVanilla().WithDomain("gdbmstorage")
) )
var ( var (
@ -115,7 +120,8 @@ func(tdb *ThreadGdbmDb) Close() error {
return err return err
} }
func(tdb *ThreadGdbmDb) Dump(_ context.Context, _ []byte) (*db.Dumper, error) { func(tdb *ThreadGdbmDb) Dump(ctx context.Context, key []byte) (*db.Dumper, error) {
logg.Warnf("method not implemented for thread gdbm db") tdb.reserve()
return nil, nil defer tdb.release()
return tdb.db.Dump(ctx, key)
} }

View File

@ -5,6 +5,10 @@ import (
"git.defalsify.org/vise.git/persist" "git.defalsify.org/vise.git/persist"
) )
const (
DATATYPE_EXTEND = 128
)
type Storage struct { type Storage struct {
Persister *persist.Persister Persister *persist.Persister
UserdataDb db.Db UserdataDb db.Db

View File

@ -9,10 +9,12 @@ import (
"git.defalsify.org/vise.git/db" "git.defalsify.org/vise.git/db"
fsdb "git.defalsify.org/vise.git/db/fs" fsdb "git.defalsify.org/vise.git/db/fs"
"git.defalsify.org/vise.git/db/postgres" "git.defalsify.org/vise.git/db/postgres"
"git.defalsify.org/vise.git/lang"
"git.defalsify.org/vise.git/logging" "git.defalsify.org/vise.git/logging"
"git.defalsify.org/vise.git/persist" "git.defalsify.org/vise.git/persist"
"git.defalsify.org/vise.git/resource" "git.defalsify.org/vise.git/resource"
"git.grassecon.net/urdt/ussd/initializers" "git.grassecon.net/urdt/ussd/initializers"
gdbmstorage "git.grassecon.net/urdt/ussd/internal/storage/db/gdbm"
) )
var ( var (
@ -29,6 +31,7 @@ type StorageService interface {
type MenuStorageService struct { type MenuStorageService struct {
dbDir string dbDir string
resourceDir string resourceDir string
poResource resource.Resource
resourceStore db.Db resourceStore db.Db
stateStore db.Db stateStore db.Db
userDataStore db.Db userDataStore db.Db
@ -57,6 +60,28 @@ func NewMenuStorageService(dbDir string, resourceDir string) *MenuStorageService
} }
} }
// 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) getOrCreateDb(ctx context.Context, existingDb db.Db, fileName string) (db.Db, error) { func (ms *MenuStorageService) getOrCreateDb(ctx context.Context, existingDb db.Db, fileName string) (db.Db, error) {
database, ok := ctx.Value("Database").(string) database, ok := ctx.Value("Database").(string)
if !ok { if !ok {
@ -75,7 +100,7 @@ func (ms *MenuStorageService) getOrCreateDb(ctx context.Context, existingDb db.D
connStr := buildConnStr() connStr := buildConnStr()
err = newDb.Connect(ctx, connStr) err = newDb.Connect(ctx, connStr)
} else { } else {
newDb = NewThreadGdbmDb() newDb = gdbmstorage.NewThreadGdbmDb()
storeFile := path.Join(ms.dbDir, fileName) storeFile := path.Join(ms.dbDir, fileName)
err = newDb.Connect(ctx, storeFile) err = newDb.Connect(ctx, storeFile)
} }
@ -119,6 +144,11 @@ func (ms *MenuStorageService) GetResource(ctx context.Context) (resource.Resourc
return nil, err return nil, err
} }
rfs := resource.NewDbResource(ms.resourceStore) 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 return rfs, nil
} }

View File

@ -1,12 +1,14 @@
package httpmocks package httpmocks
import "context"
// MockRequestParser implements the handlers.RequestParser interface for testing // MockRequestParser implements the handlers.RequestParser interface for testing
type MockRequestParser struct { type MockRequestParser struct {
GetSessionIdFunc func(any) (string, error) GetSessionIdFunc func(any) (string, error)
GetInputFunc func(any) ([]byte, error) GetInputFunc func(any) ([]byte, error)
} }
func (m *MockRequestParser) GetSessionId(rq any) (string, error) { func (m *MockRequestParser) GetSessionId(ctx context.Context, rq any) (string, error) {
return m.GetSessionIdFunc(rq) return m.GetSessionIdFunc(rq)
} }

View File

@ -3,7 +3,7 @@ package utils
var isoCodes = map[string]bool{ var isoCodes = map[string]bool{
"eng": true, // English "eng": true, // English
"swa": true, // Swahili "swa": true, // Swahili
"default": true, // Default language: English
} }
func IsValidISO639(code string) bool { func IsValidISO639(code string) bool {

View File

@ -54,7 +54,7 @@
}, },
{ {
"input": "1235", "input": "1235",
"expectedContent": "Incorrect PIN\n1:Retry\n9:Quit" "expectedContent": "Incorrect PIN. You have: 2 remaining attempt(s).\n1:Retry\n9:Quit"
}, },
{ {
"input": "1", "input": "1",
@ -62,10 +62,10 @@
}, },
{ {
"input": "1234", "input": "1234",
"expectedContent": "Select language:\n0:English\n1:Kiswahili" "expectedContent": "Select language:\n1:English\n2:Kiswahili"
}, },
{ {
"input": "0", "input": "1",
"expectedContent": "Your language change request was successful.\n0:Back\n9:Quit" "expectedContent": "Your language change request was successful.\n0:Back\n9:Quit"
}, },
{ {
@ -95,7 +95,7 @@
}, },
{ {
"input": "1235", "input": "1235",
"expectedContent": "Incorrect PIN\n1:Retry\n9:Quit" "expectedContent": "Incorrect PIN. You have: 2 remaining attempt(s).\n1:Retry\n9:Quit"
}, },
{ {
"input": "1", "input": "1",
@ -108,7 +108,6 @@
{ {
"input": "0", "input": "0",
"expectedContent": "Balances:\n1:My balance\n2:Community balance\n0:Back" "expectedContent": "Balances:\n1:My balance\n2:Community balance\n0:Back"
}, },
{ {
"input": "0", "input": "0",
@ -141,7 +140,7 @@
}, },
{ {
"input": "1235", "input": "1235",
"expectedContent": "Incorrect PIN\n1:Retry\n9:Quit" "expectedContent": "Incorrect PIN. You have: 2 remaining attempt(s).\n1:Retry\n9:Quit"
}, },
{ {
"input": "1", "input": "1",
@ -154,7 +153,6 @@
{ {
"input": "0", "input": "0",
"expectedContent": "Balances:\n1:My balance\n2:Community balance\n0:Back" "expectedContent": "Balances:\n1:My balance\n2:Community balance\n0:Back"
}, },
{ {
"input": "0", "input": "0",
@ -258,7 +256,6 @@
"input": "0", "input": "0",
"expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit"
} }
] ]
}, },
{ {
@ -430,7 +427,7 @@
}, },
{ {
"input": "1234", "input": "1234",
"expectedContent": "My profile:\nName: foo bar\nGender: male\nAge: 84\nLocation: Kilifi\nYou provide: Bananas\n\n0:Back" "expectedContent": "My profile:\nName: foo bar\nGender: male\nAge: 80\nLocation: Kilifi\nYou provide: Bananas\n\n0:Back\n9:Quit"
}, },
{ {
"input": "0", "input": "0",
@ -444,9 +441,3 @@
} }
] ]
} }

View File

@ -298,9 +298,10 @@ func TestMainMenuSend(t *testing.T) {
ctx := context.Background() ctx := context.Background()
sessions := testData sessions := testData
for _, session := range sessions { for _, session := range sessions {
groups := driver.FilterGroupsByName(session.Groups, "send_with_invalid_inputs") groups := driver.FilterGroupsByName(session.Groups, "send_with_invite")
for _, group := range groups { for _, group := range groups {
for _, step := range group.Steps { for index, step := range group.Steps {
t.Logf("step %v with input %v", index, step.Input)
cont, err := en.Exec(ctx, []byte(step.Input)) cont, err := en.Exec(ctx, []byte(step.Input))
if err != nil { if err != nil {
t.Fatalf("Test case '%s' failed at input '%s': %v", group.Name, step.Input, err) t.Fatalf("Test case '%s' failed at input '%s': %v", group.Name, step.Input, err)

View File

@ -7,14 +7,14 @@
"steps": [ "steps": [
{ {
"input": "", "input": "",
"expectedContent": "Welcome to Sarafu Network\nPlease select a language\n0:English\n1:Kiswahili" "expectedContent": "Welcome to Sarafu Network\nPlease select a language\n1:English\n2:Kiswahili"
}, },
{ {
"input": "0", "input": "1",
"expectedContent": "Do you agree to terms and conditions?\n0:Yes\n1:No" "expectedContent": "Do you agree to terms and conditions?\nhttps://grassecon.org/pages/terms-and-conditions\n\n1:Yes\n2:No"
}, },
{ {
"input": "0", "input": "1",
"expectedContent": "Please enter a new four number PIN for your account:\n0:Exit" "expectedContent": "Please enter a new four number PIN for your account:\n0:Exit"
}, },
{ {
@ -40,14 +40,14 @@
"steps": [ "steps": [
{ {
"input": "", "input": "",
"expectedContent": "Welcome to Sarafu Network\nPlease select a language\n0:English\n1:Kiswahili" "expectedContent": "Welcome to Sarafu Network\nPlease select a language\n1:English\n2:Kiswahili"
},
{
"input": "0",
"expectedContent": "Do you agree to terms and conditions?\n0:Yes\n1:No"
}, },
{ {
"input": "1", "input": "1",
"expectedContent": "Do you agree to terms and conditions?\nhttps://grassecon.org/pages/terms-and-conditions\n\n1:Yes\n2:No"
},
{
"input": "2",
"expectedContent": "Thank you for using Sarafu. Goodbye!" "expectedContent": "Thank you for using Sarafu. Goodbye!"
} }
] ]
@ -64,8 +64,8 @@
"expectedContent": "Enter recipient's phone number/address/alias:\n0:Back" "expectedContent": "Enter recipient's phone number/address/alias:\n0:Back"
}, },
{ {
"input": "000", "input": "0@0",
"expectedContent": "000 is invalid, please try again:\n1:Retry\n9:Quit" "expectedContent": "0@0 is invalid, please try again:\n1:Retry\n9:Quit"
}, },
{ {
"input": "1", "input": "1",

View File

@ -7,3 +7,4 @@ HALT
INCMP _ 0 INCMP _ 0
INCMP my_balance 1 INCMP my_balance 1
INCMP community_balance 2 INCMP community_balance 2
INCMP . *

View File

@ -0,0 +1,2 @@
LOAD show_blocked_account 0
HALT

View File

@ -2,9 +2,9 @@ LOAD reset_account_authorized 0
LOAD reset_incorrect 0 LOAD reset_incorrect 0
CATCH incorrect_pin flag_incorrect_pin 1 CATCH incorrect_pin flag_incorrect_pin 1
CATCH pin_entry flag_account_authorized 0 CATCH pin_entry flag_account_authorized 0
MOUT english 0 MOUT english 1
MOUT kiswahili 1 MOUT kiswahili 2
HALT HALT
INCMP set_default 0 INCMP set_eng 1
INCMP set_swa 1 INCMP set_swa 2
INCMP . * INCMP . *

View File

@ -9,3 +9,4 @@ MOUT quit 9
HALT HALT
INCMP _ 0 INCMP _ 0
INCMP quit 9 INCMP quit 9
INCMP . *

View File

@ -1,2 +1,2 @@
Jina la kwanza la sasa {{.get_current_profile_info}} Jina la kwanza la sasa: {{.get_current_profile_info}}
Weka majina yako ya kwanza: Weka majina yako ya kwanza:

View File

@ -1,2 +1,2 @@
Eneo la sasa {{.get_current_profile_info}} Eneo la sasa: {{.get_current_profile_info}}
Weka eneo: Weka eneo:

View File

@ -10,5 +10,4 @@ CATCH _ flag_back_set 1
RELOAD save_offerings RELOAD save_offerings
INCMP _ 0 INCMP _ 0
CATCH pin_entry flag_offerings_set 1 CATCH pin_entry flag_offerings_set 1
CATCH pin_entry flag_offerings_set 0
INCMP update_profile_items * INCMP update_profile_items *

View File

@ -20,3 +20,4 @@ INCMP edit_yob 4
INCMP edit_location 5 INCMP edit_location 5
INCMP edit_offerings 6 INCMP edit_offerings 6
INCMP view_profile 7 INCMP view_profile 7
INCMP . *

View File

@ -1,2 +1,2 @@
Mwaka wa sasa wa kuzaliwa {{.get_current_profile_info}} Mwaka wa sasa wa kuzaliwa: {{.get_current_profile_info}}
Weka mwaka wa kuzaliwa Weka mwaka wa kuzaliwa

View File

@ -1 +1 @@
Incorrect PIN Incorrect PIN. You have: {{.reset_incorrect}} remaining attempt(s).

View File

@ -1,5 +1,7 @@
LOAD reset_incorrect 0 LOAD reset_incorrect 0
RELOAD reset_incorrect RELOAD reset_incorrect
MAP reset_incorrect
CATCH blocked_account flag_account_blocked 1
MOUT retry 1 MOUT retry 1
MOUT quit 9 MOUT quit 9
HALT HALT

View File

@ -1 +1 @@
PIN ulioeka sio sahihi PIN ulioeka sio sahihi, una majaribio: {{.reset_incorrect}} yaliyobaki

View File

@ -7,8 +7,11 @@ msgstr "Ombi lako limetumwa. %s atapokea %s %s kutoka kwa %s."
msgid "Thank you for using Sarafu. Goodbye!" msgid "Thank you for using Sarafu. Goodbye!"
msgstr "Asante kwa kutumia huduma ya Sarafu. Kwaheri!" msgstr "Asante kwa kutumia huduma ya Sarafu. Kwaheri!"
msgid "For more help,please call: 0757628885" msgid "For more help, please call: 0757628885"
msgstr "Kwa usaidizi zaidi,piga: 0757628885" msgstr "Kwa usaidizi zaidi, piga: 0757628885"
msgid "Your account has been locked. For help on how to unblock your account, contact support at: 0757628885"
msgstr "Akaunti yako imefungwa. Kwa usaidizi wa jinsi ya kufungua akaunti yako, wasiliana na usaidizi kwa: 0757628885"
msgid "Balance: %s\n" msgid "Balance: %s\n"
msgstr "Salio: %s\n" msgstr "Salio: %s\n"

View File

@ -14,3 +14,4 @@ INCMP balances 3
INCMP check_statement 4 INCMP check_statement 4
INCMP pin_management 5 INCMP pin_management 5
INCMP address 6 INCMP address 6
INCMP . *

View File

@ -9,3 +9,4 @@ MOUT quit 9
HALT HALT
INCMP _ 0 INCMP _ 0
INCMP quit 9 INCMP quit 9
INCMP . *

View File

@ -28,3 +28,5 @@ flag,flag_gender_set,34,this is set when the gender of the profile is set
flag,flag_location_set,35,this is set when the location of the profile is set flag,flag_location_set,35,this is set when the location of the profile is set
flag,flag_offerings_set,36,this is set when the offerings of the profile is set flag,flag_offerings_set,36,this is set when the offerings of the profile is set
flag,flag_back_set,37,this is set when it is a back navigation flag,flag_back_set,37,this is set when it is a back navigation
flag,flag_account_blocked,38,this is set when an account has been blocked after the allowed incorrect PIN attempts have been exceeded

1 flag flag_language_set 8 checks whether the user has set their prefered language
28 flag flag_location_set 35 this is set when the location of the profile is set
29 flag flag_offerings_set 36 this is set when the offerings of the profile is set
30 flag flag_back_set 37 this is set when it is a back navigation
31 flag flag_account_blocked 38 this is set when an account has been blocked after the allowed incorrect PIN attempts have been exceeded
32

View File

@ -1,3 +1,4 @@
CATCH blocked_account flag_account_blocked 1
CATCH select_language flag_language_set 0 CATCH select_language flag_language_set 0
CATCH terms flag_account_created 0 CATCH terms flag_account_created 0
LOAD check_account_status 0 LOAD check_account_status 0

View File

@ -11,3 +11,4 @@ INCMP _ 0
INCMP set_male 1 INCMP set_male 1
INCMP set_female 2 INCMP set_female 2
INCMP set_unspecified 3 INCMP set_unspecified 3
INCMP . *

View File

@ -1,2 +1,2 @@
Jinsia ya sasa {{.get_current_profile_info}} Jinsia ya sasa: {{.get_current_profile_info}}
Chagua jinsia Chagua jinsia

View File

@ -1,6 +1,6 @@
MOUT english 0 MOUT english 1
MOUT kiswahili 1 MOUT kiswahili 2
HALT HALT
INCMP set_eng 0 INCMP set_eng 1
INCMP set_swa 1 INCMP set_swa 2
INCMP . * INCMP . *

View File

@ -0,0 +1,4 @@
LOAD set_language 6
RELOAD set_language
CATCH terms flag_account_created 0
MOVE language_changed

View File

@ -1 +1,2 @@
Do you agree to terms and conditions? Do you agree to terms and conditions?
https://grassecon.org/pages/terms-and-conditions

View File

@ -1,5 +1,5 @@
MOUT yes 0 MOUT yes 1
MOUT no 1 MOUT no 2
HALT HALT
INCMP create_pin 0 INCMP create_pin 1
INCMP quit * INCMP quit *

View File

@ -1 +1,2 @@
Kwa kutumia hii huduma umekubali sheria na masharti? Kwa kutumia hii huduma umekubali sheria na masharti?
https://grassecon.org/pages/terms-and-conditions

View File

@ -4,5 +4,8 @@ LOAD reset_incorrect 6
CATCH incorrect_pin flag_incorrect_pin 1 CATCH incorrect_pin flag_incorrect_pin 1
CATCH pin_entry flag_account_authorized 0 CATCH pin_entry flag_account_authorized 0
MOUT back 0 MOUT back 0
MOUT quit 9
HALT HALT
INCMP _ 0 INCMP _ 0
INCMP quit 9
INCMP . *