diff --git a/config.go b/config.go index 56fd2ff..4860517 100644 --- a/config.go +++ b/config.go @@ -28,6 +28,7 @@ type Config struct { Static string Upload string } + NotificationBaseURL string } Session struct { diff --git a/db.go b/db.go new file mode 100644 index 0000000..2046724 --- /dev/null +++ b/db.go @@ -0,0 +1,13 @@ +package main + +import ( + // Standard + "database/sql" +) + +// Queryable can take both a Db and a transaction +type Queryable interface { + Exec(string, ...any) (sql.Result, error) + Query(string, ...any) (*sql.Rows, error) + QueryRow(string, ...any) *sql.Row +} diff --git a/go.mod b/go.mod index a0791a8..59b3275 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.21.0 require ( git.gibonuddevalla.se/go/webservice v0.2.2 + git.gibonuddevalla.se/go/wrappederror v0.3.3 github.com/google/uuid v1.5.0 github.com/gorilla/websocket v1.5.0 github.com/jmoiron/sqlx v1.3.5 diff --git a/go.sum b/go.sum index bbaf4e1..6466326 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ git.gibonuddevalla.se/go/dbschema v1.3.0 h1:HzFMR29tWfy/ibIjltTbIMI4inVktj/rh8bE git.gibonuddevalla.se/go/dbschema v1.3.0/go.mod h1:BNw3q/574nXbGoeWyK+tLhRfggVkw2j2aXZzrBKC3ig= git.gibonuddevalla.se/go/webservice v0.2.2 h1:pmfeLa7c9pSPbuu6TuzcJ6yuVwdMLJ8SSPm1IkusThk= git.gibonuddevalla.se/go/webservice v0.2.2/go.mod h1:3uBS6nLbK9qbuGzDls8MZD5Xr9ORY1Srbj6v06BIhws= +git.gibonuddevalla.se/go/wrappederror v0.3.3 h1:pdIy3/daSY3zMmUr9PXW6ffIt8iYonOv64mgJBpKz+0= +git.gibonuddevalla.se/go/wrappederror v0.3.3/go.mod h1:j4w320Hk1wvhOPjUaK4GgLvmtnjUUM5yVu6JFO1OCSc= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= diff --git a/main.go b/main.go index eebe749..b331f2a 100644 --- a/main.go +++ b/main.go @@ -3,14 +3,17 @@ package main import ( // External "git.gibonuddevalla.se/go/webservice" + "git.gibonuddevalla.se/go/wrappederror" // Internal "git.gibonuddevalla.se/go/webservice/session" + "notes/notification" // Standard "crypto/md5" "embed" "encoding/hex" + "encoding/json" "flag" "fmt" "io" @@ -20,6 +23,7 @@ import ( "path/filepath" "strconv" "strings" + "time" ) const LISTEN_HOST = "0.0.0.0" @@ -31,12 +35,14 @@ var ( flagCheckLocal bool flagConfig string - service *webservice.Service - connectionManager ConnectionManager - static http.Handler - config Config - logger *slog.Logger - VERSION string + service *webservice.Service + connectionManager ConnectionManager + notificationManager notification.Manager + static http.Handler + config Config + logger *slog.Logger + schedulers map[int]Schedule + VERSION string //go:embed version sql/* embeddedSQL embed.FS @@ -45,7 +51,7 @@ var ( staticFS embed.FS ) -func sqlProvider(dbname string, version int) (sql []byte, found bool) { +func sqlProvider(dbname string, version int) (sql []byte, found bool) { // {{{ var err error sql, err = embeddedSQL.ReadFile(fmt.Sprintf("sql/%05d.sql", version)) if err != nil { @@ -53,7 +59,27 @@ func sqlProvider(dbname string, version int) (sql []byte, found bool) { } found = true return -} +} // }}} +func logCallback(e WrappedError.Error) { // {{{ + now := time.Now() + out := struct { + Year int + Month int + Day int + Time string + Error any + }{now.Year(), int(now.Month()), now.Day(), now.Format("15:04:05"), e} + + j, _ := json.Marshal(out) + file, err := os.OpenFile("/tmp/notes.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + logger.Error("log", "error", err) + return + } + file.Write(j) + file.Write([]byte("\n")) + file.Close() +} // }}} func init() { // {{{ version, _ := embeddedSQL.ReadFile("version") @@ -63,6 +89,8 @@ func init() { // {{{ opt.Level = slog.LevelDebug logger = slog.New(slog.NewJSONHandler(os.Stdout, &opt)) + schedulers = make(map[int]Schedule, 512) + configFilename := os.Getenv("HOME") + "/.config/notes.yaml" flag.IntVar(&flagPort, "port", 1371, "TCP port to listen on") flag.BoolVar(&flagVersion, "version", false, "Shows Notes version and exists") @@ -72,6 +100,9 @@ func init() { // {{{ flag.Parse() } // }}} func main() { // {{{ + WrappedError.Init() + WrappedError.SetLogCallback(logCallback) + var err error if flagVersion { @@ -115,6 +146,7 @@ func main() { // {{{ service.Register("/key/retrieve", true, true, keyRetrieve) service.Register("/key/create", true, true, keyCreate) service.Register("/key/counter", true, true, keyCounter) + service.Register("/notification/ack", false, false, notificationAcknowledge) service.Register("/", false, false, service.StaticHandler) if flagCreateUser { @@ -122,45 +154,48 @@ func main() { // {{{ os.Exit(0) } + go scheduleHandler() + + if err = service.InitDatabaseConnection(); err != nil { + logger.Error("application", "error", err) + os.Exit(1) + } + + err = InitNotificationManager() + if err != nil { + logger.Error("application", "error", err) + os.Exit(1) + } + err = service.Start() if err != nil { logger.Error("webserver", "error", err) os.Exit(1) } } // }}} - -/* -func userPassword(w http.ResponseWriter, r *http.Request) { // {{{ - var err error - var ok bool - var session Session - - if session, _, err = ValidateSession(r, true); err != nil { - responseError(w, err) - return +func scheduleHandler() { // {{{ + // Wait for the approximate minute. + //wait := 60000 - time.Now().Sub(time.Now().Truncate(time.Minute)).Milliseconds() + //time.Sleep(time.Millisecond * time.Duration(wait)) + tick := time.NewTicker(time.Minute) + tick = time.NewTicker(time.Second*5) + for { + <-tick.C + for _, event := range ExpiredSchedules() { + notificationManager.Send( + event.UserID, + event.ScheduleUUID, + []byte( + fmt.Sprintf( + "%s\n%s", + event.Time.Format("2006-01-02 15:04"), + event.Description, + ), + ), + ) + } } - - req := struct { - CurrentPassword string - NewPassword string - }{} - if err = parseRequest(r, &req); err != nil { - responseError(w, err) - return - } - - ok, err = session.UpdatePassword(req.CurrentPassword, req.NewPassword) - if err != nil { - responseError(w, err) - return - } - - responseData(w, map[string]interface{}{ - "OK": true, - "CurrentPasswordOK": ok, - }) } // }}} -*/ func nodeTree(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ logger.Info("webserver", "request", "/node/tree") @@ -726,4 +761,20 @@ func keyCounter(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{ }) } // }}} + +func notificationAcknowledge(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ + logger.Info("webserver", "request", "/notification/ack") + var err error + + + err = AcknowledgeNotification(r.URL.Query().Get("uuid")) + if err != nil { + responseError(w, err) + return + } + + responseData(w, map[string]interface{}{ + "OK": true, + }) +} // }}} // vim: foldmethod=marker diff --git a/node.go b/node.go index c65b4b4..1868122 100644 --- a/node.go +++ b/node.go @@ -6,6 +6,7 @@ import ( // Standard "time" + "database/sql" ) type ChecklistItem struct { @@ -323,8 +324,69 @@ func CreateNode(userID, parentID int, name string) (node Node, err error) { // { return } // }}} func UpdateNode(userID, nodeID int, content string, cryptoKeyID int, markdown bool) (err error) { // {{{ + var scannedSchedules, dbSchedules, add, remove []Schedule + scannedSchedules = ScanForSchedules(content) + for i := range scannedSchedules { + scannedSchedules[i].Node.ID = nodeID + scannedSchedules[i].UserID = userID + } + + var tsx *sql.Tx + tsx, err = service.Db.Conn.Begin() + if err != nil { + return + } + + dbSchedules, err = RetrieveSchedules(userID, nodeID) + if err != nil { + tsx.Rollback() + return + } + + for _, scanned := range scannedSchedules { + found := false + for _, db := range dbSchedules { + if scanned.IsEqual(db) { + found = true + break + } + } + if !found { + add = append(add, scanned) + } + } + + for _, db := range dbSchedules { + found := false + for _, scanned := range scannedSchedules { + if db.IsEqual(scanned) { + found = true + break + } + } + if !found { + remove = append(remove, db) + } + } + + for _, event := range remove { + err = event.Delete(tsx) + if err != nil { + tsx.Rollback() + return + } + } + + for _, event := range add { + err = event.Insert(tsx) + if err != nil { + tsx.Rollback() + return + } + } + if cryptoKeyID > 0 { - _, err = service.Db.Conn.Exec(` + _, err = tsx.Exec(` UPDATE node SET content = '', @@ -345,7 +407,7 @@ func UpdateNode(userID, nodeID int, content string, cryptoKeyID int, markdown bo markdown, ) } else { - _, err = service.Db.Conn.Exec(` + _, err = tsx.Exec(` UPDATE node SET content = $1, @@ -366,6 +428,12 @@ func UpdateNode(userID, nodeID int, content string, cryptoKeyID int, markdown bo markdown, ) } + if err != nil { + tsx.Rollback() + return + } + + err = tsx.Commit() return } // }}} diff --git a/notification/factory.go b/notification/factory.go new file mode 100644 index 0000000..cc4d145 --- /dev/null +++ b/notification/factory.go @@ -0,0 +1,15 @@ +package notification + +import ( + // External + werr "git.gibonuddevalla.se/go/wrappederror" +) + +func ServiceFactory(t string, config []byte, prio int, ackURL string) (Service, error) { + switch t { + case "NTFY": + return NewNTFY(config, prio, ackURL) + } + + return nil, werr.New("Unknown notification service, '%s'", t).WithCode("002-0000") +} diff --git a/notification/ntfy.go b/notification/ntfy.go new file mode 100644 index 0000000..3f1b2ef --- /dev/null +++ b/notification/ntfy.go @@ -0,0 +1,70 @@ +package notification + +import ( + // External + werr "git.gibonuddevalla.se/go/wrappederror" + + // Standard + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +type NTFY struct { + URL string + Prio int + AcknowledgeURL string +} + +func NewNTFY(config []byte, prio int, ackURL string) (instance NTFY, err error) { + err = json.Unmarshal(config, &instance) + if err != nil { + err = werr.Wrap(err).WithCode("002-0001").WithData(config) + return + } + instance.Prio = prio + instance.AcknowledgeURL = ackURL + return instance, nil +} + +func (ntfy NTFY) GetPrio() int { + return ntfy.Prio +} + +func (ntfy NTFY) Send(uuid string, msg []byte) (err error) { + var req *http.Request + var res *http.Response + req, err = http.NewRequest("POST", ntfy.URL, bytes.NewReader(msg)) + if err != nil { + err = werr.Wrap(err).WithCode("002-0002").WithData(ntfy.URL) + return + } + + ackURL := fmt.Sprintf("http, OK, %s/notification/ack?uuid=%s", ntfy.AcknowledgeURL, uuid) + req.Header.Add("X-Actions", ackURL) + + res, err = http.DefaultClient.Do(req) + if err != nil { + err = werr.Wrap(err).WithCode("002-0003") + return + } + + body, _ := io.ReadAll(res.Body) + if res.StatusCode != 200 { + err = werr.New("Invalid NTFY response").WithCode("002-0004").WithData(body) + return + } + + ntfyResp := struct { + ID string + }{} + err = json.Unmarshal(body, &ntfyResp) + if err != nil { + err = werr.Wrap(err).WithCode("002-0005").WithData(body) + return + } + + return +} diff --git a/notification/pkg.go b/notification/pkg.go new file mode 100644 index 0000000..5732f31 --- /dev/null +++ b/notification/pkg.go @@ -0,0 +1,59 @@ +package notification + +import ( + // External + werr "git.gibonuddevalla.se/go/wrappederror" + + // Standard + _ "fmt" + "slices" +) + +type Service interface { + GetPrio() int + Send(string, []byte) error +} + +type Manager struct { + services map[int][]Service +} + +func NewManager() (nm Manager) { + nm.services = make(map[int][]Service, 32) + return +} + +func (nm *Manager) AddService(userID int, service Service) { + var services []Service + var found bool + if services, found = nm.services[userID]; !found { + services = []Service{} + } + + services = append(services, service) + slices.SortFunc(services, func(a, b Service) int { + if a.GetPrio() < b.GetPrio() { + return -1 + } + if a.GetPrio() > b.GetPrio() { + return 1 + } + return 0 + }) + nm.services[userID] = services +} + +func (nm *Manager) Send(userID int, uuid string, msg []byte) (err error) { + services, found := nm.services[userID] + if !found { + return werr.New("No notification services defined for user ID %d", userID).WithCode("002-0008") + } + + for _, service := range services { + if err = service.Send(uuid, msg); err == nil { + break + } + } + + return +} diff --git a/notification_manager.go b/notification_manager.go new file mode 100644 index 0000000..7022af9 --- /dev/null +++ b/notification_manager.go @@ -0,0 +1,75 @@ +package main + +import ( + // External + werr "git.gibonuddevalla.se/go/wrappederror" + + // Internal + "notes/notification" + + // Standard + "database/sql" + "encoding/json" +) + +type DbNotificationService struct { + ID int + UserID int `json:"user_id"` + Service string + Configuration string + Prio int +} + +func InitNotificationManager() (err error) {// {{{ + var dbServices []DbNotificationService + var row *sql.Row + + row = service.Db.Conn.QueryRow(` + WITH services AS ( + SELECT + id, + user_id, + prio, + service, + configuration::varchar + FROM notification n + ORDER BY + user_id ASC, + prio ASC + ) + SELECT jsonb_agg(s.*) + FROM services s + `, + ) + var dbData []byte + err = row.Scan(&dbData) + if err != nil { + err = werr.Wrap(err).WithCode("002-0006") + return + } + + err = json.Unmarshal(dbData, &dbServices) + if err != nil { + err = werr.Wrap(err).WithCode("002-0007") + return + } + + notificationManager = notification.NewManager() + var service notification.Service + for _, dbService := range dbServices { + service, err = notification.ServiceFactory( + dbService.Service, + []byte(dbService.Configuration), + dbService.Prio, + config.Application.NotificationBaseURL, + ) + notificationManager.AddService(dbService.UserID, service) + } + + return +}// }}} + +func AcknowledgeNotification(uuid string) (err error) {// {{{ + _, err = service.Db.Conn.Exec(`UPDATE schedule SET acknowledged=true WHERE schedule_uuid=$1`, uuid) + return +}// }}} diff --git a/schedule.go b/schedule.go new file mode 100644 index 0000000..8b0f9b6 --- /dev/null +++ b/schedule.go @@ -0,0 +1,160 @@ +package main + +import ( + // External + werr "git.gibonuddevalla.se/go/wrappederror" + + // Standard + "encoding/json" + "fmt" + "regexp" + "strings" + "time" +) + +func init() { + fmt.Printf("") +} + +type Schedule struct { + ID int + UserID int `json:"user_id" db:"user_id"` + Node Node + ScheduleUUID string `db:"schedule_uuid"` + Time time.Time + Description string + Acknowledged bool +} + +func ScanForSchedules(content string) (schedules []Schedule) {// {{{ + schedules = []Schedule{} + + rxp := regexp.MustCompile(`\{\s*([0-9]{4}-[0-9]{2}-[0-9]{2}\s+[0-9]{2}:[0-9]{2}(?::[0-9]{2})?)\s*\,\s*([^\]]+?)\s*\}`) + foundSchedules := rxp.FindAllStringSubmatch(content, -1) + + for _, data := range foundSchedules { + // Missing seconds + if strings.Count(data[1], ":") == 1 { + data[1] = data[1] + ":00" + } + + timestamp, err := time.Parse("2006-01-02 15:04:05", data[1]) + if err != nil { + continue + } + + schedule := Schedule{ + Time: timestamp, + Description: data[2], + } + schedules = append(schedules, schedule) + } + + return +}// }}} +func RetrieveSchedules(userID int, nodeID int) (schedules []Schedule, err error) {// {{{ + schedules = []Schedule{} + + res := service.Db.Conn.QueryRow(` + WITH schedule_events AS ( + SELECT + id, + user_id, + json_build_object('id', node_id) AS node, + schedule_uuid, + time::timestamptz, + description, + acknowledged + FROM schedule + WHERE + user_id=$1 AND + CASE + WHEN $2 > 0 THEN node_id = $2 + ELSE true + END + ) + SELECT + COALESCE(jsonb_agg(s.*), '[]'::jsonb) + FROM schedule_events s + `, + userID, + nodeID, + ) + + var data []byte + err = res.Scan(&data) + if err != nil { + return + } + + err = json.Unmarshal(data, &schedules) + return +}// }}} + +func (a Schedule) IsEqual(b Schedule) bool {// {{{ + return a.UserID == b.UserID && + a.Node.ID == b.Node.ID && + a.Time.Equal(b.Time) && + a.Description == b.Description +}// }}} +func (s *Schedule) Insert(queryable Queryable) error {// {{{ + res := queryable.QueryRow(` + INSERT INTO schedule(user_id, node_id, time, description) + VALUES($1, $2, $3, $4) + RETURNING id + `, + s.UserID, + s.Node.ID, + s.Time, + s.Description, + ) + + return res.Scan(&s.ID) +}// }}} +func (s *Schedule) Delete(queryable Queryable) error {// {{{ + _, err := queryable.Exec(` + DELETE FROM schedule + WHERE + user_id = $1 AND + id = $2 + `, + s.UserID, + s.ID, + ) + return err +}// }}} + +func ExpiredSchedules() (schedules []Schedule) {// {{{ + schedules = []Schedule{} + + res, err := service.Db.Conn.Queryx(` + SELECT + id, + user_id, + node_id, + schedule_uuid, + time, + description + FROM schedule + WHERE + time < NOW() AND + NOT acknowledged + ORDER BY + time ASC + `) + if err != nil { + err = werr.Wrap(err).WithCode("002-0009") + return + } + defer res.Close() + + for res.Next() { + s := Schedule{} + if err = res.Scan(&s.ID, &s.UserID, &s.Node.ID, &s.ScheduleUUID, &s.Time, &s.Description); err != nil { + werr.Wrap(err).WithCode("002-000a") + continue + } + schedules = append(schedules, s) + } + return +}// }}} diff --git a/sql/00015.sql b/sql/00015.sql new file mode 100644 index 0000000..c39cc4f --- /dev/null +++ b/sql/00015.sql @@ -0,0 +1,14 @@ +CREATE TABLE public.schedule ( + id SERIAL NOT NULL, + user_id INT4 NOT NULL, + node_id INT4 NOT NULL, + schedule_uuid CHAR(36) DEFAULT GEN_RANDOM_UUID() NOT NULL, + "time" TIMESTAMP NOT NULL, + description VARCHAR DEFAULT '' NOT NULL, + acknowledged BOOL DEFAULT false NOT NULL, + + CONSTRAINT schedule_pk PRIMARY KEY (id), + CONSTRAINT schedule_uuid UNIQUE (schedule_uuid), + CONSTRAINT schedule_node_fk FOREIGN KEY (node_id) REFERENCES public.node(id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT schedule_user_fk FOREIGN KEY (user_id) REFERENCES "_webservice"."user"(id) ON DELETE CASCADE ON UPDATE CASCADE +); diff --git a/sql/00016.sql b/sql/00016.sql new file mode 100644 index 0000000..8a98bf1 --- /dev/null +++ b/sql/00016.sql @@ -0,0 +1 @@ +ALTER TABLE public.schedule ADD CONSTRAINT schedule_event UNIQUE (user_id, node_id, "time", description); diff --git a/sql/00017.sql b/sql/00017.sql new file mode 100644 index 0000000..52009bf --- /dev/null +++ b/sql/00017.sql @@ -0,0 +1,11 @@ +CREATE TABLE public.notification ( + id SERIAl NOT NULL, + user_id INT4 NOT NULL, + service VARCHAR DEFAULT 'NTFY' NOT NULL, + "configuration" JSONB DEFAULT '{}' NOT NULL, + prio INT DEFAULT 0 NOT NULL, + + CONSTRAINT notification_pk PRIMARY KEY (id), + CONSTRAINT notification_unique UNIQUE (user_id,prio), + CONSTRAINT notification_user_fk FOREIGN KEY (user_id) REFERENCES "_webservice"."user"(id) ON DELETE CASCADE ON UPDATE CASCADE +); diff --git a/version b/version index 1689437..53d1c14 100644 --- a/version +++ b/version @@ -1 +1 @@ -v21 +v22