diff --git a/.gitignore b/.gitignore index b66bc5d..e95fee3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ install.sh config-*.yml !config-test.yml data +.vscode/launch.json \ No newline at end of file diff --git a/.vscode/launch-tpl.json b/.vscode/launch-tpl.json new file mode 100644 index 0000000..d750ce6 --- /dev/null +++ b/.vscode/launch-tpl.json @@ -0,0 +1,12 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to Process", + "type": "go", + "request": "attach", + "mode": "local", + "processId": 0 + } + ] +} \ No newline at end of file diff --git a/Makefile b/Makefile index c190fd1..7fa7f22 100644 --- a/Makefile +++ b/Makefile @@ -22,8 +22,9 @@ build: webroot $(COMMANDSDIST) dev: while true; do \ kill $$(cat .server.pid); \ - make build && \ + make GOGCFLAGS='all=-N -l' build && \ (dist/server -c config-dev.yml & echo $$! > .server.pid); \ + jq " .configurations[0].processId = $$(cat .server.pid) " .vscode/launch-tpl.json > .vscode/launch.json; \ inotifywait -e modify -r webroot internal server config && kill $(cat .server.pid) ; \ done @@ -45,6 +46,7 @@ dist/%: ${CMD_DIR}/% FORCE go build -buildvcs\ -ldflags "-X ${MODULE_PATH}/internal/version.tagVersion=$(VERSION) \ -X ${MODULE_PATH}/internal/version.buildDate=$(BUILDDATE)" \ + -gcflags "$(GOGCFLAGS)" \ -o $@ ${MODULE_PATH}/$< .PHONY: build clean diff --git a/cmd/compile/compile.go b/cmd/compile/compile.go deleted file mode 100644 index 9f64989..0000000 --- a/cmd/compile/compile.go +++ /dev/null @@ -1,16 +0,0 @@ -package main - -import ( - htmlTemplate "html/template" - "log" - "os" -) - -func main() { - - htmlTpl := htmlTemplate.Must(htmlTemplate.ParseGlob("*.tpl.html")) - for _, tpl := range htmlTpl.Templates() { - log.Printf("template: %s", tpl.Name()) - tpl.Execute(os.Stdout, nil) - } -} diff --git a/config/config.go b/config/config.go index c136dd3..e04f872 100644 --- a/config/config.go +++ b/config/config.go @@ -60,6 +60,7 @@ type C struct { Password string PublicKeyId []string Roles []string + Telegram string } Yubikey struct { ClientId string diff --git a/config/fs.go b/config/fs.go index aff96ea..a337341 100644 --- a/config/fs.go +++ b/config/fs.go @@ -2,4 +2,5 @@ package config type FileStore struct { BasePath string + Serve bool } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 1a04d33..6538697 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -3,15 +3,19 @@ package auth import ( "crypto/rand" "crypto/subtle" + "encoding/json" "errors" "fmt" "log" "net/http" + "strconv" "strings" "time" "github.com/alexedwards/argon2id" "github.com/eternal-flame-AD/yoake/config" + "github.com/eternal-flame-AD/yoake/internal/auth/tglogin" + "github.com/eternal-flame-AD/yoake/internal/db" "github.com/eternal-flame-AD/yoake/internal/echoerror" "github.com/eternal-flame-AD/yoake/internal/util" "github.com/gorilla/sessions" @@ -23,6 +27,16 @@ const AuthSessionName = "auth_session" var dummyHash string +func authSessionOptions() *sessions.Options { + return &sessions.Options{ + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Secure: config.Config().Listen.Ssl.Use, + MaxAge: config.Config().Auth.ValidMinutes * 60 * 5, + } +} + func init() { var dummyPassword [16]byte _, err := rand.Read(dummyPassword[:]) @@ -40,6 +54,13 @@ type RequestAuth struct { Valid bool Roles []string Expire time.Time + Ident UserIdent +} + +type UserIdent struct { + Username string `json:"username"` + PhotoURL string `json:"photo_url"` + DisplayName string `json:"display_name"` } func (a RequestAuth) HasRole(role Role) bool { @@ -99,13 +120,7 @@ func Middleware(store sessions.Store) echo.MiddlewareFunc { return func(c echo.Context) error { sess, _ := store.Get(c.Request(), AuthSessionName) - sess.Options = &sessions.Options{ - Path: "/", - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - Secure: config.Config().Listen.Ssl.Use, - MaxAge: config.Config().Auth.ValidMinutes * 60 * 5, - } + sess.Options = authSessionOptions() var auth RequestAuth if expireTs, ok := sess.Values["expire"].(string); ok { @@ -130,6 +145,15 @@ func Middleware(store sessions.Store) echo.MiddlewareFunc { c.Set("devel", true) } + if existingIdentJSON, ok := sess.Values["ident"].([]byte); !ok { + sess.Values["ident"] = []byte("{}") + sess.Save(c.Request(), c.Response()) + } else if auth.Valid { + if err := json.Unmarshal(existingIdentJSON, &auth.Ident); err != nil { + log.Printf("invalid ident: %v", existingIdentJSON) + } + } + c.Set("auth_"+AuthSessionName, auth) c.Set("auth_store", store) @@ -138,15 +162,20 @@ func Middleware(store sessions.Store) echo.MiddlewareFunc { } } +func issueIdent(c echo.Context, ident UserIdent) error { + sess, _ := c.Get("auth_store").(sessions.Store).Get(c.Request(), AuthSessionName) + sess.Options = authSessionOptions() + identJSON, err := json.Marshal(ident) + if err != nil { + return err + } + sess.Values["ident"] = identJSON + return sess.Save(c.Request(), c.Response()) +} + func issueSession(c echo.Context, period time.Duration, roles []string) error { sess, _ := c.Get("auth_store").(sessions.Store).Get(c.Request(), AuthSessionName) - sess.Options = &sessions.Options{ - Path: "/", - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - Secure: config.Config().Listen.Ssl.Use, - MaxAge: config.Config().Auth.ValidMinutes * 60 * 5, - } + sess.Options = authSessionOptions() if period == 0 { period = time.Duration(config.Config().Auth.ValidMinutes) * time.Minute } @@ -169,7 +198,7 @@ type LoginForm struct { var errInvalidUserPass = echoerror.NewHttp(http.StatusUnauthorized, errors.New("invalid username or password")) -func Register(g *echo.Group) (err error) { +func Register(g *echo.Group, database db.DB) (err error) { g.GET("/auth.json", func(c echo.Context) error { return c.JSON(http.StatusOK, GetRequestAuth(c)) }) @@ -182,6 +211,35 @@ func Register(g *echo.Group) (err error) { loginRateLimiter := middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ Store: loginRateLimiterStore, }) + g.POST("/login_tg", func(c echo.Context) error { + tgForm, err := tglogin.VerifyLoginCallback(c, database) + if err != nil { + return err + } + if tgForm.ID == 0 { + return echoerror.NewHttp(http.StatusUnauthorized, errors.New("invalid telegram login")) + } + roles := []string{string(RoleTelgram)} + for _, user := range config.Config().Auth.Users { + if user.Telegram == "@"+tgForm.UserName || + user.Telegram == strconv.FormatUint(tgForm.ID, 10) { + { + roles = append(roles, user.Roles...) + } + } + } + if err := issueSession(c, 0, roles); err != nil { + return fmt.Errorf("failed to issue session: %v", err) + } + if err := issueIdent(c, UserIdent{ + Username: "@" + tgForm.UserName, + PhotoURL: tgForm.PhotoURL, + DisplayName: strings.TrimSpace(tgForm.FirstName + " " + tgForm.LastName), + }); err != nil { + return fmt.Errorf("failed to issue ident: %v", err) + } + return c.JSON(http.StatusOK, GetRequestAuth(c)) + }) g.POST("/login", func(c echo.Context) error { var form LoginForm if err := c.Bind(&form); err != nil { @@ -228,7 +286,15 @@ func Register(g *echo.Group) (err error) { return echo.NewHTTPError(http.StatusBadRequest, "otp not required but you provided one, this may be an configuration error") } - issueSession(c, 0, user.Roles) + if err := issueSession(c, 0, user.Roles); err != nil { + return fmt.Errorf("failed to issue session: %w", err) + } + if err := issueIdent(c, UserIdent{ + Username: form.Username, + DisplayName: form.Username, + }); err != nil { + return fmt.Errorf("failed to issue identification: %w", err) + } c.JSON(http.StatusOK, map[string]interface{}{"message": "ok", "ok": true}) return nil } else { diff --git a/internal/auth/gob.go b/internal/auth/gob.go new file mode 100644 index 0000000..0a62a0e --- /dev/null +++ b/internal/auth/gob.go @@ -0,0 +1,7 @@ +package auth + +import "encoding/gob" + +func init() { + gob.Register(UserIdent{}) +} diff --git a/internal/auth/role.go b/internal/auth/role.go index edef395..b91b0db 100644 --- a/internal/auth/role.go +++ b/internal/auth/role.go @@ -3,6 +3,7 @@ package auth type Role string const ( - RoleAdmin Role = "admin" - RoleUser Role = "user" + RoleAdmin Role = "admin" + RoleUser Role = "user" + RoleTelgram Role = "tg" ) diff --git a/internal/auth/tglogin/verify.go b/internal/auth/tglogin/verify.go new file mode 100644 index 0000000..6a99252 --- /dev/null +++ b/internal/auth/tglogin/verify.go @@ -0,0 +1,93 @@ +package tglogin + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/json" + "fmt" + "sort" + "time" + + "github.com/eternal-flame-AD/yoake/config" + "github.com/eternal-flame-AD/yoake/internal/db" + "github.com/labstack/echo/v4" +) + +type LoginCallbackForm struct { + ID uint64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + UserName string `json:"username"` + PhotoURL string `json:"photo_url"` + AuthDate uint64 `json:"auth_date"` +} + +type verifyForm map[string]interface{} + +func sha256Str(input string) []byte { + h := sha256.New() + h.Write([]byte(input)) + return h.Sum(nil) +} + +func VerifyLoginCallback(c echo.Context, database db.DB) (*LoginCallbackForm, error) { + txn := database.NewTransaction(true) + defer txn.Discard() + tgToken := config.Config().Comm.Telegram.Token + if tgToken == "" { + return nil, fmt.Errorf("telegram token not set") + } + verifyForm := make(verifyForm) + if err := c.Bind(&verifyForm); err != nil { + return nil, err + } + verifyKeys := make([]string, 0, len(verifyForm)) + for k := range verifyForm { + if k != "hash" { + verifyKeys = append(verifyKeys, k) + } + } + sort.Strings(verifyKeys) + hmac := hmac.New(sha256.New, sha256Str(tgToken)) + for i, k := range verifyKeys { + if i != 0 { + hmac.Write([]byte("\n")) + } + switch val := verifyForm[k].(type) { + case string: + fmt.Fprintf(hmac, "%s=%s", k, val) + case float64: + fmt.Fprintf(hmac, "%s=%v", k, uint64(val)) + default: + return nil, fmt.Errorf("invalid verify form, unexpected type %T", val) + } + } + if fmt.Sprintf("%x", hmac.Sum(nil)) != verifyForm["hash"] { + return nil, echo.NewHTTPError(403, "invalid hash") + } + + form := new(LoginCallbackForm) + remarshaled, err := json.Marshal(verifyForm) + if err != nil { + return nil, err + } + if err := json.Unmarshal(remarshaled, form); err != nil { + return nil, err + } + + lastAuthDateDBKey := fmt.Sprintf("auth_telegram_last_auth_date_%d", form.ID) + var dbLastAuthDate uint64 + if err := db.GetJSON(txn, []byte(lastAuthDateDBKey), &dbLastAuthDate); db.IsNotFound(err) { + dbLastAuthDate = 0 + } else if err != nil { + return nil, err + } + if form.AuthDate <= dbLastAuthDate || time.Since(time.Unix(int64(form.AuthDate), 0)) > 10*time.Minute { + return nil, echo.NewHTTPError(403, "authentication payload expired") + } + if err := db.SetJSON(txn, []byte(lastAuthDateDBKey), form.AuthDate); err != nil { + return nil, err + } + + return form, nil +} diff --git a/internal/comm/telegram/bot.go b/internal/comm/telegram/bot.go index 8db64eb..03612d2 100644 --- a/internal/comm/telegram/bot.go +++ b/internal/comm/telegram/bot.go @@ -10,6 +10,7 @@ import ( "github.com/eternal-flame-AD/yoake/config" "github.com/eternal-flame-AD/yoake/internal/comm/model" "github.com/eternal-flame-AD/yoake/internal/db" + "github.com/eternal-flame-AD/yoake/internal/servetpl/funcmap" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" ) @@ -21,7 +22,7 @@ const ( RoleOwner Role = 2 ) -type CommandHandler func(bot *Bot, role Role, msg *tgbotapi.Message) error +type CommandHandler func(bot *Bot, role Role, update tgbotapi.Update) error type Bot struct { client *tgbotapi.BotAPI @@ -75,6 +76,10 @@ func (b *Bot) saveConf() error { return txn.Commit() } +func (b *Bot) Client() *tgbotapi.BotAPI { + return b.client +} + func (b *Bot) SendHTML(chatID int64, fmtStr string, args ...interface{}) error { for i := range args { switch v := args[i].(type) { @@ -90,32 +95,50 @@ func (b *Bot) SendHTML(chatID int64, fmtStr string, args ...interface{}) error { return err } func (b *Bot) handleUpdate(update tgbotapi.Update) error { - if update.Message == nil { - return nil - } - log.Printf("received message from %s: %v", update.Message.From.UserName, update.Message) - msg := *update.Message - conf := config.Config().Comm.Telegram - if strings.HasPrefix(conf.Owner, "@") && msg.From.UserName == conf.Owner[1:] { - log.Printf("telegram owner chat id set: %d", msg.Chat.ID) - b.OwnerChatID = msg.Chat.ID - } else if id, err := strconv.ParseInt(conf.Owner, 10, 64); err == nil && msg.From.ID == id || msg.Chat.ID == id { - if msg.Chat.ID != b.OwnerChatID { - log.Printf("telegram owner chat id set: %d", msg.Chat.ID) - b.OwnerChatID = msg.Chat.ID + + role := RoleAnonymous + if msg := update.Message; msg != nil { + log.Printf("received message from %s: %v", update.Message.From.UserName, update.Message) + msg := *update.Message + conf := config.Config().Comm.Telegram + if strings.HasPrefix(conf.Owner, "@") && msg.From.UserName == conf.Owner[1:] { + if msg.Chat.ID != b.OwnerChatID { + log.Printf("telegram owner chat id set: %d", msg.Chat.ID) + b.OwnerChatID = msg.Chat.ID + } + } else if id, err := strconv.ParseInt(conf.Owner, 10, 64); err == nil && msg.From.ID == id || msg.Chat.ID == id { + if msg.Chat.ID != b.OwnerChatID { + log.Printf("telegram owner chat id set: %d", msg.Chat.ID) + b.OwnerChatID = msg.Chat.ID + } + } + if msg.Chat.ID == b.OwnerChatID { + role = RoleOwner + } + if msg.IsCommand() { + if handler, ok := b.cmdHandlers[msg.Command()]; ok { + return handler(b, role, update) + } else { + if err := b.SendHTML(msg.Chat.ID, "unknown command: %s\n", msg.Command()); err != nil { + return err + } + } } } - role := RoleAnonymous - if msg.Chat.ID == b.OwnerChatID { - role = RoleOwner - } - if msg.IsCommand() { - if handler, ok := b.cmdHandlers[msg.Command()]; ok { - return handler(b, role, &msg) - } else { - if err := b.SendHTML(msg.Chat.ID, "unknown command: %s\n", msg.Command()); err != nil { - return err + if callback := update.CallbackQuery; callback != nil { + cb := tgbotapi.NewCallback(callback.ID, "") + defer b.client.Send(cb) + if msg := callback.Message; msg != nil { + if callbackMsg := msg.ReplyToMessage; callbackMsg != nil && callbackMsg.IsCommand() { + if handler, ok := b.cmdHandlers[callbackMsg.Command()]; ok { + log.Printf("callback for command %s", callbackMsg.Command()) + return handler(b, role, update) + } else { + if err := b.SendHTML(callback.Message.Chat.ID, "unknown command: %s\n", callback.Message.Command()); err != nil { + return err + } + } } } } @@ -127,8 +150,12 @@ func (bot *Bot) start() error { u := tgbotapi.NewUpdate(bot.LastUpdateID + 1) u.Timeout = 60 - bot.RegisterCommand("start", "onboarding command", func(bot *Bot, role Role, msg *tgbotapi.Message) error { - bot.client.Send(tgbotapi.NewMessage(msg.Chat.ID, strings.ReplaceAll(banner, "{name}", msg.From.FirstName+" "+msg.From.LastName))) + bot.RegisterCommand("start", "onboarding command", func(bot *Bot, role Role, upd tgbotapi.Update) error { + if msg := upd.Message; msg != nil { + if _, err := bot.client.Send(tgbotapi.NewMessage(msg.Chat.ID, strings.ReplaceAll(banner, "{name}", msg.From.FirstName+" "+msg.From.LastName))); err != nil { + return err + } + } return nil }) @@ -136,10 +163,19 @@ func (bot *Bot) start() error { go func() { for update := range updates { if err := bot.handleUpdate(update); err != nil { - if fid := update.FromChat().ID; fid != bot.OwnerChatID { - bot.SendHTML(fid, "RUNTIME ERROR\n
%s
\nBot owner has been notified.", err) + fromChat := update.FromChat() + if stopImgURL, err := funcmap.TrimaImg("ja/btn_stop.gif", "url"); err != nil { + log.Printf("failed to get stop image: %v", err) + } else { + stopPhoto := tgbotapi.NewPhoto(fromChat.ID, tgbotapi.FileURL(stopImgURL)) + stopPhoto.DisableNotification = true + bot.client.Send(stopPhoto) } - bot.SendHTML(bot.OwnerChatID, "RUNTIME ERROR\noriginating chat ID: %d (@%s)\n\n
%s
", update.FromChat().ID, update.FromChat().UserName, err) + if fromChat.ID != bot.OwnerChatID { + bot.SendHTML(fromChat.ID, "Runtime Error\n
%s
\nBot owner has been notified.", err) + + } + bot.SendHTML(bot.OwnerChatID, "Runtime Error\noriginating chat ID: %d (@%s)\n\n
%s
", update.FromChat().ID, update.FromChat().UserName, err) log.Printf("telegram runtime error: %v", err) } diff --git a/internal/health/api.go b/internal/health/api.go index a9512e0..6ad8412 100644 --- a/internal/health/api.go +++ b/internal/health/api.go @@ -1,6 +1,7 @@ package health import ( + "encoding/json" "fmt" "html" "log" @@ -20,118 +21,173 @@ import ( ) func telegramHandler(database db.DB) telegram.CommandHandler { - return func(bot *telegram.Bot, role telegram.Role, msg *tgbotapi.Message) error { - if role != telegram.RoleOwner { - bot.SendHTML(msg.Chat.ID, "EPERM: You are not authorized to use this command.") - return nil - } - switch msg.Command() { - case "medtake": - argStr := strings.TrimSpace(msg.CommandArguments()) - if len(argStr) == 0 || argStr == "help" { - bot.SendHTML(msg.Chat.ID, "Usage: /medtake [dose] [YYYY-MM-DDZHH:mm:ss]") - return nil - } - args := strings.Split(argStr, " ") - meds, err := DBMedListGet(database) - if err != nil { - return err - } - for _, med := range meds { - if med.KeyName() == strings.ToLower(args[0]) { - dose := med.Dosage - if len(args) > 1 { - dose, err = strconv.Atoi(args[1]) - if err != nil { + return func(bot *telegram.Bot, role telegram.Role, update tgbotapi.Update) error { + msg := update.Message + if callback := update.CallbackQuery; callback != nil { + msg = callback.Message.ReplyToMessage + switch msg.Command() { + case "medtake": + fields := strings.Fields(callback.Data) + if fields[0] == "" { + return nil + } + switch fields[0] { + case "medtake_confirm": + + if msg := callback.Message; msg != nil { + if _, err := bot.Client().Send(tgbotapi.NewEditMessageReplyMarkup(msg.Chat.ID, msg.MessageID, tgbotapi.InlineKeyboardMarkup{ + InlineKeyboard: [][]tgbotapi.InlineKeyboardButton{}, + })); err != nil { return err } } - timestamp := time.Now() - if len(args) > 2 { - timestamp, err = time.Parse("2006-01-02Z15:04:05", args[2]) - if err != nil { - return err - } + return nil + case "medtake_undo": + uuid := fields[1] + delDose := ComplianceLog{ + UUID: uuid, + Actual: ComplianceDoseInfo{ + Time: time.Now(), + Dose: -1, + }, } - var log ComplianceLog - log.MedKeyname = med.KeyName() - log.Actual.Time = timestamp - log.Actual.Dose = dose - if err := DBMedComplianceLogSetOne(database, med, &log); err != nil { + if err := DBMedComplianceLogSetOne(database, Direction{}, &delDose); err != nil { return err } - return bot.SendHTML(msg.Chat.ID, "Success.\n\nDose: %d/%d\nTime Actual: %s\nTime Expected: %s\n\nOffset: %.2f", log.Actual.Dose, log.Expected.Dose, - log.Actual.Time.Format("2006-01-02 15:04:05"), log.Expected.Time.Format("2006-01-02 15:04:05"), log.DoseOffset) + if err := bot.SendHTML(msg.Chat.ID, "Last action has been undone."); err != nil { + return err + } + if _, err := bot.Client().Request(tgbotapi.NewDeleteMessage(msg.Chat.ID, callback.Message.MessageID)); err != nil { + return err + } + return nil } } - return bot.SendHTML(msg.Chat.ID, "keyname %s not found", args[0]) - case "medinfo": - meds, err := DBMedListGet(database) - if err != nil { - return err - } - logs, err := DBMedComplianceLogGet(database, util.DateRangeAround(time.Now(), 1)) - if err != nil { - return err - } - argStr := strings.TrimSpace(msg.CommandArguments()) - if argStr == "help" { - bot.SendHTML(msg.Chat.ID, "Usage: /medinfo [[keyname...]|all]") + return nil + } else if msg != nil { + if role != telegram.RoleOwner { + bot.SendHTML(msg.Chat.ID, "EPERM: You are not authorized to use this command.") return nil } - all := true - keynames := strings.Split(msg.CommandArguments(), " ") - var replies []strings.Builder - if argStr == "" || argStr == "all" { - all = argStr == "all" - replies = make([]strings.Builder, len(meds)) - } else { - replies = make([]strings.Builder, len(keynames)) - } - - for i, med := range meds { - index := -1 - if argStr != "" && argStr != "all" { - for j, keyname := range keynames { - if med.KeyName() == keyname { - index = j - break + switch msg.Command() { + case "medtake": + argStr := strings.TrimSpace(msg.CommandArguments()) + if len(argStr) == 0 || argStr == "help" { + return bot.SendHTML(msg.Chat.ID, html.EscapeString("Usage: /medtake [dose] [YYYY-MM-DDZHH:mm:ss]")) + } + args := strings.Split(argStr, " ") + meds, err := DBMedListGet(database) + if err != nil { + return err + } + for _, med := range meds { + if med.KeyName() == strings.ToLower(args[0]) { + dose := med.Dosage + if len(args) > 1 { + dose, err = strconv.Atoi(args[1]) + if err != nil { + return err + } } + timestamp := time.Now() + if len(args) > 2 { + timestamp, err = time.Parse("2006-01-02Z15:04:05", args[2]) + if err != nil { + return err + } + } + var log ComplianceLog + log.MedKeyname = med.KeyName() + log.Actual.Time = timestamp + log.Actual.Dose = dose + if err := DBMedComplianceLogSetOne(database, med, &log); err != nil { + return err + } + logJSON, err := json.MarshalIndent(log, "", " ") + if err != nil { + return err + } + message := tgbotapi.NewMessage(msg.Chat.ID, fmt.Sprintf("Medication %s taken.
%s
", med.Name, logJSON)) + message.ChatID = msg.Chat.ID + message.ParseMode = tgbotapi.ModeHTML + message.ReplyToMessageID = msg.MessageID + message.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup([]tgbotapi.InlineKeyboardButton{ + tgbotapi.NewInlineKeyboardButtonData("Undo", fmt.Sprintf("medtake_undo %s", log.UUID)), + tgbotapi.NewInlineKeyboardButtonData("Confirm", "medtake_confirm"), + }) + _, err = bot.Client().Send(message) + return err } - if index == -1 { - continue - } + } + return bot.SendHTML(msg.Chat.ID, "keyname %s not found", args[0]) + case "medinfo": + meds, err := DBMedListGet(database) + if err != nil { + return err + } + logs, err := DBMedComplianceLogGet(database, util.DateRangeAround(time.Now(), 1)) + if err != nil { + return err + } + argStr := strings.TrimSpace(msg.CommandArguments()) + if argStr == "help" { + bot.SendHTML(msg.Chat.ID, "Usage: /medinfo [[keyname...]|all]") + return nil + } + all := true + keynames := strings.Split(msg.CommandArguments(), " ") + var replies []strings.Builder + if argStr == "" || argStr == "all" { + all = argStr == "all" + replies = make([]strings.Builder, len(meds)) } else { - index = i + replies = make([]strings.Builder, len(keynames)) } - name, dir := med.ShortHand() - fmt.Fprintf(&replies[index], "%s %s\n", html.EscapeString(name), html.EscapeString(dir)) - fmt.Fprintf(&replies[index], "Dosage: %d\n", med.Dosage) - nextDose := logs.ProjectNextDose(med) - fmt.Fprintf(&replies[index], "Next Dose: %d%s at %s\n", nextDose.Expected.Dose, html.EscapeString(med.DosageUnit), nextDose.Expected.Time.Format("2006-01-02 15:04:05")) - stateStr := "unknown" - if util.Contain(med.Flags, DirectionFlagPRN) && nextDose.DoseOffset >= 0 { - stateStr = "available" - } else if nextDose.DoseOffset > 0 { - stateStr = "DUE" - } else if util.Contain(med.Flags, DirectionFlagAdLib) { - stateStr = "available" - } else if nextDose.DoseOffset < 0 { - stateStr = "scheduled" + + for i, med := range meds { + index := -1 + if argStr != "" && argStr != "all" { + for j, keyname := range keynames { + if med.KeyName() == keyname { + index = j + break + } + } + if index == -1 { + continue + } + } else { + index = i + } + name, dir := med.ShortHand() + fmt.Fprintf(&replies[index], "%s %s\n", html.EscapeString(name), html.EscapeString(dir)) + nextDose := logs.ProjectNextDose(med) + stateStr := "unknown" + if util.Contain(med.Flags, DirectionFlagPRN) && nextDose.DoseOffset >= 0 { + stateStr = "available" + } else if nextDose.DoseOffset > 0 { + stateStr = "DUE" + } else if util.Contain(med.Flags, DirectionFlagAdLib) { + stateStr = "available" + } else if nextDose.DoseOffset < 0 { + stateStr = "scheduled" + } + fmt.Fprintf(&replies[index], "Status: %s\n", stateStr) + fmt.Fprintf(&replies[index], "Offset: %+.2f period, expected %.2f hrs (%s)\n", nextDose.DoseOffset, float64(time.Until(nextDose.Expected.Time))/float64(time.Hour), nextDose.Expected.Time.Format("2006-01-02 15:04:05")) + if !all && nextDose.DoseOffset < 0 { + replies[index].Reset() + } } - fmt.Fprintf(&replies[index], "Offset: %.2f (%s)\n", nextDose.DoseOffset, stateStr) - if !all && nextDose.DoseOffset < 0 { - replies[index].Reset() + var out strings.Builder + for _, reply := range replies { + if reply.Len() > 0 { + out.WriteString(reply.String()) + out.WriteString("\n") + } } + return bot.SendHTML(msg.Chat.ID, out.String()) } - var out strings.Builder - for _, reply := range replies { - if reply.Len() > 0 { - out.WriteString(reply.String()) - out.WriteString("\n") - } - } - return bot.SendHTML(msg.Chat.ID, out.String()) + return nil } return nil } diff --git a/internal/health/compliance.go b/internal/health/compliance.go index deb1d9b..7c90d23 100644 --- a/internal/health/compliance.go +++ b/internal/health/compliance.go @@ -2,7 +2,6 @@ package health import ( "encoding/json" - "log" "math" "sort" "time" @@ -148,7 +147,6 @@ func (c ComplianceLogList) UpdateDoseOffset(dir Direction) { } c[i].DoseOffset = f64OrNan(doseOffset(dir, thisDose, lastDose)) - log.Printf("thisDose: %+v, \nlastDose: %+v\n-->offset: %f\n", thisDose, lastDose, c[i].DoseOffset) } } } diff --git a/internal/health/db.go b/internal/health/db.go index 0b6e9b1..690d260 100644 --- a/internal/health/db.go +++ b/internal/health/db.go @@ -189,13 +189,19 @@ func DBMedComplianceLogSetOne(database db.DB, dir Direction, log *ComplianceLog) } logs.UpdateDoseOffset(dir) + + uuid := log.UUID sort.Sort(ComplianceLogList(logs)) if err := db.SetJSON(txn, []byte(dbMedComplianceLogPrefix+index), logs); err != nil { return err } - *log = logs[len(logs)-1] + for i, l := range logs { + if l.UUID == uuid { + *log = logs[i] + } + } return txn.Commit() diff --git a/internal/servetpl/funcmap/xml.go b/internal/servetpl/funcmap/escape.go similarity index 73% rename from internal/servetpl/funcmap/xml.go rename to internal/servetpl/funcmap/escape.go index 06ab05f..845e63b 100644 --- a/internal/servetpl/funcmap/xml.go +++ b/internal/servetpl/funcmap/escape.go @@ -3,6 +3,7 @@ package funcmap import ( "bytes" "encoding/xml" + "net/url" ) func EscapeXML(s string) (string, error) { @@ -12,3 +13,7 @@ func EscapeXML(s string) (string, error) { } return b.String(), nil } + +func EscapeQuery(s string) string { + return url.QueryEscape(s) +} diff --git a/internal/servetpl/funcmap/files.go b/internal/servetpl/funcmap/files.go new file mode 100644 index 0000000..36d05b5 --- /dev/null +++ b/internal/servetpl/funcmap/files.go @@ -0,0 +1,7 @@ +package funcmap + +import "net/url" + +func FileAccess(path string) string { + return "https://yoake.yumechi.jp/file_access.html?path=" + url.QueryEscape(path) +} diff --git a/internal/servetpl/funcmap/funcmap.go b/internal/servetpl/funcmap/funcmap.go index 803085c..1b1c3da 100644 --- a/internal/servetpl/funcmap/funcmap.go +++ b/internal/servetpl/funcmap/funcmap.go @@ -2,7 +2,6 @@ package funcmap import ( "fmt" - "log" "reflect" ) @@ -19,7 +18,6 @@ func Lookup(name string, args ...reflect.Value) (interface{}, error) { zero := reflect.ValueOf("") target := args[0] - log.Printf("lookup %s %v", name, target) if len(args) > 1 { zero = args[1] } @@ -60,9 +58,6 @@ func Invoke(name string, target reflect.Value, args ...reflect.Value) (any, erro } target = reflect.ValueOf(t) } - for i, arg := range args { - log.Printf("invoke %s arg[%d]=%v", name, i, arg) - } ret := target.Call(args) if len(ret) == 0 { return nil, nil @@ -71,9 +66,6 @@ func Invoke(name string, target reflect.Value, args ...reflect.Value) (any, erro if err, ok := ret[len(ret)-1].Interface().(error); ok && err != nil { return nil, err } - for i, r := range ret { - log.Printf("invoke %s ret[%d]=%v", name, i, r) - } switch len(ret) { case 0: @@ -96,25 +88,26 @@ func Void(args ...reflect.Value) string { func GetFuncMap() map[string]interface{} { return map[string]interface{}{ - "lookup": Lookup, - "invoke": Invoke, - "void": Void, - "get": FuncGet, - "set": FuncSet, - "math": Math, - "xml": EscapeXML, - "twilio_validate": TwilioValidate, - "stop": Stop, - "trima_img": TrimaImg, - "parse_json": ParseJSON, - "json": MarshalJSON, - "get_auth": AuthGet, + "lookup": Lookup, + "invoke": Invoke, + "void": Void, + "get": FuncGet, + "set": FuncSet, + "math": Math, + "xml": EscapeXML, + "stop": Stop, + "trima_img": TrimaImg, + "parse_json": ParseJSON, + "json": MarshalJSON, + "get_auth": AuthGet, "sprintf": func(format string, input ...interface{}) interface{} { return fmt.Sprintf(format, input...) }, - "http": HttpRequest, - "version": Version, - "find_word": FindWord, - "contain": Contain, + "http": HttpRequest, + "version": Version, + "find_word": FindWord, + "contain": Contain, + "query": EscapeQuery, + "theme_color": ThemeColor, } } diff --git a/internal/servetpl/funcmap/http.go b/internal/servetpl/funcmap/http.go index 5309674..65b1fe1 100644 --- a/internal/servetpl/funcmap/http.go +++ b/internal/servetpl/funcmap/http.go @@ -1,7 +1,7 @@ package funcmap import ( - "io/ioutil" + "io" "net/http" "github.com/PuerkitoBio/goquery" @@ -44,7 +44,7 @@ func HttpRequest(method string, URL string, selector string, responseType string } return result.Html() } - response, err := ioutil.ReadAll(resp.Body) + response, err := io.ReadAll(resp.Body) if err != nil { return nil, err } diff --git a/internal/servetpl/funcmap/nipponcolor.go b/internal/servetpl/funcmap/nipponcolor.go new file mode 100644 index 0000000..f40911b --- /dev/null +++ b/internal/servetpl/funcmap/nipponcolor.go @@ -0,0 +1,254 @@ +package funcmap + +var nipponColors = map[string]string{ + "nadeshiko": "#DC9F84", "撫子": "#DC9F84", + "kohbai": "#E16B8C", "紅梅": "#E16B8C", + "suoh": "#8E354A", "蘇芳": "#8E354A", + "taikoh": "#F8C3CD", "退紅": "#F8C3CD", + "ikkonzome": "#F4A7B9", "一斥染": "#F4A7B9", + "kuwazome": "#64363c", "桑染": "#64363c", + "momo": "#F596AA", "桃": "#F596AA", + "ichigo": "#B5495B", "莓": "#B5495B", + "usubeni": "#E87A90", "薄紅": "#E87A90", + "imayoh": "#D05A6E", "今様": "#D05A6E", + "nakabeni": "#DB4D6D", "中紅": "#DB4D6D", + "sakura": "#FEDFE1", "桜": "#FEDFE1", + "umenezumi": "#9E7A7A", "梅鼠": "#9E7A7A", + "karakurenai": "#D0104C", "韓紅花": "#D0104C", + "enji": "#9F353A", "燕脂": "#9F353A", + "kurenai": "#CB1B45", "紅": "#CB1B45", + "toki": "#EEA9A9", "鴇": "#EEA9A9", + "cyohsyun": "#BF6766", "長春": "#BF6766", + "kokiake": "#86473F", "深緋": "#86473F", + "sakuranezumi": "#B19693", "桜鼠": "#B19693", + "jinzamomi": "#EB7A77", "甚三紅": "#EB7A77", + "azuki": "#954A45", "小豆": "#954A45", + "suohkoh": "#A96360", "蘇芳香": "#A96360", + "akabeni": "#CB4042", "赤紅": "#CB4042", + "shinsyu": "#AB3B3A", "真朱": "#AB3B3A", + "haizakura": "#D7C4BB", "灰桜": "#D7C4BB", + "kuriume": "#904840", "栗梅": "#904840", + "ebicha": "#734338", "海老茶": "#734338", + "ginsyu": "#C73E3A", "銀朱": "#C73E3A", + "kurotobi": "#554236", "黒鳶": "#554236", + "benitobi": "#994639", "紅鳶": "#994639", + "akebono": "#F19483", "曙": "#F19483", + "benikaba": "#B54434", "紅樺": "#B54434", + "mizugaki": "#B9887D", "水がき": "#B9887D", + "sangosyu": "#F17C67", "珊瑚朱": "#F17C67", + "benihiwada": "#884C3A", "紅檜皮": "#884C3A", + "syojyohi": "#E83015", "猩猩緋": "#E83015", + "entan": "#D75455", "鉛丹": "#D75455", + "shikancha": "#B55D4C", "芝翫茶": "#B55D4C", + "hiwada": "#854836", "檜皮": "#854836", + "kakishibu": "#A35E47", "柿渋": "#A35E47", + "ake": "#CC543A", "緋": "#CC543A", + "tobi": "#724832", "鳶": "#724832", + "benihi": "#F75C2F", "紅緋": "#F75C2F", + "kurikawacha": "#6A4028", "栗皮茶": "#6A4028", + "bengara": "#9A5034", "弁柄": "#9A5034", + "terigaki": "#C46243", "照柿": "#C46243", + "edocha": "#AF5F3C", "江戸茶": "#AF5F3C", + "araisyu": "#FB966E", "洗朱": "#FB966E", + "momoshiocha": "#724938", "百塩茶": "#724938", + "karacha": "#B47157", "唐茶": "#B47157", + "tokigaracha": "#DB8E71", "ときがら茶": "#DB8E71", + "ohni": "#F05E1C", "黄丹": "#F05E1C", + "sohi": "#ED784A", "纁": "#ED784A", + "ensyucha": "#CA7853", "遠州茶": "#CA7853", + "kabacha": "#B35C37", "樺茶": "#B35C37", + "kogecha": "#563F2E", "焦茶": "#563F2E", + "akakoh": "#E3916E", "赤香": "#E3916E", + "suzumecha": "#8F5A3C", "雀茶": "#8F5A3C", + "shishi": "#F0A986", "宍": "#F0A986", + "sodenkaracha": "#A0674B", "宗伝唐茶": "#A0674B", + "kaba": "#c1693c", "樺": "#c1693c", + "kokikuchinashi": "#FB9966", "深支子": "#FB9966", + "kurumi": "#947A6D", "胡桃": "#947A6D", + "taisya": "#A36336", "代赭": "#A36336", + "araigaki": "#E79460", "洗柿": "#E79460", + "kohrozen": "#7D532C", "黄櫨染": "#7D532C", + "akakuchiba": "#C78550", "赤朽葉": "#C78550", + "tonocha": "#985F2A", "礪茶": "#985F2A", + "akashiritsurubami": "#E1A679", "赤白橡": "#E1A679", + "sencha": "#855B32", "煎茶": "#855B32", + "kanzo": "#FC9F4D", "萱草": "#FC9F4D", + "sharegaki": "#FFBA84", "洒落柿": "#FFBA84", + "beniukon": "#E98B2A", "紅鬱金": "#E98B2A", + "umezome": "#E9A368", "梅染": "#E9A368", + "biwacha": "#B17844", "枇杷茶": "#B17844", + "chojicha": "#96632E", "丁子茶": "#96632E", + "kenpohzome": "#43341B", "憲法染": "#43341B", + "kohaku": "#CA7A2C", "琥珀": "#CA7A2C", + "usugaki": "#ECB88A", "薄柿": "#ECB88A", + "kyara": "#78552B", "伽羅": "#78552B", + "chojizome": "#B07736", "丁子染": "#B07736", + "fushizome": "#967249", "柴染": "#967249", + "kuchiba": "#E2943B", "朽葉": "#E2943B", + "kincha": "#C7802D", "金茶": "#C7802D", + "kitsune": "#9B6E23", "狐": "#9B6E23", + "susutake": "#6E552F", "煤竹": "#6E552F", + "usukoh": "#EBB471", "薄香": "#EBB471", + "tonoko": "#D7B98E", "砥粉": "#D7B98E", + "ginsusutake": "#82663A", "銀煤竹": "#82663A", + "ohdo": "#B68E55", "黄土": "#B68E55", + "shiracha": "#BC9F77", "白茶": "#BC9F77", + "kobicha": "#876633", "媚茶": "#876633", + "kigaracha": "#C18A26", "黄唐茶": "#C18A26", + "yamabuki": "#FFB11B", "山吹": "#FFB11B", + "yamabukicha": "#D19826", "山吹茶": "#D19826", + "hajizome": "#DDA52D", "櫨染": "#DDA52D", + "kuwacha": "#C99833", "桑茶": "#C99833", + "tamago": "#F9BF45", "玉子": "#F9BF45", + "shirotsurubami": "#DCB879", "白橡": "#DCB879", + "kitsurubami": "#BA9132", "黄橡": "#BA9132", + "tamamorokoshi": "#E8B647", "玉蜀黍": "#E8B647", + "hanaba": "#F7C242", "花葉": "#F7C242", + "namakabe": "#7D6C46", "生壁": "#7D6C46", + "torinoko": "#DAC9A6", "鳥の子": "#DAC9A6", + "usuki": "#FAD689", "浅黄": "#FAD689", + "kikuchiba": "#D9AB42", "黄朽葉": "#D9AB42", + "kuchinashi": "#F6C555", "梔子": "#F6C555", + "tohoh": "#FFC408", "籐黄": "#FFC408", + "ukon": "#EFBB24", "鬱金": "#EFBB24", + "karashi": "#CAAD5F", "芥子": "#CAAD5F", + "higosusutake": "#8D742A", "肥後煤竹": "#8D742A", + "rikyushiracha": "#B4A582", "利休白茶": "#B4A582", + "aku": "#877F6C", "灰汁": "#877F6C", + "rikyucha": "#897D55", "利休茶": "#897D55", + "rokohcha": "#74673E", "路考茶": "#74673E", + "nataneyu": "#A28C37", "菜種油": "#A28C37", + "uguisucha": "#6C6024", "鶯茶": "#6C6024", + "kimirucha": "#867835", "黄海松茶": "#867835", + "mirucha": "#62592C", "海松茶": "#62592C", + "kariyasu": "#E9CD4C", "刈安": "#E9CD4C", + "nanohana": "#F7D94C", "菜の花": "#F7D94C", + "kihada": "#FBE251", "黄蘗": "#FBE251", + "mushikuri": "#D9CD90", "蒸栗": "#D9CD90", + "aokuchiba": "#ADA142", "青朽葉": "#ADA142", + "ominaeshi": "#DDD23B", "女郎花": "#DDD23B", + "hiwacha": "#A5A051", "鶸茶": "#A5A051", + "hiwa": "#BEC23F", "鶸": "#BEC23F", + "uguisu": "#6C6A2D", "鶯": "#6C6A2D", + "yanagicha": "#939650", "柳茶": "#939650", + "koke": "#838A2D", "苔": "#838A2D", + "kikujin": "#B1B479", "麹塵": "#B1B479", + "rikancha": "#616138", "璃寛茶": "#616138", + "aikobicha": "#4B4E2A", "藍媚茶": "#4B4E2A", + "miru": "#5B622E", "海松": "#5B622E", + "sensaicha": "#4D5139", "千歳茶": "#4D5139", + "baikocha": "#89916B", "梅幸茶": "#89916B", + "hiwamoegi": "#90B44B", "鶸萌黄": "#90B44B", + "yanagizome": "#91AD70", "柳染": "#91AD70", + "urayanagi": "#B5CAA0", "裏柳": "#B5CAA0", + "iwaicha": "#646A58", "岩井茶": "#646A58", + "moegi": "#7BA23F", "萌黄": "#7BA23F", + "nae": "#86C166", "苗": "#86C166", + "yanagisusutake": "#4A593D", "柳煤竹": "#4A593D", + "matsuba": "#42602D", "松葉": "#42602D", + "aoni": "#516E41", "青丹": "#516E41", + "usuao": "#91B493", "薄青": "#91B493", + "yanaginezumi": "#808F7C", "柳鼠": "#808F7C", + "tokiwa": "#1B813E", "常磐": "#1B813E", + "wakatake": "#5DAC81", "若竹": "#5DAC81", + "chitosemidori": "#36563C", "千歳緑": "#36563C", + "midori": "#227D51", "緑": "#227D51", + "byakuroku": "#A8D8B9", "白緑": "#A8D8B9", + "oitake": "#6A8372", "老竹": "#6A8372", + "tokusa": "#2D6D4B", "木賊": "#2D6D4B", + "onandocha": "#465D4C", "御納戸茶": "#465D4C", + "rokusyoh": "#24936E", "緑青": "#24936E", + "sabiseiji": "#86A697", "錆青磁": "#86A697", + "aotake": "#00896C", "青竹": "#00896C", + "veludo": "#096148", "ビロード": "#096148", + "mushiao": "#20604F", "虫襖": "#20604F", + "aimirucha": "#0F4C3A", "藍海松茶": "#0F4C3A", + "tonocha2": "#4F726C", "沈香茶": "#4F726C", + "aomidori": "#00AA90", "青緑": "#00AA90", + "seiji": "#69B0AC", "青磁": "#69B0AC", + "tetsu": "#26453D", "鉄": "#26453D", + "mizuasagi": "#66BAB7", "水浅葱": "#66BAB7", + "seiheki": "#268785", "青碧": "#268785", + "sabitetsuonando": "#405B55", "錆鉄御納戸": "#405B55", + "korainando": "#305A56", "高麗納戸": "#305A56", + "byakugun": "#78C2C4", "白群": "#78C2C4", + "omeshicha": "#376B6D", "御召茶": "#376B6D", + "kamenozoki": "#A5DEE4", "瓶覗": "#A5DEE4", + "fukagawanezumi": "#77969A", "深川鼠": "#77969A", + "sabiasagi": "#6699A1", "錆浅葱": "#6699A1", + "mizu": "#81C7D4", "水": "#81C7D4", + "asagi": "#33A6B8", "浅葱": "#33A6B8", + "onando": "#0C4842", "御納戸": "#0C4842", + "ai": "#0D5661", "藍": "#0D5661", + "shinbashi": "#0089A7", "新橋": "#0089A7", + "sabionando": "#336774", "錆御納戸": "#336774", + "tetsuonando": "#255359", "鉄御納戸": "#255359", + "hanaasagi": "#1E88A8", "花浅葱": "#1E88A8", + "ainezumi": "#566C73", "藍鼠": "#566C73", + "masuhana": "#577C8A", "舛花": "#577C8A", + "sora": "#58B2DC", "空": "#58B2DC", + "noshimehana": "#2B5F75", "熨斗目花": "#2B5F75", + "chigusa": "#3A8FB7", "千草": "#3A8FB7", + "omeshionando": "#2E5C6E", "御召御納戸": "#2E5C6E", + "hanada": "#006284", "縹": "#006284", + "wasurenagusa": "#7DB9DE", "勿忘草": "#7DB9DE", + "gunjyo": "#51A8DD", "群青": "#51A8DD", + "tsuyukusa": "#2EA9DF", "露草": "#2EA9DF", + "kurotsurubami": "#0B1013", "黒橡": "#0B1013", + "kon": "#0F2540", "紺": "#0F2540", + "kachi": "#08192D", "褐": "#08192D", + "ruri": "#005CAF", "瑠璃": "#005CAF", + "rurikon": "#0B346E", "瑠璃紺": "#0B346E", + "benimidori": "#7B90D2", "紅碧": "#7B90D2", + "fujinezumi": "#6E75A4", "藤鼠": "#6E75A4", + "tetsukon": "#261E47", "鉄紺": "#261E47", + "konjyo": "#113285", "紺青": "#113285", + "benikakehana": "#4E4F97", "紅掛花": "#4E4F97", + "konkikyo": "#211E55", "紺桔梗": "#211E55", + "fuji": "#8B81C3", "藤": "#8B81C3", + "futaai": "#70649A", "二藍": "#70649A", + "ouchi": "#9B90C2", "楝": "#9B90C2", + "fujimurasaki": "#8A6BBE", "藤紫": "#8A6BBE", + "kikyo": "#6A4C9C", "桔梗": "#6A4C9C", + "shion": "#8F77B5", "紫苑": "#8F77B5", + "messhi": "#533D5B", "滅紫": "#533D5B", + "usu": "#B28FCE", "薄": "#B28FCE", + "hashita": "#986DB2", "半": "#986DB2", + "edomurasaki": "#77428D", "江戸紫": "#77428D", + "shikon": "#3C2F41", "紫紺": "#3C2F41", + "kokimurasaki": "#4A225D", "深紫": "#4A225D", + "sumire": "#66327C", "菫": "#66327C", + "murasaki": "#592C63", "紫": "#592C63", + "ayame": "#6F3381", "菖蒲": "#6F3381", + "fujisusutake": "#574C57", "藤煤竹": "#574C57", + "benifuji": "#B481BB", "紅藤": "#B481BB", + "kurobeni": "#3F2B36", "黒紅": "#3F2B36", + "nasukon": "#572A3F", "茄子紺": "#572A3F", + "budohnezumi": "#5E3D50", "葡萄鼠": "#5E3D50", + "hatobanezumi": "#72636E", "鳩羽鼠": "#72636E", + "kakitsubata": "#622954", "杜若": "#622954", + "ebizome": "#6D2E5B", "蒲葡": "#6D2E5B", + "botan": "#C1328E", "牡丹": "#C1328E", + "umemurasaki": "#A8497A", "梅紫": "#A8497A", + "nisemurasaki": "#562E37", "似紫": "#562E37", + "tsutsuji": "#E03C8A", "躑躅": "#E03C8A", + "murasakitobi": "#60373E", "紫鳶": "#60373E", + "shironeri": "#FCFAF2", "白練": "#FCFAF2", + "gofun": "#FFFFFB", "胡粉": "#FFFFFB", + "shironezumi": "#BDC0BA", "白鼠": "#BDC0BA", + "ginnezumi": "#91989F", "銀鼠": "#91989F", + "namari": "#787878", "鉛": "#787878", + "hai": "#828282", "灰": "#828282", + "sunezumi": "#787D7B", "素鼠": "#787D7B", + "rikyunezumi": "#707C74", "利休鼠": "#707C74", + "nibi": "#656765", "鈍": "#656765", + "aonibi": "#535953", "青鈍": "#535953", + "dobunezumi": "#4F4F48", "溝鼠": "#4F4F48", + "benikeshinezumi": "#52433D", "紅消鼠": "#52433D", + "aisumicha": "#373C38", "藍墨茶": "#373C38", + "binrojizome": "#3A3226", "檳榔子染": "#3A3226", + "keshizumi": "#434343", "消炭": "#434343", + "sumi": "#1C1C1C", "墨": "#1C1C1C", + "kuro": "#080808", "黒": "#080808", + "ro": "#0C0C0C", "呂": "#0C0C0C", +} diff --git a/internal/servetpl/funcmap/theme.go b/internal/servetpl/funcmap/theme.go new file mode 100644 index 0000000..080a511 --- /dev/null +++ b/internal/servetpl/funcmap/theme.go @@ -0,0 +1,10 @@ +package funcmap + +import "fmt" + +func ThemeColor(key string) (string, error) { + if c, ok := nipponColors[key]; ok { + return c, nil + } + return "", fmt.Errorf("no such color %s", key) +} diff --git a/internal/servetpl/funcmap/trimaimg.go b/internal/servetpl/funcmap/trimaimg.go index 845e298..5b5ab0d 100644 --- a/internal/servetpl/funcmap/trimaimg.go +++ b/internal/servetpl/funcmap/trimaimg.go @@ -3,7 +3,7 @@ package funcmap import ( "encoding/base64" "fmt" - "io/ioutil" + "io" "net/http" "time" ) @@ -23,7 +23,7 @@ func TrimaImg(path string, retType string) (string, error) { if resp.StatusCode != 200 { return nil, fmt.Errorf("bad status code: %d", resp.StatusCode) } - return ioutil.ReadAll(resp.Body) + return io.ReadAll(resp.Body) } switch retType { case "url": diff --git a/internal/servetpl/funcmap/twilio.go b/internal/servetpl/funcmap/twilio.go deleted file mode 100644 index edcdf74..0000000 --- a/internal/servetpl/funcmap/twilio.go +++ /dev/null @@ -1,7 +0,0 @@ -package funcmap - -import ( - "github.com/eternal-flame-AD/yoake/internal/twilio" -) - -var TwilioValidate = twilio.TwilioValidate diff --git a/internal/servetpl/middleware.go b/internal/servetpl/middleware.go index 4aa4650..0fc17b3 100644 --- a/internal/servetpl/middleware.go +++ b/internal/servetpl/middleware.go @@ -122,8 +122,6 @@ func ServeTemplateDir(dir string) echo.MiddlewareFunc { ext := path.Ext(file) tplName := file[:len(file)-len(ext)] + ".tpl" + ext if path.Ext(file[:len(file)-len(ext)]) == ".tpl" { - /* reject requests for the template source file */ - log.Printf("rejecting request for template source file: %s", file) if _, err := os.Stat(filepath.Join(dir, file)); err == nil { return func(wr io.Writer, data any) error { return errTplExtNotStripped @@ -133,8 +131,6 @@ func ServeTemplateDir(dir string) echo.MiddlewareFunc { tplPath := filepath.Join(dir, tplName) if _, err := os.Stat(tplPath); err == nil { - //log.Printf("dispatch template: %s(%s) ext=%s", tplName, tplPath, ext) - // template file is still there, execute if ext == ".html" { return func(wr io.Writer, data any) error { return templates.ExecuteTemplate(wr, tplName, data) } } else { diff --git a/internal/twilio/api.go b/internal/twilio/api.go index 37a8972..f8120e0 100644 --- a/internal/twilio/api.go +++ b/internal/twilio/api.go @@ -15,10 +15,13 @@ import ( "github.com/eternal-flame-AD/yoake/internal/comm/model" "github.com/eternal-flame-AD/yoake/internal/comm/telegram" "github.com/eternal-flame-AD/yoake/internal/filestore" + "github.com/eternal-flame-AD/yoake/internal/servetpl/funcmap" "github.com/labstack/echo/v4" "github.com/spf13/afero" "github.com/twilio/twilio-go" openapi "github.com/twilio/twilio-go/rest/api/v2010" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" ) const rfc2822 = "Mon, 02 Jan 2006 15:04:05 -0700" @@ -64,27 +67,27 @@ type CallStatusCallbackProgressForm struct { SequenceNumber int `form:"SequenceNumber" json:"SequenceNumber"` } -func findCallDir(callsDir filestore.FS, callSid string, from string, to string) (filestore.FS, error) { +func findCallDir(callsDir filestore.FS, callSid string, from string, to string) (filestore.FS, string, error) { if callSid == "" { - return nil, fmt.Errorf("callSid is empty") + return nil, "", fmt.Errorf("callSid is empty") } dirs, err := afero.ReadDir(callsDir, ".") if err != nil { - return nil, err + return nil, "", err } for _, dir := range dirs { if dir.IsDir() { if strings.HasSuffix(dir.Name(), callSid) { - return filestore.ChrootFS(callsDir, dir.Name()), nil + return filestore.ChrootFS(callsDir, dir.Name()), dir.Name(), nil } } } now := time.Now() - newName := fmt.Sprintf("%s_%s_%s_%s", now.Format("2006-01-02T15:04:05"), from, to, callSid) + newName := fmt.Sprintf("%s_%s_%s_%s", now.Format("2006-01-02T15.04.05"), from, to, callSid) if err := callsDir.Mkdir(newName, 0770); err != nil { - return nil, err + return nil, "", err } - return filestore.ChrootFS(callsDir, newName), nil + return filestore.ChrootFS(callsDir, newName), newName, nil } func fetchRecording(apiClient *twilio.RestClient, callDir filestore.FS, sid string, recType string) error { @@ -215,7 +218,7 @@ func Register(g *echo.Group, fs filestore.FS, comm model.Communicator) { if sid == "" { return c.String(http.StatusBadRequest, "missing call sid") } - callDir, err := findCallDir(calls, form.CallSid, "", "") + callDir, _, err := findCallDir(calls, form.CallSid, "", "") if err != nil { return err } @@ -227,12 +230,19 @@ func Register(g *echo.Group, fs filestore.FS, comm model.Communicator) { if err := c.Bind(form); err != nil { return err } - callDir, err := findCallDir(calls, form.CallSid, form.From, form.To) + callDir, callDirName, err := findCallDir(calls, form.CallSid, form.From, form.To) if err != nil { return err } + callDirAbs := fmt.Sprintf("/calls/%s", callDirName) + log.Printf("call %s: %s -> %s dirAbs=%s", form.CallSid, form.From, form.To, callDirAbs) if hasTelegram { - tg.SendHTML(tg.OwnerChatID, fmt.Sprintf("Call From %s (%s):\n\nTo: %s\nSid: %s", form.From, form.CallStatus, form.To, form.CallSid)) + msg := tgbotapi.NewMessage(tg.OwnerChatID, fmt.Sprintf("Call From %s (%s):\n\nTo: %s\nSid: %s\nCallDir: %s", + form.From, form.CallStatus, form.To, form.CallSid, funcmap.FileAccess(callDirAbs+"/"), callDirAbs)) + msg.ParseMode = tgbotapi.ModeHTML + if _, err := tg.Client().Send(msg); err != nil { + log.Printf("failed to send telegram message: %v", err) + } } fileName := fmt.Sprintf("status.%d.json", time.Now().UnixNano()) diff --git a/internal/twilio/verify.go b/internal/twilio/verify.go index feb3582..48e7a4f 100644 --- a/internal/twilio/verify.go +++ b/internal/twilio/verify.go @@ -37,7 +37,6 @@ func VerifyMiddleware(prefix string, baseurlS string) echo.MiddlewareFunc { if err != nil { log.Fatalf("invalid twilio baseurl: %v", baseurlS) } - log.Printf("twilio baseurl is %v", baseURL) var basicAuth echo.MiddlewareFunc if userpass := baseURL.User.String(); userpass != "" { basicAuth = middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) { @@ -59,14 +58,12 @@ func VerifyMiddleware(prefix string, baseurlS string) echo.MiddlewareFunc { } cleanPath := path.Clean(c.Request().URL.Path) - //log.Printf("cleanPath: %s", cleanPath) if cleanPath == prefix || strings.HasPrefix(cleanPath, prefix+"/") { fullReq := c.Request().Clone(c.Request().Context()) - log.Printf("original request URL: %v, scheme=%s, host=%s, user=%s", c.Request().URL, c.Request().URL.Scheme, c.Request().URL.Host, c.Request().URL.User) fullReq.URL = baseURL.ResolveReference(c.Request().URL) fullReq.URL.User = nil if err := TwilioValidate(c, fullReq); err != nil { - log.Printf("twilio verify failed: %v", err) + log.Printf("twilio verify failed: %v, url=%s", err, fullReq.URL.String()) if !bypassOk { c.String(http.StatusOK, "We are sorry. Request Validation Failed. This is not your fault.") return nil diff --git a/server/webroot/server.go b/server/webroot/server.go index 26b75d3..373595d 100644 --- a/server/webroot/server.go +++ b/server/webroot/server.go @@ -4,6 +4,7 @@ import ( "encoding/json" "log" "os" + "regexp" "github.com/eternal-flame-AD/yoake/config" "github.com/eternal-flame-AD/yoake/internal/auth" @@ -70,12 +71,30 @@ func Init(hostname string, comm *comm.Communicator, database db.DB, fs filestore canvaslms.Register(api.Group("/canvas", logMiddleware("api_canvas", nil)), comm) utilapi.Register(api.Group("/util", logMiddleware("api_util", nil))) comm.RegisterAPIRoute(api.Group("/comm", logMiddleware("api_comm", nil))) - auth.Register(api.Group("/auth", logMiddleware("api_auth", nil))) + auth.Register(api.Group("/auth", logMiddleware("api_auth", nil)), database) entertainment.Register(api.Group("/entertainment", logMiddleware("api_entertainment", nil)), database) health.Register(api.Group("/health", logMiddleware("api_health", nil)), database, comm) twilio.Register(api.Group("/twilio", logMiddleware("api_twilio", nil)), fs, comm) } + if fsConf := config.Config().FS; fsConf.Serve { + e.Group("/files").Use(auth.RequireMiddleware(auth.RoleAdmin), middleware.RewriteWithConfig(middleware.RewriteConfig{RegexRules: map[*regexp.Regexp]string{regexp.MustCompile("^/files/(.*)$"): "/$1"}}), + middleware.StaticWithConfig(middleware.StaticConfig{ + Skipper: func(c echo.Context) bool { + if c.Request().Method != echo.GET { + return true + } + if fetchMode := c.Request().Header.Get("Sec-Fetch-Mode"); fetchMode != "" && fetchMode != "navigate" { + // some protection against XSS + return true + } + return false + }, + Root: fsConf.BasePath, + Browse: true, + }), logMiddleware("files", nil)) + } + e.Use( echoerror.Middleware(echoerror.HTMLWriter), func(next echo.HandlerFunc) echo.HandlerFunc { @@ -89,7 +108,7 @@ func Init(hostname string, comm *comm.Communicator, database db.DB, fs filestore middleware.Gzip(), auth.Middleware(sessionCookie), logMiddleware("twilio", twilio.VerifyMiddleware("/twilio", config.Config().Twilio.BaseURL)), - middleware.Rewrite(map[string]string{"*/": "$1/index.html"}), + middleware.RewriteWithConfig(middleware.RewriteConfig{RegexRules: map[*regexp.Regexp]string{regexp.MustCompile("^/$"): "/index.html"}}), logMiddleware("template", servetpl.ServeTemplateDir(webroot.Root)), logMiddleware("static", middleware.Static(webroot.Root))) diff --git a/webroot/assets/voicemail_welcome.mp3 b/webroot/assets/voicemail_welcome.mp3 index f34c490..7c361fd 100644 Binary files a/webroot/assets/voicemail_welcome.mp3 and b/webroot/assets/voicemail_welcome.mp3 differ diff --git a/webroot/file_access.tpl.html b/webroot/file_access.tpl.html new file mode 100644 index 0000000..ee15660 --- /dev/null +++ b/webroot/file_access.tpl.html @@ -0,0 +1,21 @@ + +{{- $path := "" }} +{{- if $formPath := (invoke "FormValue" .C "path") }} +{{- $path = $formPath }} +{{- else if $pathParam := (invoke "Param" .C "path") }} +{{- $path = $pathParam }} +{{- end }} +{{- if or (not $path) + (ne (slice $path 0 1) "/") }} +{{- $path = (sprintf "/%s" $path) }} +{{- end}} + + + + {{ if (call .Config).FS.Serve }} + + {{ else }} + + {{ end }} + \ No newline at end of file diff --git a/webroot/includes/modal-login.tpl.html b/webroot/includes/modal-login.tpl.html index 0c70c07..da4c2b0 100644 --- a/webroot/includes/modal-login.tpl.html +++ b/webroot/includes/modal-login.tpl.html @@ -4,6 +4,7 @@