2025-05-10 00:36:38 +01:00
package main
import (
"context"
2025-05-10 13:04:39 +01:00
"database/sql"
2025-05-10 00:36:38 +01:00
"embed"
_ "embed"
2025-05-10 13:04:39 +01:00
"encoding/json"
"errors"
2025-05-10 15:21:48 +01:00
"flag"
2025-05-10 00:36:38 +01:00
"fmt"
2025-05-10 13:04:39 +01:00
"github.com/SherClockHolmes/webpush-go"
2025-05-10 00:36:38 +01:00
"github.com/go-co-op/gocron/v2"
2025-05-10 13:04:39 +01:00
"github.com/google/uuid"
2025-05-10 00:36:38 +01:00
"github.com/joho/godotenv"
"google.golang.org/genai"
"html/template"
"io"
"log"
"mime"
2025-05-10 13:04:39 +01:00
_ "modernc.org/sqlite"
2025-05-10 00:36:38 +01:00
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
type apiKey struct {
openWeatherMap string
}
type location struct {
lat float32
lon float32
ianaName string
}
2025-05-10 15:13:31 +01:00
// pageTemplate stores all pre-compiled HTML templates for the application
2025-05-10 00:36:38 +01:00
type pageTemplate struct {
summary * template . Template
}
2025-05-10 15:13:31 +01:00
// summaryTemplateData stores template data for summary.html
2025-05-10 00:36:38 +01:00
type summaryTemplateData struct {
Summary string
Location string
}
2025-05-10 15:13:31 +01:00
// updateSubscription is the request body for creating/updating registration
2025-05-10 13:04:39 +01:00
type updateSubscription struct {
Subscription webpush . Subscription ` json:"subscription" `
Locations [ ] string ` json:"locations" `
}
2025-05-10 15:13:31 +01:00
// registeredSubscription represents a registered webpush subscription.
2025-05-10 13:04:39 +01:00
type registeredSubscription struct {
ID uuid . UUID ` json:"id" `
Subscription * webpush . Subscription ` json:"-" `
Locations [ ] string ` json:"locations" `
}
type state struct {
ctx context . Context
db * sql . DB
genai * genai . Client
apiKey apiKey
template pageTemplate
2025-05-10 15:13:31 +01:00
// summaries maps location keys to their latest weather summary
summaries sync . Map
// summaryChans stores a map of location key to the corresponding summary channel
// which is used to track summary updates
2025-05-10 13:04:39 +01:00
summaryChans map [ string ] chan string
2025-05-10 15:13:31 +01:00
// subscriptions maps location keys to the list of registered subscriptions
// that are subscribed to updates for the location
subscriptions map [ string ] [ ] registeredSubscription
// subscriptionsMutex syncs writes to subscriptions
2025-05-10 13:04:39 +01:00
subscriptionsMutex sync . Mutex
2025-05-10 15:13:31 +01:00
// vapidPublicKey is the base64 url encoded VAPID public key
vapidPublicKey string
// vapidPrivateKey is the base64 url encoded VAPID private key
2025-05-10 13:04:39 +01:00
vapidPrivateKey string
}
2025-05-10 00:36:38 +01:00
//go:embed web
var webDir embed . FS
2025-05-10 16:21:43 +01:00
var prompt = "The current time is 7am. Provide a summary of today's weather in %v below, as well as how to deal with the weather, such as how to dress for the weather, and whether they need an umbrella Use celsius and fahrenheit for temperature. Mention %v in the summary, but don't add anything else, as the summary will be displayed on a website."
2025-05-10 00:36:38 +01:00
var supportedLocations = map [ string ] location {
"london" : { 51.507351 , - 0.127758 , "Europe/London" } ,
"sf" : { 37.774929 , - 122.419418 , "America/Los_Angeles" } ,
"sj" : { 37.338207 , - 121.886330 , "America/Los_Angeles" } ,
"la" : { 34.052235 , - 118.243683 , "America/Los_Angeles" } ,
"nyc" : { 40.712776 , - 74.005974 , "America/New_York" } ,
"tokyo" : { 35.689487 , 139.691711 , "Asia/Tokyo" } ,
}
2025-05-10 16:21:43 +01:00
var locationNames = map [ string ] string {
"london" : "London" ,
"sf" : "San Francisco" ,
"sj" : "San Jose" ,
"la" : "Los Angeles" ,
"nyc" : "New York City" ,
"tokyo" : "Tokyo" ,
}
2025-05-10 00:36:38 +01:00
func main ( ) {
2025-05-10 15:21:48 +01:00
port := flag . Int ( "port" , 8080 , "the port that the server should listen on" )
2025-05-10 15:28:55 +01:00
genKeys := flag . Bool ( "generate-vapid-keys" , false , "generate a new vapid key pair, which will be outputted to stdout." )
flag . Parse ( )
if * genKeys {
generateKeys ( )
return
}
2025-05-10 15:21:48 +01:00
2025-05-10 00:36:38 +01:00
err := godotenv . Load ( )
if err != nil {
2025-05-10 14:58:41 +01:00
log . Fatalln ( "please create a .env file using the provided template!" )
2025-05-10 00:36:38 +01:00
}
2025-05-10 13:04:39 +01:00
db , err := initDB ( )
if err != nil {
log . Fatalf ( "failed to initialize db: %e\n" , err )
}
2025-05-10 00:36:38 +01:00
ctx , cancel := context . WithCancel ( context . Background ( ) )
defer cancel ( )
genaiClient , err := genai . NewClient ( ctx , & genai . ClientConfig {
APIKey : os . Getenv ( "GEMINI_API_KEY" ) ,
Backend : genai . BackendGeminiAPI ,
} )
if err != nil {
log . Fatalf ( "failed to initialize gemini client: %e\n" , err )
}
summaryHTML , _ := webDir . ReadFile ( "web/summary.html" )
summaryPageTemplate , _ := template . New ( "summary.html" ) . Parse ( string ( summaryHTML ) )
state := state {
ctx : ctx ,
2025-05-10 13:04:39 +01:00
db : db ,
2025-05-10 00:36:38 +01:00
apiKey : apiKey {
openWeatherMap : os . Getenv ( "OPEN_WEATHER_MAP_API_KEY" ) ,
} ,
template : pageTemplate {
summary : summaryPageTemplate ,
} ,
summaries : sync . Map { } ,
summaryChans : map [ string ] chan string { } ,
genai : genaiClient ,
2025-05-10 13:04:39 +01:00
subscriptions : map [ string ] [ ] registeredSubscription { } ,
vapidPublicKey : os . Getenv ( "VAPID_PUBLIC_KEY_BASE64" ) ,
vapidPrivateKey : os . Getenv ( "VAPID_PRIVATE_KEY_BASE64" ) ,
2025-05-10 00:36:38 +01:00
}
var schedulers [ ] gocron . Scheduler
2025-05-10 15:13:31 +01:00
// schedule periodic updates of weather summary for each supported location
2025-05-10 00:36:38 +01:00
for locKey , loc := range supportedLocations {
l , err := time . LoadLocation ( loc . ianaName )
if err != nil {
log . Fatal ( err )
}
s , err := gocron . NewScheduler ( gocron . WithLocation ( l ) )
if err != nil {
log . Fatal ( err )
}
_ , err = s . NewJob (
gocron . DurationJob ( time . Minute ) ,
2025-05-10 13:04:39 +01:00
gocron . NewTask ( updateSummaries , & state , locKey , & loc ) ,
gocron . WithStartAt ( gocron . WithStartImmediately ( ) ) ,
)
2025-05-10 00:36:38 +01:00
if err != nil {
log . Fatal ( err )
}
schedulers = append ( schedulers , s )
2025-05-10 13:04:39 +01:00
c := make ( chan string )
state . subscriptions [ locKey ] = [ ] registeredSubscription { }
state . summaryChans [ locKey ] = c
2025-05-10 15:13:31 +01:00
// listen for summary updates, and publish updates to all update subscribers via web push
2025-05-10 13:04:39 +01:00
go listenForSummaryUpdates ( & state , locKey )
2025-05-10 00:36:38 +01:00
s . Start ( )
}
2025-05-10 14:58:41 +01:00
err = loadSubscriptions ( & state )
if err != nil {
log . Fatalf ( "failed to load existing subscriptions: %e\n" , err )
}
2025-05-10 13:04:39 +01:00
2025-05-10 00:36:38 +01:00
http . HandleFunc ( "/" , handleHTTPRequest ( & state ) )
2025-05-10 15:21:48 +01:00
log . Printf ( "server listening on %d..." , * port )
err = http . ListenAndServe ( fmt . Sprintf ( ":%d" , * port ) , nil )
2025-05-10 14:58:41 +01:00
if err != nil {
log . Printf ( "failed to start http server: %e\n" , err )
}
2025-05-10 00:36:38 +01:00
for _ , s := range schedulers {
s . Shutdown ( )
}
}
2025-05-10 15:28:55 +01:00
func generateKeys ( ) {
priv , pub , err := webpush . GenerateVAPIDKeys ( )
if err != nil {
log . Fatal ( err )
}
fmt . Println ( "all keys are base64 url encoded." )
fmt . Printf ( "public key: %v\n" , pub )
fmt . Printf ( "private key: %v\n" , priv )
}
2025-05-10 00:36:38 +01:00
func handleHTTPRequest ( state * state ) http . HandlerFunc {
return func ( writer http . ResponseWriter , request * http . Request ) {
path := strings . TrimPrefix ( request . URL . Path , "/" )
2025-05-10 13:04:39 +01:00
if path == "" {
if request . Method == "" || request . Method == "GET" {
index , _ := webDir . ReadFile ( "web/index.html" )
writer . Write ( index )
} else {
writer . WriteHeader ( http . StatusMethodNotAllowed )
}
} else if path == "vapid" {
if request . Method == "" || request . Method == "GET" {
writer . Write ( [ ] byte ( state . vapidPublicKey ) )
} else {
writer . WriteHeader ( http . StatusMethodNotAllowed )
2025-05-10 00:36:38 +01:00
}
2025-05-10 13:04:39 +01:00
} else if strings . HasPrefix ( path , "registrations" ) {
if path == "registrations" && request . Method == "POST" {
defer request . Body . Close ( )
update := updateSubscription { }
err := json . NewDecoder ( request . Body ) . Decode ( & update )
if err != nil {
writer . WriteHeader ( http . StatusBadRequest )
return
}
2025-05-10 00:36:38 +01:00
2025-05-10 13:04:39 +01:00
reg , err := registerSubscription ( state , & update )
if err != nil {
writer . WriteHeader ( http . StatusBadRequest )
return
}
err = json . NewEncoder ( writer ) . Encode ( reg )
if err != nil {
writer . WriteHeader ( http . StatusBadRequest )
}
2025-05-10 14:58:41 +01:00
} else if request . Method == "PATCH" || request . Method == "DELETE" {
2025-05-10 13:04:39 +01:00
parts := strings . Split ( path , "/" )
if len ( parts ) < 2 {
writer . WriteHeader ( http . StatusMethodNotAllowed )
return
}
regID , err := uuid . Parse ( parts [ 1 ] )
if err != nil {
writer . WriteHeader ( http . StatusNotFound )
return
}
2025-05-10 14:58:41 +01:00
switch request . Method {
case "PATCH" :
defer request . Body . Close ( )
2025-05-10 13:04:39 +01:00
2025-05-10 14:58:41 +01:00
update := updateSubscription { }
err = json . NewDecoder ( request . Body ) . Decode ( & update )
if err != nil {
writer . WriteHeader ( http . StatusBadRequest )
return
}
2025-05-10 13:04:39 +01:00
2025-05-10 14:58:41 +01:00
reg , err := updateRegisteredSubscription ( state , regID , & update )
if err != nil {
if errors . Is ( err , sql . ErrNoRows ) {
writer . WriteHeader ( http . StatusNotFound )
} else {
writer . WriteHeader ( http . StatusInternalServerError )
}
2025-05-10 13:04:39 +01:00
} else {
2025-05-10 14:58:41 +01:00
json . NewEncoder ( writer ) . Encode ( reg )
}
case "DELETE" :
err = deleteSubscription ( state , regID )
if err != nil {
if errors . Is ( err , sql . ErrNoRows ) {
writer . WriteHeader ( http . StatusNotFound )
} else {
writer . WriteHeader ( http . StatusInternalServerError )
}
} else {
writer . WriteHeader ( http . StatusNoContent )
2025-05-10 13:04:39 +01:00
}
}
2025-05-10 00:36:38 +01:00
2025-05-10 13:04:39 +01:00
} else {
writer . WriteHeader ( http . StatusMethodNotAllowed )
}
} else {
if request . Method != "" && request . Method != "GET" {
writer . WriteHeader ( http . StatusMethodNotAllowed )
return
2025-05-10 00:36:38 +01:00
}
summary , ok := state . summaries . Load ( path )
if ok {
state . template . summary . Execute ( writer , summaryTemplateData { summary . ( string ) , path } )
} else {
f , err := webDir . ReadFile ( "web/" + path )
if err != nil {
2025-05-10 14:58:41 +01:00
writer . WriteHeader ( http . StatusNotFound )
2025-05-10 00:36:38 +01:00
} else {
m := mime . TypeByExtension ( filepath . Ext ( path ) )
if m != "" {
writer . Header ( ) . Set ( "Content-Type" , m )
}
writer . Write ( f )
}
}
}
}
}
2025-05-10 13:04:39 +01:00
func initDB ( ) ( * sql . DB , error ) {
db , err := sql . Open ( "sqlite" , "file:data.sqlite" )
if err != nil {
log . Fatalln ( "failed to initialize database" )
}
2025-05-10 00:36:38 +01:00
2025-05-10 13:04:39 +01:00
_ , err = db . Exec ( `
CREATE TABLE IF NOT EXISTS subscriptions (
id TEXT PRIMARY KEY ,
locations TEXT NOT NULL ,
subscription_json TEXT NOT NULL
) ;
` )
if err != nil {
return nil , err
}
return db , nil
}
func loadSubscriptions ( state * state ) error {
rows , err := state . db . Query ( ` SELECT id, locations, subscription_json FROM subscriptions; ` )
if err != nil {
return err
}
for rows . Next ( ) {
var id string
var locations string
var j string
err := rows . Scan ( & id , & locations , & j )
2025-05-10 00:36:38 +01:00
if err != nil {
2025-05-10 13:04:39 +01:00
continue
2025-05-10 00:36:38 +01:00
}
2025-05-10 13:04:39 +01:00
s := webpush . Subscription { }
err = json . Unmarshal ( [ ] byte ( j ) , & s )
if err != nil {
continue
}
2025-05-10 00:36:38 +01:00
2025-05-10 13:04:39 +01:00
reg := registeredSubscription {
ID : uuid . MustParse ( id ) ,
Locations : strings . Split ( locations , "," ) ,
Subscription : & s ,
}
for _ , l := range reg . Locations {
state . subscriptions [ l ] = append ( state . subscriptions [ l ] , reg )
2025-05-10 00:36:38 +01:00
}
}
2025-05-10 13:04:39 +01:00
return nil
}
func updateRegisteredSubscription ( state * state , id uuid . UUID , update * updateSubscription ) ( * registeredSubscription , error ) {
j , err := json . Marshal ( update . Subscription )
if err != nil {
return nil , err
}
_ , err = state . db . Exec (
"UPDATE subscriptions SET subscription_json = ?, locations = ? WHERE id = ?" ,
string ( j ) , strings . Join ( update . Locations , "," ) , id ,
)
if err != nil {
return nil , err
}
return & registeredSubscription {
ID : id ,
Subscription : & update . Subscription ,
Locations : update . Locations ,
} , nil
}
func registerSubscription ( state * state , sub * updateSubscription ) ( * registeredSubscription , error ) {
j , err := json . Marshal ( sub . Subscription )
if err != nil {
return nil , err
}
id , err := uuid . NewV7 ( )
if err != nil {
return nil , err
}
_ , err = state . db . Exec (
"INSERT INTO subscriptions (id, locations, subscription_json) VALUES (?, ?, ?);" ,
id , strings . Join ( sub . Locations , "," ) , string ( j ) ,
)
if err != nil {
return nil , err
}
reg := registeredSubscription {
ID : id ,
Subscription : & sub . Subscription ,
Locations : sub . Locations ,
}
for _ , l := range sub . Locations {
2025-05-10 15:13:31 +01:00
state . subscriptionsMutex . Lock ( )
2025-05-10 13:04:39 +01:00
state . subscriptions [ l ] = append ( state . subscriptions [ l ] , reg )
2025-05-10 15:13:31 +01:00
state . subscriptionsMutex . Unlock ( )
2025-05-10 13:04:39 +01:00
}
return & reg , nil
2025-05-10 00:36:38 +01:00
}
2025-05-10 14:58:41 +01:00
func deleteSubscription ( state * state , regID uuid . UUID ) error {
_ , err := state . db . Exec ( "DELETE FROM subscriptions WHERE id = ?" , regID )
return err
}
2025-05-10 00:36:38 +01:00
func updateSummaries ( state * state , locKey string , loc * location ) {
log . Printf ( "updating summary for %v...\n" , locKey )
resp , err := http . Get ( fmt . Sprintf ( "https://api.openweathermap.org/data/2.5/weather?lat=%f&lon=%f&appid=%v" , loc . lat , loc . lon , state . apiKey . openWeatherMap ) )
if err != nil {
log . Printf ( "error updating summaries for %s: %e\n" , locKey , err )
return
}
b , err := io . ReadAll ( resp . Body )
defer resp . Body . Close ( )
if err != nil {
log . Printf ( "error updating summaries for %s: %e\n" , locKey , err )
return
}
result , err := state . genai . Models . GenerateContent ( state . ctx , "gemini-2.0-flash" , [ ] * genai . Content { {
Parts : [ ] * genai . Part {
2025-05-10 16:21:43 +01:00
{ Text : fmt . Sprintf ( prompt , locationNames [ locKey ] ) } ,
2025-05-10 00:36:38 +01:00
{ Text : string ( b ) } ,
} ,
} } , nil )
if err != nil {
log . Printf ( "error updating summaries for %s: %e\n" , locKey , err )
return
}
summary := result . Text ( )
c := state . summaryChans [ locKey ]
state . summaries . Store ( locKey , summary )
2025-05-10 13:04:39 +01:00
if len ( state . subscriptions [ locKey ] ) > 0 {
2025-05-10 00:36:38 +01:00
c <- summary
}
log . Printf ( "updated summary for %v successfully\n" , locKey )
}
2025-05-10 13:04:39 +01:00
func listenForSummaryUpdates ( state * state , locKey string ) {
c := state . summaryChans [ locKey ]
2025-05-10 15:13:31 +01:00
opts := webpush . Options {
VAPIDPublicKey : state . vapidPublicKey ,
VAPIDPrivateKey : state . vapidPrivateKey ,
TTL : 30 ,
}
2025-05-10 13:04:39 +01:00
for {
select {
case summary := <- c :
log . Printf ( "sending summary for %v to subscribers...\n" , locKey )
2025-05-10 14:58:41 +01:00
var wg sync . WaitGroup
2025-05-10 13:04:39 +01:00
for _ , sub := range state . subscriptions [ locKey ] {
2025-05-10 14:58:41 +01:00
wg . Add ( 1 )
go func ( ) {
defer wg . Done ( )
2025-05-10 15:13:31 +01:00
_ , err := webpush . SendNotificationWithContext ( state . ctx , [ ] byte ( summary ) , sub . Subscription , & opts )
2025-05-10 14:58:41 +01:00
if err != nil {
log . Printf ( "failed to send summary for %v to sub id %v: %e\n" , locKey , sub . ID , err )
}
} ( )
2025-05-10 13:04:39 +01:00
}
2025-05-10 14:58:41 +01:00
wg . Wait ( )
2025-05-10 13:04:39 +01:00
case <- state . ctx . Done ( ) :
return
}
}
}