Compare commits

..

No commits in common. "a6c94ac7ca8144db894d83d29fd1aa2ba0341a88" and "fd01e751e2c791ea827057961828250c032b8c38" have entirely different histories.

15 changed files with 42 additions and 583 deletions

View File

@ -28,7 +28,6 @@ type Config struct {
Static string Static string
Upload string Upload string
} }
NotificationBaseURL string
} }
Session struct { Session struct {

13
db.go
View File

@ -1,13 +0,0 @@
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
}

1
go.mod
View File

@ -4,7 +4,6 @@ go 1.21.0
require ( require (
git.gibonuddevalla.se/go/webservice v0.2.2 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/google/uuid v1.5.0
github.com/gorilla/websocket v1.5.0 github.com/gorilla/websocket v1.5.0
github.com/jmoiron/sqlx v1.3.5 github.com/jmoiron/sqlx v1.3.5

2
go.sum
View File

@ -2,8 +2,6 @@ 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/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 h1:pmfeLa7c9pSPbuu6TuzcJ6yuVwdMLJ8SSPm1IkusThk=
git.gibonuddevalla.se/go/webservice v0.2.2/go.mod h1:3uBS6nLbK9qbuGzDls8MZD5Xr9ORY1Srbj6v06BIhws= 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 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=

115
main.go
View File

@ -3,17 +3,14 @@ package main
import ( import (
// External // External
"git.gibonuddevalla.se/go/webservice" "git.gibonuddevalla.se/go/webservice"
"git.gibonuddevalla.se/go/wrappederror"
// Internal // Internal
"git.gibonuddevalla.se/go/webservice/session" "git.gibonuddevalla.se/go/webservice/session"
"notes/notification"
// Standard // Standard
"crypto/md5" "crypto/md5"
"embed" "embed"
"encoding/hex" "encoding/hex"
"encoding/json"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@ -23,7 +20,6 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"time"
) )
const LISTEN_HOST = "0.0.0.0" const LISTEN_HOST = "0.0.0.0"
@ -37,11 +33,9 @@ var (
service *webservice.Service service *webservice.Service
connectionManager ConnectionManager connectionManager ConnectionManager
notificationManager notification.Manager
static http.Handler static http.Handler
config Config config Config
logger *slog.Logger logger *slog.Logger
schedulers map[int]Schedule
VERSION string VERSION string
//go:embed version sql/* //go:embed version sql/*
@ -51,7 +45,7 @@ var (
staticFS embed.FS 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 var err error
sql, err = embeddedSQL.ReadFile(fmt.Sprintf("sql/%05d.sql", version)) sql, err = embeddedSQL.ReadFile(fmt.Sprintf("sql/%05d.sql", version))
if err != nil { if err != nil {
@ -59,27 +53,7 @@ func sqlProvider(dbname string, version int) (sql []byte, found bool) { // {{{
} }
found = true found = true
return 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() { // {{{ func init() { // {{{
version, _ := embeddedSQL.ReadFile("version") version, _ := embeddedSQL.ReadFile("version")
@ -89,8 +63,6 @@ func init() { // {{{
opt.Level = slog.LevelDebug opt.Level = slog.LevelDebug
logger = slog.New(slog.NewJSONHandler(os.Stdout, &opt)) logger = slog.New(slog.NewJSONHandler(os.Stdout, &opt))
schedulers = make(map[int]Schedule, 512)
configFilename := os.Getenv("HOME") + "/.config/notes.yaml" configFilename := os.Getenv("HOME") + "/.config/notes.yaml"
flag.IntVar(&flagPort, "port", 1371, "TCP port to listen on") flag.IntVar(&flagPort, "port", 1371, "TCP port to listen on")
flag.BoolVar(&flagVersion, "version", false, "Shows Notes version and exists") flag.BoolVar(&flagVersion, "version", false, "Shows Notes version and exists")
@ -100,9 +72,6 @@ func init() { // {{{
flag.Parse() flag.Parse()
} // }}} } // }}}
func main() { // {{{ func main() { // {{{
WrappedError.Init()
WrappedError.SetLogCallback(logCallback)
var err error var err error
if flagVersion { if flagVersion {
@ -146,7 +115,6 @@ func main() { // {{{
service.Register("/key/retrieve", true, true, keyRetrieve) service.Register("/key/retrieve", true, true, keyRetrieve)
service.Register("/key/create", true, true, keyCreate) service.Register("/key/create", true, true, keyCreate)
service.Register("/key/counter", true, true, keyCounter) service.Register("/key/counter", true, true, keyCounter)
service.Register("/notification/ack", false, false, notificationAcknowledge)
service.Register("/", false, false, service.StaticHandler) service.Register("/", false, false, service.StaticHandler)
if flagCreateUser { if flagCreateUser {
@ -154,48 +122,45 @@ func main() { // {{{
os.Exit(0) 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() err = service.Start()
if err != nil { if err != nil {
logger.Error("webserver", "error", err) logger.Error("webserver", "error", err)
os.Exit(1) os.Exit(1)
} }
} // }}} } // }}}
func scheduleHandler() { // {{{
// Wait for the approximate minute. /*
//wait := 60000 - time.Now().Sub(time.Now().Truncate(time.Minute)).Milliseconds() func userPassword(w http.ResponseWriter, r *http.Request) { // {{{
//time.Sleep(time.Millisecond * time.Duration(wait)) var err error
tick := time.NewTicker(time.Minute) var ok bool
tick = time.NewTicker(time.Second*5) var session Session
for {
<-tick.C if session, _, err = ValidateSession(r, true); err != nil {
for _, event := range ExpiredSchedules() { responseError(w, err)
notificationManager.Send( return
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) { // {{{ func nodeTree(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
logger.Info("webserver", "request", "/node/tree") logger.Info("webserver", "request", "/node/tree")
@ -761,20 +726,4 @@ 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 // vim: foldmethod=marker

72
node.go
View File

@ -6,7 +6,6 @@ import (
// Standard // Standard
"time" "time"
"database/sql"
) )
type ChecklistItem struct { type ChecklistItem struct {
@ -324,69 +323,8 @@ func CreateNode(userID, parentID int, name string) (node Node, err error) { // {
return return
} // }}} } // }}}
func UpdateNode(userID, nodeID int, content string, cryptoKeyID int, markdown bool) (err error) { // {{{ 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 { if cryptoKeyID > 0 {
_, err = tsx.Exec(` _, err = service.Db.Conn.Exec(`
UPDATE node UPDATE node
SET SET
content = '', content = '',
@ -407,7 +345,7 @@ func UpdateNode(userID, nodeID int, content string, cryptoKeyID int, markdown bo
markdown, markdown,
) )
} else { } else {
_, err = tsx.Exec(` _, err = service.Db.Conn.Exec(`
UPDATE node UPDATE node
SET SET
content = $1, content = $1,
@ -428,12 +366,6 @@ func UpdateNode(userID, nodeID int, content string, cryptoKeyID int, markdown bo
markdown, markdown,
) )
} }
if err != nil {
tsx.Rollback()
return
}
err = tsx.Commit()
return return
} // }}} } // }}}

View File

@ -1,15 +0,0 @@
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")
}

View File

@ -1,70 +0,0 @@
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
}

View File

@ -1,59 +0,0 @@
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
}

View File

@ -1,75 +0,0 @@
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
}// }}}

View File

@ -1,160 +0,0 @@
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
}// }}}

View File

@ -1,14 +0,0 @@
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
);

View File

@ -1 +0,0 @@
ALTER TABLE public.schedule ADD CONSTRAINT schedule_event UNIQUE (user_id, node_id, "time", description);

View File

@ -1,11 +0,0 @@
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
);

View File

@ -1 +1 @@
v22 v21