This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

WIP: try to implement webpush

+254 -54
+2
.env.example
··· 1 1 PORT=8000 2 2 CHURROS_API_URL=http://localhost:4000/graphql 3 3 DATABASE_URL="postgres://postgres:dev@localhost:5432/postgres?schema=public" 4 + PUBLIC_VAPID_KEY="" 5 + VAPID_PRIVATE_KEY=""
+35
churros.go
··· 62 62 func ConnectToDababase() error { 63 63 return prisma.Connect() 64 64 } 65 + 66 + // Group returns the Churros group ID responsible for the notification 67 + func (msg Message) Group() (string, error) { 68 + switch msg.Event { 69 + case EventNewPost: 70 + post, err := prisma.Article.FindUnique( 71 + db.Article.ID.Equals(msg.ChurrosObjectId), 72 + ).Select( 73 + db.Article.GroupID.Field(), 74 + ).Exec(context.Background()) 75 + if err != nil { 76 + return "", fmt.Errorf("while getting the group responsible for the notification: %w", err) 77 + } 78 + 79 + return post.GroupID, nil 80 + } 81 + 82 + return "", fmt.Errorf("unknown event type %q", msg.Event) 83 + } 84 + 85 + func (msg Message) Channel() db.NotificationChannel { 86 + switch msg.Event { 87 + case EventNewPost: 88 + return db.NotificationChannelArticles 89 + case EventNewTicket: 90 + return db.NotificationChannelShotguns 91 + case EventCommentReply: 92 + case EventNewComment: 93 + return db.NotificationChannelComments 94 + case EventGodchildRequest: 95 + return db.NotificationChannelGodparentRequests 96 + } 97 + 98 + return db.NotificationChannelOther 99 + }
+38
config.go
··· 1 + package notella 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/caarlos0/env/v11" 7 + ) 8 + 9 + type Configuration struct { 10 + Port int `env:"PORT" envDefault:"8080"` 11 + ChurrosApiUrl string `env:"CHURROS_API_URL" envDefault:"http://localhost:4000/graphql"` 12 + PollInterval int `env:"POLL_INTERVAL_MS" envDefault:"500"` 13 + RedisURL string `env:"REDIS_URL" envDefault:"redis://localhost:6379"` 14 + ChurrosDatabaseURL string `env:"DATABASE_URL"` 15 + VapidPublicKey string `env:"PUBLIC_VAPID_KEY"` 16 + VapidPrivateKey string `env:"VAPID_PRIVATE_KEY"` 17 + ContactEmail string `env:"CONTACT_EMAIL"` 18 + } 19 + 20 + func LoadConfiguration() (Configuration, error) { 21 + config := Configuration{} 22 + err := env.Parse(&config) 23 + if err != nil { 24 + return Configuration{}, fmt.Errorf("could not load env variables: %w", err) 25 + } 26 + 27 + return config, nil 28 + } 29 + 30 + var config Configuration 31 + 32 + func init() { 33 + var err error 34 + config, err = LoadConfiguration() 35 + if err != nil { 36 + panic(fmt.Errorf("could not load configuration: %w", err)) 37 + } 38 + }
-24
jobs.go
··· 1 - package notella 2 - 3 - import ( 4 - "time" 5 - ) 6 - 7 - type ScheduledJob struct { 8 - ID string `json:"id"` 9 - When time.Time `json:"when"` 10 - Object ChurrosId `json:"object"` 11 - Event Event `json:"event"` 12 - } 13 - 14 - func (job ScheduledJob) ShouldRun() bool { 15 - return time.Now().After(job.When) 16 - } 17 - 18 - func (job ScheduledJob) Run() error { 19 - switch job.Event { 20 - // TODO 21 - } 22 - 23 - return nil 24 - }
+53
messages.go
··· 1 + package notella 2 + 3 + import ( 4 + "fmt" 5 + "time" 6 + 7 + ll "github.com/ewen-lbh/label-logger-go" 8 + ) 9 + 10 + func (msg Message) ShouldRun() bool { 11 + return time.Now().After(msg.SendAt) 12 + } 13 + 14 + func (msg Message) Run() error { 15 + users, err := Receivers(msg) 16 + if err != nil { 17 + return fmt.Errorf("could not determine who to send the notification to: %w", err) 18 + } 19 + 20 + subs, err := subscriptionsOfUsers(users) 21 + if err != nil { 22 + return fmt.Errorf("could not determine which subscriptions to send the notification to: %w", err) 23 + } 24 + 25 + if len(subs) == 0 { 26 + ll.Warn("no subscriptions to send notification [dim]%s[reset] ([bold]%s on %s[reset]) to", msg.Id, msg.Event, msg.ChurrosObjectId) 27 + return nil 28 + } 29 + 30 + group, err := msg.Group() 31 + if err != nil { 32 + return fmt.Errorf("could not get churros responsible group for %s: %w", msg.ChurrosObjectId, err) 33 + } 34 + 35 + ll.Log("Sending", "green", "notification for %s on %s to %d users (%d subscriptions)", msg.Event, msg.ChurrosObjectId, len(users), len(subs)) 36 + 37 + // Parallelize sending the notifications 38 + for _, sub := range subs { 39 + ll.Debug("sending notification to %#v", sub) 40 + go func(sub Subscription) { 41 + if sub.IsWebpush() { 42 + err := msg.SendWebPush(group, sub) 43 + if err != nil { 44 + ll.ErrorDisplay("could not send webpush notification", err) 45 + } 46 + } else { 47 + ll.Warn("subscription for %s is not webpush, ignoring", sub.Owner.Uid) 48 + } 49 + }(sub) 50 + } 51 + 52 + return nil 53 + }
+4 -1
receiver.go
··· 18 18 return fmt.Errorf("while unmarshaling received message: %w", err) 19 19 } 20 20 21 - ll.Log("Received", "cyan", "%-10s | %s", message.Id, message.ChurrosObjectId) 21 + ll.Log("Received", "cyan", "%-10s | %-10s on %s", message.Id, message.Event, message.ChurrosObjectId) 22 22 CreateInDatabaseNotification(message, "feur") 23 + 24 + message.Schedule() 25 + 23 26 return nil 24 27 }
+15 -9
scheduler.go
··· 5 5 cmap "github.com/orcaman/concurrent-map/v2" 6 6 ) 7 7 8 - var schedules = cmap.New[ScheduledJob]() 8 + var schedules = cmap.New[Message]() 9 9 10 - func (job ScheduledJob) Unschedule() { 11 - schedules.Remove(job.ID) 10 + func (job Message) Unschedule() { 11 + schedules.Remove(job.Id) 12 12 } 13 13 14 - func (job ScheduledJob) Schedule() { 15 - schedules.Set(job.ID, job) 14 + func (job Message) Schedule() { 15 + ll.Log("Scheduling", "magenta", "%s for %s", job.Id, job.SendAt) 16 + schedules.Set(job.Id, job) 16 17 } 17 18 18 - func (job ScheduledJob) IsScheduled() bool { 19 - return schedules.Has(job.ID) 19 + func (job Message) IsScheduled() bool { 20 + return schedules.Has(job.Id) 20 21 } 21 22 22 23 // StartScheduler starts the scheduler loop, which runs forever ··· 24 25 for { 25 26 for _, job := range schedules.Items() { 26 27 if job.ShouldRun() { 27 - ll.Log("Running", "cyan", "job for %s on %s", job.Event, job.Object) 28 + ll.Log("Running", "cyan", "job for %s on %s", job.Event, job.ChurrosObjectId) 28 29 job.Unschedule() 29 - go job.Run() 30 + go func() { 31 + err := job.Run() 32 + if err != nil { 33 + ll.ErrorDisplay("could not run job %s", err, job.Id) 34 + } 35 + }() 30 36 } 31 37 } 32 38 }
+8 -15
server/main.go
··· 11 11 "time" 12 12 13 13 "git.inpt.fr/churros/notella" 14 - "github.com/caarlos0/env/v11" 15 14 "github.com/common-nighthawk/go-figure" 16 15 ll "github.com/ewen-lbh/label-logger-go" 17 16 "github.com/joho/godotenv" 18 17 "github.com/nats-io/nats.go" 19 18 ) 20 - 21 - type Configuration struct { 22 - Port int `env:"PORT" envDefault:"8080"` 23 - ChurrosApiUrl string `env:"CHURROS_API_URL" envDefault:"http://localhost:4000/graphql"` 24 - PollInterval int `env:"POLL_INTERVAL_MS" envDefault:"500"` 25 - RedisURL string `env:"REDIS_URL" envDefault:"redis://localhost:6379"` 26 - ChurrosDatabaseURL string `env:"DATABASE_URL"` 27 - } 28 19 29 20 var Version = "DEV" 30 21 ··· 41 32 ll.Info("loaded .env file") 42 33 } 43 34 44 - config := Configuration{} 45 - err := env.Parse(&config) 46 - if err != nil { 47 - ll.ErrorDisplay("could not load env variables", err) 48 - } 35 + config, _ := notella.LoadConfiguration() 49 36 50 37 ll.Info("Running with config ") 51 38 ll.Log("", "reset", "port: [bold]%d[reset]", config.Port) 39 + ll.Log("", "reset", "contact email: [bold]%s[reset]", config.ContactEmail) 52 40 ll.Log("", "reset", "Churros API URL: [bold]%s[reset]", redactURL(config.ChurrosApiUrl)) 53 41 ll.Log("", "reset", "Churros DB URL: [bold]%s[reset]", redactURL(config.ChurrosDatabaseURL)) 54 42 ll.Log("", "reset", "Redis URL: [bold]%s[reset]", redactURL(config.RedisURL)) 55 43 ll.Log("", "reset", "Poll interval: [bold]%d[reset] ms", config.PollInterval) 44 + if config.VapidPublicKey != "" && config.VapidPrivateKey != "" { 45 + ll.Log("", "reset", "VAPID keys: [bold][green]set[reset]") 46 + } else { 47 + ll.Log("", "reset", "VAPID keys: [bold][red]not set[reset]") 48 + } 56 49 fmt.Println() 57 50 58 51 ll.Info("starting scheduler") 59 52 go notella.StartScheduler() 60 53 61 54 ll.Log("Connecting", "cyan", "to Churros database at [bold]%s[reset]", config.ChurrosDatabaseURL) 62 - err = notella.ConnectToDababase() 55 + err := notella.ConnectToDababase() 63 56 if err != nil { 64 57 ll.ErrorDisplay("could not connect to database", err) 65 58 }
+4 -4
subscriptions.go
··· 20 20 Owner SubscriptionOwner `json:"owner"` 21 21 } 22 22 23 - var subscriptions []Subscription 24 - 25 - func notificationSubscriptionsFromDatabase() (subscriptions []Subscription, err error) { 23 + func subscriptionsOfUsers(ids []string) (subscriptions []Subscription, err error) { 26 24 if err := prisma.Prisma.Connect(); err != nil { 27 25 return nil, fmt.Errorf("could not connect to prisma: %w", err) 28 26 } 29 - subs, err := prisma.NotificationSubscription.FindMany().With(db.NotificationSubscription.Owner.Fetch()).Exec(context.Background()) 27 + subs, err := prisma.NotificationSubscription.FindMany( 28 + db.NotificationSubscription.OwnerID.In(ids), 29 + ).With(db.NotificationSubscription.Owner.Fetch()).Exec(context.Background()) 30 30 31 31 if err != nil { 32 32 return subscriptions, fmt.Errorf("while getting notification subscriptions from database: %w", err)
+3 -1
users.go
··· 10 10 // AllUsers returns all the users in the database that have at least one notification subscription 11 11 func AllUsers() ([]string, error) { 12 12 users, err := prisma.User.FindMany( 13 - db.User.NotificationSubscriptions.Some(), 13 + db.User.NotificationSubscriptions.Some( 14 + db.NotificationSubscription.Endpoint.Not(""), 15 + ), 14 16 ).Select( 15 17 db.User.ID.Field(), 16 18 ).Exec(context.Background())
+92
webpush.go
··· 1 + package notella 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + 7 + "git.inpt.fr/churros/notella/db" 8 + "github.com/SherClockHolmes/webpush-go" 9 + ll "github.com/ewen-lbh/label-logger-go" 10 + ) 11 + 12 + type WebPushNotification struct { 13 + Title string `json:"title"` 14 + Actions []webpushAction `json:"actions"` 15 + Badge string `json:"badge"` 16 + Icon string `json:"icon"` 17 + Image string `json:"image"` 18 + Body string `json:"body"` 19 + Renotify bool `json:"renotify"` 20 + RequireInteraction bool `json:"requireInteraction"` 21 + Silent bool `json:"silent"` 22 + Tag string `json:"tag"` 23 + Timestamp int64 `json:"timestamp"` 24 + Vibrate []int `json:"vibrate"` 25 + Data webpushNotificationData `json:"data"` 26 + } 27 + 28 + type webpushAction struct { 29 + Action string `json:"action"` 30 + Label string `json:"label"` 31 + Icon string `json:"icon"` 32 + } 33 + 34 + type webpushNotificationData struct { 35 + Group string `json:"group"` 36 + Channel db.NotificationChannel `json:"channel"` 37 + SubscriptionName string `json:"subscriptionName"` 38 + Goto string `json:"goto"` 39 + } 40 + 41 + func (msg Message) WebPush(groupId string) WebPushNotification { 42 + actions := make([]webpushAction, len(msg.Actions)) 43 + for i, action := range msg.Actions { 44 + actions[i] = webpushAction{ 45 + Action: action.Action, 46 + Label: action.Label, 47 + Icon: "", 48 + } 49 + } 50 + 51 + return WebPushNotification{ 52 + Title: msg.Title, 53 + Actions: actions, 54 + Badge: "", 55 + Icon: "", 56 + Image: msg.Image, 57 + Body: msg.Body, 58 + Data: webpushNotificationData{ 59 + Group: groupId, 60 + Channel: msg.Channel(), 61 + SubscriptionName: "", 62 + Goto: msg.Action, 63 + }, 64 + } 65 + } 66 + 67 + func (msg Message) SendWebPush(groupId string, sub Subscription) error { 68 + jsoned, err := json.Marshal(msg.WebPush(groupId)) 69 + if err != nil { 70 + ll.ErrorDisplay("could not marshal notification to json", err) 71 + } 72 + 73 + resp, err := webpush.SendNotification(jsoned, &sub.Webpush, &webpush.Options{ 74 + TTL: 30, 75 + Subscriber: config.ContactEmail, 76 + VAPIDPublicKey: config.VapidPublicKey, 77 + VAPIDPrivateKey: config.VapidPrivateKey, 78 + }) 79 + if err != nil { 80 + return fmt.Errorf("could not send notification to %s: %w", sub.Owner.Uid, err) 81 + } 82 + 83 + if resp.StatusCode >= 400 { 84 + return fmt.Errorf("could not send notification to %s: %s", sub.Owner.Uid, resp.Status) 85 + } 86 + 87 + return nil 88 + } 89 + 90 + func (sub Subscription) IsWebpush() bool { 91 + return sub.Webpush.Endpoint != "" 92 + }