Compare commits
	
		
			10 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 1a4b4f3d0a | |||
| 7e60e39a11 | |||
| d87b197cc0 | |||
| 9db30c7e80 | |||
| b619b70d22 | |||
| 7db71b94b1 | |||
| f135020914 | |||
| 0c6003a894 | |||
| 4c986119f9 | |||
| 6712864da0 | 
							
								
								
									
										10
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -1,3 +1,13 @@ | |||||||
|  | tz | ||||||
|  | tzdb | ||||||
|  | 
 | ||||||
|  | db.json | ||||||
|  | *.bak | ||||||
|  | *.tmp | ||||||
|  | .*.sw* | ||||||
|  | /cmd/again/again | ||||||
|  | /again | ||||||
|  | 
 | ||||||
| # ---> Go | # ---> Go | ||||||
| # Binaries for programs and plugins | # Binaries for programs and plugins | ||||||
| *.exe | *.exe | ||||||
|  | |||||||
							
								
								
									
										64
									
								
								again.go
									
									
									
									
									
								
							
							
						
						
									
										64
									
								
								again.go
									
									
									
									
									
								
							| @ -3,10 +3,32 @@ package again | |||||||
| import ( | import ( | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"time" | 	"time" | ||||||
|  | 
 | ||||||
|  | 	webhooks "git.rootprojects.org/root/go-again/webhooks" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | type Webhook webhooks.Webhook | ||||||
|  | 
 | ||||||
| type Schedule struct { | type Schedule struct { | ||||||
| 	NextRunAt time.Time | 	ID        string    `json:"id" db:"id"` | ||||||
|  | 	AccessID  string    `json:"-" db:"access_id"` | ||||||
|  | 	Date      string    `json:"date" db:"date"` | ||||||
|  | 	Time      string    `json:"time" db:"time"` | ||||||
|  | 	TZ        string    `json:"tz" db:"tz"` | ||||||
|  | 	NextRunAt time.Time `json:"next_run_at" db:"next_run_at"` | ||||||
|  | 	Webhooks  []Webhook `json:"webhooks" db"webhooks"` | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type Schedules []*Schedule | ||||||
|  | 
 | ||||||
|  | func (s Schedules) Len() int { | ||||||
|  | 	return len(s) | ||||||
|  | } | ||||||
|  | func (s Schedules) Less(i, j int) bool { | ||||||
|  | 	return s[i].NextRunAt.Sub(s[j].NextRunAt) < 0 | ||||||
|  | } | ||||||
|  | func (s Schedules) Swap(i, j int) { | ||||||
|  | 	s[j], s[i] = s[i], s[j] | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // https://yourbasic.org/golang/time-change-convert-location-timezone/ | // https://yourbasic.org/golang/time-change-convert-location-timezone/ | ||||||
| @ -38,7 +60,7 @@ func Run() { | |||||||
| 		[]int{2019, 11, 10, 23, 59, 59, 0}, | 		[]int{2019, 11, 10, 23, 59, 59, 0}, | ||||||
| 		[]int{2019, 11, 31, 23, 0, 0, 0}, | 		[]int{2019, 11, 31, 23, 0, 0, 0}, | ||||||
| 	} { | 	} { | ||||||
| 		err := Exists(st, "America/Denver") | 		_, err := Exists(st, "America/Denver") | ||||||
| 		if nil != err { | 		if nil != err { | ||||||
| 			fmt.Println(err) | 			fmt.Println(err) | ||||||
| 		} | 		} | ||||||
| @ -107,58 +129,58 @@ func (err ErrNoExist) Error() string { | |||||||
| //     fmt.Println(time.Date(2016, time.December, 31, 23, 59, 60, 0, time.UTC)) | //     fmt.Println(time.Date(2016, time.December, 31, 23, 59, 60, 0, time.UTC)) | ||||||
| //     "2020-12-02 02:00:00 +0000 UTC" // should be "2016-12-31 23:59:60 +0000 UTC" | //     "2020-12-02 02:00:00 +0000 UTC" // should be "2016-12-31 23:59:60 +0000 UTC" | ||||||
| // | // | ||||||
| func Exists(st []int, tzstr string) error { | func Exists(st []int, tzstr string) (*time.Time, error) { | ||||||
| 	tz, err := time.LoadLocation(tzstr) | 	tz, err := time.LoadLocation(tzstr) | ||||||
| 	if nil != err { | 	if nil != err { | ||||||
| 		return err | 		return nil, err | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	m := time.Month(st[1]) | 	m := time.Month(st[1]) | ||||||
| 	t1 := time.Date(st[0], m, st[2], st[3], st[4], st[5], st[6], tz) | 	t1 := time.Date(st[0], m, st[2], st[3], st[4], st[5], 0, tz) | ||||||
| 	if st[5] != t1.Second() { | 	if st[5] != t1.Second() { | ||||||
| 		return ErrNoExist{ | 		return nil, ErrNoExist{ | ||||||
| 			t: st, | 			t: st, | ||||||
| 			z: tzstr, | 			z: tzstr, | ||||||
| 			e: "invalid second, probably just bad math on your part", | 			e: "invalid second, probably just bad math on your part", | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 	if st[4] != t1.Minute() { | 	if st[4] != t1.Minute() { | ||||||
| 		return ErrNoExist{ | 		return nil, ErrNoExist{ | ||||||
| 			t: st, | 			t: st, | ||||||
| 			z: tzstr, | 			z: tzstr, | ||||||
| 			e: "invalid minute, probably just bad math on your part, but perhaps a half-hour daylight savings or summer time", | 			e: "invalid minute, probably just bad math on your part, but perhaps a half-hour daylight savings or summer time", | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 	if st[3] != t1.Hour() { | 	if st[3] != t1.Hour() { | ||||||
| 		return ErrNoExist{ | 		return nil, ErrNoExist{ | ||||||
| 			t: st, | 			t: st, | ||||||
| 			z: tzstr, | 			z: tzstr, | ||||||
| 			e: "invalid hour, possibly a Daylight Savings or Summer Time error, or perhaps bad math on your part", | 			e: "invalid hour, possibly a Daylight Savings or Summer Time error, or perhaps bad math on your part", | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 	if st[2] != t1.Day() { | 	if st[2] != t1.Day() { | ||||||
| 		return ErrNoExist{ | 		return nil, ErrNoExist{ | ||||||
| 			t: st, | 			t: st, | ||||||
| 			z: tzstr, | 			z: tzstr, | ||||||
| 			e: "invalid day of month, most likely bad math on your part. Remember: 31 28¼ 31 30 31 30 31 31 30 31 30 31", | 			e: "invalid day of month, most likely bad math on your part. Remember: 31 28¼ 31 30 31 30 31 31 30 31 30 31", | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 	if st[1] != int(t1.Month()) { | 	if st[1] != int(t1.Month()) { | ||||||
| 		return ErrNoExist{ | 		return nil, ErrNoExist{ | ||||||
| 			t: st, | 			t: st, | ||||||
| 			z: tzstr, | 			z: tzstr, | ||||||
| 			e: "invalid month, most likely bad math on your part. Remember: Decemberween isn't until next year", | 			e: "invalid month, most likely bad math on your part. Remember: Decemberween isn't until next year", | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 	if st[0] != t1.Year() { | 	if st[0] != t1.Year() { | ||||||
| 		return ErrNoExist{ | 		return nil, ErrNoExist{ | ||||||
| 			t: st, | 			t: st, | ||||||
| 			z: tzstr, | 			z: tzstr, | ||||||
| 			e: "invalid year, must have reached the end of time...", | 			e: "invalid year, must have reached the end of time...", | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return nil | 	return &t1, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // Check if the time happens more than once in a given timezone. | // Check if the time happens more than once in a given timezone. | ||||||
| @ -198,21 +220,21 @@ func IsAmbiguous(st []int, tzstr string) error { | |||||||
| 	if nil != err { | 	if nil != err { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
| 	m := time.Month(st[1]) | 	m := time.Month(st[1]) | ||||||
| 	t1 := time.Date(st[0], m, st[2], st[3], st[4], st[5], st[6], tz) | 	t1 := time.Date(st[0], m, st[2], st[3], st[4], st[5], 0, tz) | ||||||
| 	u1 := t1.UTC() | 	u1 := t1.UTC() | ||||||
| 	// A better way to do this would probably be to parse the timezone database, but... yeah... | 	// Australia/Lord_Howe has a 30-minute DST | ||||||
| 	for _, n := range []int{ /*-120, -60,*/ 30, 60, 120} { | 	// 60-minute DST is common | ||||||
| 		t2 := time.Date(st[0], m, st[2], st[3], st[4]+n, st[5], st[6], tz) | 	// Antarctica/Troll has a 120-minute DST | ||||||
|  | 	for _, n := range []int{30, 60, 120} { | ||||||
|  | 		t2 := time.Date(st[0], m, st[2], st[3], st[4]+n, st[5], 0, tz) | ||||||
| 		u2 := t2.UTC() | 		u2 := t2.UTC() | ||||||
| 		if u1.Equal(u2) { | 		if u1.Equal(u2) { | ||||||
| 			fmt.Println("Ambiguous Time") | 			return fmt.Errorf("Ambiguous: %s, %s, %+d\n", t1, t2, n) | ||||||
| 			fmt.Printf("%s, %s, %+d\n", t1, u1, n) |  | ||||||
| 			fmt.Printf("%s, %s, %+d\n", t2, u2, n) |  | ||||||
| 			return fmt.Errorf("Ambiguous") |  | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 	//ta := | 
 | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | |||||||
							
								
								
									
										
											BIN
										
									
								
								cmd/again/again
									
									
									
									
									
								
							
							
						
						
									
										
											BIN
										
									
								
								cmd/again/again
									
									
									
									
									
								
							
										
											Binary file not shown.
										
									
								
							| @ -1,22 +1,30 @@ | |||||||
| package main | package main | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
|  | 	"context" | ||||||
|  | 	"encoding/json" | ||||||
| 	"flag" | 	"flag" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"log" | 	"log" | ||||||
|  | 	"math/rand" | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"os" | 	"os" | ||||||
| 	"strconv" | 	"strconv" | ||||||
|  | 	"strings" | ||||||
| 	"time" | 	"time" | ||||||
| 
 | 
 | ||||||
|  | 	again "git.rootprojects.org/root/go-again" | ||||||
| 	"git.rootprojects.org/root/go-again/data/jsondb" | 	"git.rootprojects.org/root/go-again/data/jsondb" | ||||||
|  | 	webhooks "git.rootprojects.org/root/go-again/webhooks" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| func main() { | func main() { | ||||||
| 	portEnv := os.Getenv("PORT") | 	portEnv := os.Getenv("PORT") | ||||||
|  | 	dbEnv := os.Getenv("DATABASE_URL") | ||||||
| 
 | 
 | ||||||
| 	portInt := flag.Int("port", 0, "port on which to serve http") | 	portInt := flag.Int("port", 0, "port on which to serve http") | ||||||
| 	addr := flag.String("addr", "", "address on which to serve http") | 	addr := flag.String("addr", "", "address on which to serve http") | ||||||
|  | 	dburl := flag.String("database-url", "", "For example: json://relative-path/db.json or json:///absolute-path/db.json") | ||||||
| 	flag.Parse() | 	flag.Parse() | ||||||
| 
 | 
 | ||||||
| 	if "" != portEnv { | 	if "" != portEnv { | ||||||
| @ -32,24 +40,300 @@ func main() { | |||||||
| 		*portInt = n | 		*portInt = n | ||||||
| 	} | 	} | ||||||
| 	if *portInt < 1024 || *portInt > 65535 { | 	if *portInt < 1024 || *portInt > 65535 { | ||||||
| 		log.Fatalf("port should be between 1024 and 65535, not %d.", *portInt) | 		log.Fatalf("`port` should be between 1024 and 65535, not %d.", *portInt) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 	portEnv = strconv.Itoa(*portInt) | 	portEnv = strconv.Itoa(*portInt) | ||||||
| 
 | 
 | ||||||
|  | 	if "" != dbEnv { | ||||||
|  | 		if "" != *dburl { | ||||||
|  | 			log.Fatal("You may set DATABASE_URL or --database-url, but not both.") | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 		*dburl = dbEnv | ||||||
|  | 		// TODO parse string? | ||||||
|  | 		// TODO have each connector try in sequence by registering with build tags like go-migrate does? | ||||||
|  | 	} | ||||||
|  | 	if "" == *dburl { | ||||||
|  | 		log.Fatalf("`database-url` must be specified." + | ||||||
|  | 			" Something like --database-url='json:///var/go-again/db.json' should do nicely.") | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	db, err := jsondb.Connect(*dburl) | ||||||
|  | 	if nil != err { | ||||||
|  | 		log.Fatalf("Could not connect to database %q: %s", *dburl, err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	s := &scheduler{ | ||||||
|  | 		DB: db, | ||||||
|  | 	} | ||||||
|  | 	mux := http.NewServeMux() | ||||||
| 	server := &http.Server{ | 	server := &http.Server{ | ||||||
| 		Addr:           fmt.Sprintf("%s:%s", *addr, portEnv), | 		Addr:           fmt.Sprintf("%s:%s", *addr, portEnv), | ||||||
| 		Handler:        http.HandlerFunc(handleFunc), | 		Handler:        mux, | ||||||
| 		ReadTimeout:    10 * time.Second, | 		ReadTimeout:    10 * time.Second, | ||||||
| 		WriteTimeout:   10 * time.Second, | 		WriteTimeout:   10 * time.Second, | ||||||
| 		MaxHeaderBytes: 1 << 20, | 		MaxHeaderBytes: 1 << 20, | ||||||
| 	} | 	} | ||||||
|  | 	//mux.Handle("/api/", http.HandlerFunc(handleFunc)) | ||||||
|  | 	mux.HandleFunc("/api/v0/schedules", s.Handle) | ||||||
|  | 	mux.HandleFunc("/api/v0/schedules/", s.Handle) | ||||||
|  | 
 | ||||||
|  | 	// TODO Filebox FS | ||||||
|  | 	mux.Handle("/", http.FileServer(http.Dir("./public"))) | ||||||
|  | 
 | ||||||
|  | 	go s.RunTasks() | ||||||
| 
 | 
 | ||||||
| 	fmt.Println("Listening on", server.Addr) | 	fmt.Println("Listening on", server.Addr) | ||||||
| 	log.Fatal(server.ListenAndServe()) | 	log.Fatal(server.ListenAndServe()) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func handleFunc(w http.ResponseWriter, r *http.Request) { | type ScheduleDB interface { | ||||||
| 	jsondb.List() | 	List(string) ([]*again.Schedule, error) | ||||||
| 	w.Write([]byte("Hello, World!")) | 	Set(again.Schedule) (*again.Schedule, error) | ||||||
|  | 	Delete(accessID string, id string) (*again.Schedule, error) | ||||||
|  | 	Upcoming(min time.Time, max time.Time) ([]*again.Schedule, error) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type scheduler struct { | ||||||
|  | 	DB ScheduleDB | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (s *scheduler) RunTasks() { | ||||||
|  | 	log.Println("[info] Task Queue Started") | ||||||
|  | 	// Tick every 4 minutes, | ||||||
|  | 	// but run tasks for up to 5 minutes before getting more. | ||||||
|  | 	ticker := time.NewTicker(4 * time.Minute) | ||||||
|  | 
 | ||||||
|  | 	// TODO some way to add things to the live queue | ||||||
|  | 	// (maybe a select between the ticker and an incoming channel) | ||||||
|  | 
 | ||||||
|  | 	// 'min' should be >= 'last'  at least one second | ||||||
|  | 	last := time.Now() | ||||||
|  | 	for { | ||||||
|  | 		min := time.Now() | ||||||
|  | 		if last.Unix() > min.Unix() { | ||||||
|  | 			min = last | ||||||
|  | 		} | ||||||
|  | 		max := min.Add(5 * time.Minute) | ||||||
|  | 		scheds, err := s.DB.Upcoming(min, max) | ||||||
|  | 		if nil != err { | ||||||
|  | 			// this seems pretty unrecoverable | ||||||
|  | 			// TODO check DB, reconnect | ||||||
|  | 			os.Exit(911) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 		log.Printf("[info] Got %d upcoming tasks", len(scheds)) | ||||||
|  | 		log.Println(time.Now()) | ||||||
|  | 		log.Println(min) | ||||||
|  | 		log.Println(max) | ||||||
|  | 		log.Println() | ||||||
|  | 		log.Println(time.Now().UTC()) | ||||||
|  | 		log.Println(min.UTC()) | ||||||
|  | 		log.Println(max.UTC()) | ||||||
|  | 
 | ||||||
|  | 		for i := range scheds { | ||||||
|  | 			sched := scheds[i] | ||||||
|  | 			fmt.Println("it's in the queue") | ||||||
|  | 			sleep := sched.NextRunAt.Sub(time.Now()) | ||||||
|  | 			// TODO create ticker to select on instead | ||||||
|  | 			time.Sleep(sleep) | ||||||
|  | 
 | ||||||
|  | 			fmt.Println("it's happening") | ||||||
|  | 			for i := range sched.Webhooks { | ||||||
|  | 				h := sched.Webhooks[i] | ||||||
|  | 				h.TZ = sched.TZ | ||||||
|  | 				webhooks.Run(webhooks.Webhook(h)) | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			// we only deal in second resulotion | ||||||
|  | 			last = sched.NextRunAt.Add(1 * time.Second) | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		<-ticker.C | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (s *scheduler) Handle(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	// note: no go-routines reading body in handlers to follow | ||||||
|  | 	defer r.Body.Close() | ||||||
|  | 
 | ||||||
|  | 	token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") | ||||||
|  | 	if 32 != len(token) { | ||||||
|  | 		http.Error(w, "Authorization Header did not contain a valid token", http.StatusForbidden) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	ctx := r.Context() | ||||||
|  | 	ctx = context.WithValue(ctx, "token", token) | ||||||
|  | 	r = r.WithContext(ctx) | ||||||
|  | 
 | ||||||
|  | 	switch r.Method { | ||||||
|  | 	case http.MethodGet: | ||||||
|  | 		s.List(w, r) | ||||||
|  | 		return | ||||||
|  | 	case http.MethodPost: | ||||||
|  | 		s.Create(w, r) | ||||||
|  | 		return | ||||||
|  | 	case http.MethodDelete: | ||||||
|  | 		s.Delete(w, r) | ||||||
|  | 		return | ||||||
|  | 	default: | ||||||
|  | 		http.Error(w, "Not Implemented", http.StatusNotImplemented) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (s *scheduler) List(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	accessID := r.Context().Value("token").(string) | ||||||
|  | 	schedules, err := s.DB.List(accessID) | ||||||
|  | 	if nil != err { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	buf, err := json.Marshal(schedules) | ||||||
|  | 	if nil != err { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	w.Write(buf) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (s *scheduler) Create(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	// TODO validate user | ||||||
|  | 	accessID := r.Context().Value("token").(string) | ||||||
|  | 
 | ||||||
|  | 	/* | ||||||
|  | 		br, bw := io.Pipe() | ||||||
|  | 		b := io.TeeReader(r.Body, bw) | ||||||
|  | 		go func() { | ||||||
|  | 			x, _ := ioutil.ReadAll(b) | ||||||
|  | 			fmt.Println("[debug] http body", string(x)) | ||||||
|  | 			bw.Close() | ||||||
|  | 		}() | ||||||
|  | 		decoder := json.NewDecoder(br) | ||||||
|  | 	*/ | ||||||
|  | 	decoder := json.NewDecoder(r.Body) | ||||||
|  | 	sched := &again.Schedule{} | ||||||
|  | 	err := decoder.Decode(sched) | ||||||
|  | 	if nil != err { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	fmt.Printf("New Schedule:\n%#v\n", sched) | ||||||
|  | 
 | ||||||
|  | 	// TODO validate and modify | ||||||
|  | 	dateParts := strings.Split(sched.Date, "-") | ||||||
|  | 	if 3 != len(dateParts) { | ||||||
|  | 		http.Error(w, "Invalid date", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	timeParts := strings.Split(sched.Time, ":") | ||||||
|  | 	if 2 == len(timeParts) { | ||||||
|  | 		timeParts = append(timeParts, "00") | ||||||
|  | 	} | ||||||
|  | 	if 3 != len(timeParts) { | ||||||
|  | 		http.Error(w, "Invalid time", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	// sub-minute resolution not supported yet | ||||||
|  | 	timeParts[2] = "00" | ||||||
|  | 
 | ||||||
|  | 	dtParts := []int{0, 0, 0, 0, 0, 0} | ||||||
|  | 	for i := range dateParts { | ||||||
|  | 		n, err := strconv.Atoi(dateParts[i]) | ||||||
|  | 		if nil != err { | ||||||
|  | 			http.Error(w, fmt.Sprintf("Invalid date part '%s'", n), http.StatusBadRequest) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 		dtParts[i] = n | ||||||
|  | 	} | ||||||
|  | 	for i := range timeParts { | ||||||
|  | 		n, err := strconv.Atoi(timeParts[i]) | ||||||
|  | 		if nil != err { | ||||||
|  | 			http.Error(w, fmt.Sprintf("Invalid time part '%s'", n), http.StatusBadRequest) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 		dtParts[i+3] = n | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	_, err = again.Exists(dtParts, time.UTC.String()) | ||||||
|  | 	if nil != err { | ||||||
|  | 		http.Error(w, fmt.Sprintf("Invalid datetime: %s", err), http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// TODO warn on non-existant / ambiguous timing | ||||||
|  | 	loc, err := time.LoadLocation(sched.TZ) | ||||||
|  | 	if nil != err { | ||||||
|  | 		http.Error(w, fmt.Sprintf("Invalid timezone: %s", err), http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	t := time.Date(dtParts[0], time.Month(dtParts[1]), dtParts[2], dtParts[3], dtParts[4], dtParts[5], 0, loc).UTC() | ||||||
|  | 	now := time.Now().UTC() | ||||||
|  | 	// 4.5 minutes (about 5 minutes) | ||||||
|  | 	ahead := t.Sub(now) | ||||||
|  | 	if ahead < 270 { | ||||||
|  | 		http.Error(w, | ||||||
|  | 			fmt.Sprintf("Invalid datetime: should be 5+ minutes into the future, not just %d seconds", ahead), | ||||||
|  | 			http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	fmt.Println("Time in UTC:", t) | ||||||
|  | 	// stagger | ||||||
|  | 	t = t.Add(time.Duration(rand.Intn(300*1000)-150*1000) * time.Millisecond) | ||||||
|  | 	// Avoid the Leap Second | ||||||
|  | 	if 23 == t.Hour() && 59 == t.Minute() && 59 == t.Second() { | ||||||
|  | 		j := rand.Intn(1) - 2 | ||||||
|  | 		n := rand.Intn(3) | ||||||
|  | 		// +/- 3 seconds | ||||||
|  | 		t = t.Add(time.Duration(j*n) * time.Second) | ||||||
|  | 	} | ||||||
|  | 	fmt.Println("Staggered Time:", t) | ||||||
|  | 
 | ||||||
|  | 	// TODO add to immediate queue if soon enough | ||||||
|  | 	sched.NextRunAt = t | ||||||
|  | 	sched.AccessID = accessID | ||||||
|  | 	sched2, err := s.DB.Set(*sched) | ||||||
|  | 	if nil != err { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	buf, err := json.Marshal(sched2) | ||||||
|  | 	if nil != err { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	w.Write(buf) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (s *scheduler) Delete(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	// TODO validate user | ||||||
|  | 	accessID := r.Context().Value("token").(string) | ||||||
|  | 	parts := strings.Split(r.URL.Path, "/") | ||||||
|  | 
 | ||||||
|  | 	// ""/"api"/"v0"/"schedules"/":id" | ||||||
|  | 	if 5 != len(parts) { | ||||||
|  | 		http.Error(w, "Not Found", http.StatusNotFound) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	id := parts[4] | ||||||
|  | 	sched2, err := s.DB.Delete(accessID, id) | ||||||
|  | 	if nil != err { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	buf, err := json.Marshal(sched2) | ||||||
|  | 	if nil != err { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	w.Write(buf) | ||||||
| } | } | ||||||
|  | |||||||
| @ -1,11 +1,290 @@ | |||||||
|  | // A JSON storage strategy for Go Again | ||||||
|  | // SQL would be a better choice, but... meh | ||||||
|  | // | ||||||
|  | // Note that we use mutexes instead of channels | ||||||
|  | // because everything is both synchronous and | ||||||
|  | // sequential. Meh. | ||||||
| package jsondb | package jsondb | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"errors" | 	"crypto/rand" | ||||||
|  | 	"crypto/subtle" | ||||||
|  | 	"encoding/hex" | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"fmt" | ||||||
|  | 	"net/url" | ||||||
|  | 	"os" | ||||||
|  | 	"path/filepath" | ||||||
|  | 	"sort" | ||||||
|  | 	"strings" | ||||||
|  | 	"sync" | ||||||
|  | 	"time" | ||||||
| 
 | 
 | ||||||
| 	again "git.rootprojects.org/root/go-again" | 	"git.rootprojects.org/root/go-again" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| func List() ([]again.Schedule, error) { | type JSONDB struct { | ||||||
| 	return nil, errors.New("Not Implemented") | 	dburl string | ||||||
|  | 	path  string | ||||||
|  | 	json  *dbjson | ||||||
|  | 	mux   sync.Mutex | ||||||
|  | 	fmux  sync.Mutex | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type dbjson struct { | ||||||
|  | 	Schedules []Schedule `json:"schedules"` | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func Connect(dburl string) (*JSONDB, error) { | ||||||
|  | 	u, err := url.Parse(dburl) | ||||||
|  | 	if nil != err { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// json:/abspath/to/db.json | ||||||
|  | 	path := u.Opaque | ||||||
|  | 	if "" == path { | ||||||
|  | 		// json:///abspath/to/db.json | ||||||
|  | 		path = u.Path | ||||||
|  | 		if "" == path { | ||||||
|  | 			// json:relpath/to/db.json | ||||||
|  | 			// json://relpath/to/db.json | ||||||
|  | 			path = strings.TrimSuffix(u.Host+"/"+u.Path, "/") | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0700) | ||||||
|  | 	if nil != err { | ||||||
|  | 		return nil, fmt.Errorf("Couldn't open %q: %s", path, err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	stat, err := f.Stat() | ||||||
|  | 	if 0 == stat.Size() { | ||||||
|  | 		_, err := f.Write([]byte(`{"schedules":[]}`)) | ||||||
|  | 		f.Close() | ||||||
|  | 		if nil != err { | ||||||
|  | 			return nil, err | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		f, err = os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0700) | ||||||
|  | 		if nil != err { | ||||||
|  | 			return nil, err | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	decoder := json.NewDecoder(f) | ||||||
|  | 	db := &dbjson{} | ||||||
|  | 	err = decoder.Decode(db) | ||||||
|  | 	f.Close() | ||||||
|  | 	if nil != err { | ||||||
|  | 		return nil, fmt.Errorf("Couldn't parse %q as JSON: %s", path, err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	wd, _ := os.Getwd() | ||||||
|  | 	fmt.Println("jsondb:", filepath.Join(wd, path)) | ||||||
|  | 	return &JSONDB{ | ||||||
|  | 		dburl: dburl, | ||||||
|  | 		path:  path, | ||||||
|  | 		json:  db, | ||||||
|  | 		mux:   sync.Mutex{}, | ||||||
|  | 		fmux:  sync.Mutex{}, | ||||||
|  | 	}, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // A copy of again.Schedule, but with access_id json-able | ||||||
|  | type Schedule struct { | ||||||
|  | 	ID        string          `json:"id" db:"id"` | ||||||
|  | 	AccessID  string          `json:"access_id" db:"access_id"` | ||||||
|  | 	Date      string          `json:"date" db:"date"` | ||||||
|  | 	Time      string          `json:"time" db:"time"` | ||||||
|  | 	TZ        string          `json:"tz" db:"tz"` | ||||||
|  | 	NextRunAt time.Time       `json:"next_run_at" db:"next_run_at"` | ||||||
|  | 	Disabled  bool            `json:"disabled" db:"disabled"` | ||||||
|  | 	Webhooks  []again.Webhook `json:"webhooks" db"webhooks"` | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (db *JSONDB) List(accessID string) ([]*again.Schedule, error) { | ||||||
|  | 	nowish := time.Now().Add(time.Duration(30) * time.Second) | ||||||
|  | 	schedules := []*again.Schedule{} | ||||||
|  | 	for i := range db.json.Schedules { | ||||||
|  | 		s := db.json.Schedules[i] | ||||||
|  | 		if !s.Disabled && ctcmp(accessID, s.AccessID) && s.NextRunAt.Sub(nowish) > 0 { | ||||||
|  | 			schedules = append(schedules, &again.Schedule{ | ||||||
|  | 				ID:        s.ID, | ||||||
|  | 				AccessID:  s.AccessID, | ||||||
|  | 				Date:      s.Date, | ||||||
|  | 				Time:      s.Time, | ||||||
|  | 				TZ:        s.TZ, | ||||||
|  | 				NextRunAt: s.NextRunAt, | ||||||
|  | 				Webhooks:  s.Webhooks, | ||||||
|  | 			}) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return schedules, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (db *JSONDB) Set(s again.Schedule) (*again.Schedule, error) { | ||||||
|  | 	exists := false | ||||||
|  | 	index := -1 | ||||||
|  | 	if "" == s.ID { | ||||||
|  | 		id, err := genID(16) | ||||||
|  | 		if nil != err { | ||||||
|  | 			return nil, err | ||||||
|  | 		} | ||||||
|  | 		s.ID = id | ||||||
|  | 	} else { | ||||||
|  | 		i, old := db.get(s.ID) | ||||||
|  | 		index = i | ||||||
|  | 		exists = nil != old | ||||||
|  | 		// TODO constant time bail | ||||||
|  | 		if !exists || !ctcmp(old.AccessID, s.AccessID) { | ||||||
|  | 			return nil, fmt.Errorf("invalid id") | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	schedule := Schedule{ | ||||||
|  | 		ID:        s.ID, | ||||||
|  | 		AccessID:  s.AccessID, | ||||||
|  | 		Date:      s.Date, | ||||||
|  | 		Time:      s.Time, | ||||||
|  | 		TZ:        s.TZ, | ||||||
|  | 		NextRunAt: s.NextRunAt, | ||||||
|  | 		Webhooks:  s.Webhooks, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if exists { | ||||||
|  | 		db.mux.Lock() | ||||||
|  | 		db.json.Schedules[index] = schedule | ||||||
|  | 		db.mux.Unlock() | ||||||
|  | 	} else { | ||||||
|  | 		db.mux.Lock() | ||||||
|  | 		db.json.Schedules = append(db.json.Schedules, schedule) | ||||||
|  | 		db.mux.Unlock() | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	err := db.save(s.AccessID) | ||||||
|  | 	if nil != err { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return &s, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (db *JSONDB) Delete(accessID string, id string) (*again.Schedule, error) { | ||||||
|  | 	_, old := db.get(id) | ||||||
|  | 	exists := nil != old | ||||||
|  | 	// TODO constant time bail | ||||||
|  | 	if !exists || !ctcmp(old.AccessID, accessID) { | ||||||
|  | 		return nil, fmt.Errorf("invalid id") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Copy everything we keep into its own array | ||||||
|  | 	newSchedules := []Schedule{} | ||||||
|  | 	for i := range db.json.Schedules { | ||||||
|  | 		schedule := db.json.Schedules[i] | ||||||
|  | 		if old.ID != schedule.ID { | ||||||
|  | 			newSchedules = append(newSchedules, schedule) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	db.mux.Lock() | ||||||
|  | 	db.json.Schedules = newSchedules | ||||||
|  | 	db.mux.Unlock() | ||||||
|  | 
 | ||||||
|  | 	err := db.save(accessID) | ||||||
|  | 	if nil != err { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return &again.Schedule{ | ||||||
|  | 		ID:        old.ID, | ||||||
|  | 		AccessID:  old.AccessID, | ||||||
|  | 		Date:      old.Date, | ||||||
|  | 		Time:      old.Time, | ||||||
|  | 		TZ:        old.TZ, | ||||||
|  | 		NextRunAt: old.NextRunAt, | ||||||
|  | 		Webhooks:  old.Webhooks, | ||||||
|  | 	}, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (db *JSONDB) Upcoming(min time.Time, max time.Time) ([]*again.Schedule, error) { | ||||||
|  | 	schedules := []*again.Schedule{} | ||||||
|  | 	for i := range db.json.Schedules { | ||||||
|  | 		s := db.json.Schedules[i] | ||||||
|  | 		if !s.Disabled && s.NextRunAt.Sub(min) > 0 && max.Sub(s.NextRunAt) > 0 { | ||||||
|  | 			schedules = append(schedules, &again.Schedule{ | ||||||
|  | 				ID:        s.ID, | ||||||
|  | 				AccessID:  s.AccessID, | ||||||
|  | 				Date:      s.Date, | ||||||
|  | 				Time:      s.Time, | ||||||
|  | 				TZ:        s.TZ, | ||||||
|  | 				NextRunAt: s.NextRunAt, | ||||||
|  | 				Webhooks:  s.Webhooks, | ||||||
|  | 			}) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	sort.Sort(again.Schedules(schedules)) | ||||||
|  | 	return []*again.Schedule(schedules), nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func ctcmp(x string, y string) bool { | ||||||
|  | 	return 1 == subtle.ConstantTimeCompare([]byte(x), []byte(y)) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (db *JSONDB) get(id string) (int, *Schedule) { | ||||||
|  | 	db.mux.Lock() | ||||||
|  | 	scheds := db.json.Schedules | ||||||
|  | 	db.mux.Unlock() | ||||||
|  | 	for i := range scheds { | ||||||
|  | 		schedule := scheds[i] | ||||||
|  | 		if ctcmp(id, schedule.ID) { | ||||||
|  | 			return i, &schedule | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return -1, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func genID(n int) (string, error) { | ||||||
|  | 	b := make([]byte, n) | ||||||
|  | 	_, err := rand.Read(b) | ||||||
|  | 	if nil != err { | ||||||
|  | 		return "", err | ||||||
|  | 	} | ||||||
|  | 	return hex.EncodeToString(b), nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (db *JSONDB) save(accessID string) error { | ||||||
|  | 	// TODO per-user files, maybe | ||||||
|  | 	// or probably better to spend that time building the postgres adapter | ||||||
|  | 	rnd, err := genID(4) | ||||||
|  | 	tmppath := db.path + "." + rnd + ".tmp" | ||||||
|  | 	bakpath := db.path + ".bak" | ||||||
|  | 
 | ||||||
|  | 	os.Remove(tmppath) // ignore error | ||||||
|  | 	f, err := os.OpenFile(tmppath, os.O_RDWR|os.O_CREATE, 0700) | ||||||
|  | 	if nil != err { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	encoder := json.NewEncoder(f) | ||||||
|  | 	err = encoder.Encode(db.json) | ||||||
|  | 	f.Close() | ||||||
|  | 	if nil != err { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// TODO could make async and debounce... | ||||||
|  | 	// or spend that time on something useful | ||||||
|  | 	db.fmux.Lock() | ||||||
|  | 	defer db.fmux.Unlock() | ||||||
|  | 	os.Remove(bakpath) // ignore error | ||||||
|  | 	err = os.Rename(db.path, bakpath) | ||||||
|  | 	if nil != err { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	err = os.Rename(tmppath, db.path) | ||||||
|  | 	if nil != err { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return nil | ||||||
| } | } | ||||||
|  | |||||||
							
								
								
									
										8
									
								
								public/.prettierrc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								public/.prettierrc
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,8 @@ | |||||||
|  | { | ||||||
|  |   "bracketSpacing": true, | ||||||
|  |   "printWidth": 120, | ||||||
|  |   "singleQuote": true, | ||||||
|  |   "tabWidth": 2, | ||||||
|  |   "trailingComma": "none", | ||||||
|  |   "useTabs": true | ||||||
|  | } | ||||||
							
								
								
									
										13
									
								
								public/ajquery.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								public/ajquery.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,13 @@ | |||||||
|  | 'use strict'; | ||||||
|  | 
 | ||||||
|  | var $ = function(sel, el) { | ||||||
|  | 	return (el || window.document).querySelector(sel); | ||||||
|  | }; | ||||||
|  | $.create = function(html) { | ||||||
|  | 	var div = document.createElement('div'); | ||||||
|  | 	div.innerHTML = html; | ||||||
|  | 	return div; | ||||||
|  | }; | ||||||
|  | var $$ = function(sel, el) { | ||||||
|  | 	return (el || window.document).querySelectorAll(sel); | ||||||
|  | }; | ||||||
							
								
								
									
										349
									
								
								public/app.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										349
									
								
								public/app.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,349 @@ | |||||||
|  | (function() { | ||||||
|  | 	'use strict'; | ||||||
|  | 
 | ||||||
|  | 	// AJ Query
 | ||||||
|  | 	var $ = window.$; | ||||||
|  | 	var $$ = window.$$; | ||||||
|  | 
 | ||||||
|  | 	var state = { account: { schedules: [] } }; | ||||||
|  | 
 | ||||||
|  | 	var $schedTpl; | ||||||
|  | 	var $headerTpl; | ||||||
|  | 	var $webhookTpl; | ||||||
|  | 	var $webhookHeaderTpl; | ||||||
|  | 
 | ||||||
|  | 	function pad(i) { | ||||||
|  | 		i = String(i); | ||||||
|  | 		while (i.length < 2) { | ||||||
|  | 			i = '0' + i; | ||||||
|  | 		} | ||||||
|  | 		return i; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	function run() { | ||||||
|  | 		$headerTpl = $('.js-new-webhook .js-header').outerHTML; | ||||||
|  | 
 | ||||||
|  | 		$webhookHeaderTpl = $('.js-schedule .js-webhook .js-header').outerHTML; | ||||||
|  | 		$('.js-schedule .js-webhooks .js-headers').innerHTML = ''; | ||||||
|  | 		$webhookTpl = $('.js-schedule .js-webhook').outerHTML; | ||||||
|  | 		$('.js-schedule .js-webhooks').innerHTML = ''; | ||||||
|  | 
 | ||||||
|  | 		// after blanking all inner templates
 | ||||||
|  | 		$schedTpl = $('.js-schedule').outerHTML; | ||||||
|  | 
 | ||||||
|  | 		var $form = $('.js-new-schedule'); | ||||||
|  | 		// Pick a date and time on an even number
 | ||||||
|  | 		// between 10 and 15 minutes in the future
 | ||||||
|  | 		var d = new Date(Date.now() + 10 * 60 * 1000); | ||||||
|  | 		var minutes = d.getMinutes() + (5 - (d.getMinutes() % 5)) - d.getMinutes(); | ||||||
|  | 		d = new Date(d.valueOf() + minutes * 60 * 1000); | ||||||
|  | 		$('.js-date', $form).value = d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); | ||||||
|  | 		$('.js-time', $form).value = pad(d.getHours()) + ':' + pad(d.getMinutes()); | ||||||
|  | 		$('.js-url', $form).value = 'https://enfqtbjh5ghw.x.pipedream.net'; | ||||||
|  | 
 | ||||||
|  | 		console.log('hello'); | ||||||
|  | 
 | ||||||
|  | 		$('body').addEventListener('click', function(ev) { | ||||||
|  | 			if (ev.target.matches('.js-new-header')) { | ||||||
|  | 				newWebhookHeader(ev.target); | ||||||
|  | 			} else if (ev.target.matches('.js-rm-header')) { | ||||||
|  | 				rmWebhookHeader(ev.target); | ||||||
|  | 			} else if (ev.target.matches('.js-delete') && ev.target.closest('.js-schedule')) { | ||||||
|  | 				deleteSchedule(ev.target.closest('.js-schedule')); | ||||||
|  | 			} else { | ||||||
|  | 				return; | ||||||
|  | 			} | ||||||
|  | 			ev.preventDefault(); | ||||||
|  | 			ev.stopPropagation(); | ||||||
|  | 		}); | ||||||
|  | 
 | ||||||
|  | 		$('body').addEventListener('change', function(ev) { | ||||||
|  | 			var $hook = ev.target.closest('.js-new-webhook'); | ||||||
|  | 			if (ev.target.matches('.js-url') && $hook) { | ||||||
|  | 				if (!$('.js-comment', $hook).value) { | ||||||
|  | 					$('.js-comment', $hook).value = ev.target.value.replace(/https:\/\//, '').replace(/\/.*/, ''); | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		}); | ||||||
|  | 
 | ||||||
|  | 		$('body').addEventListener('submit', function(ev) { | ||||||
|  | 			if (ev.target.matches('.js-new-schedule')) { | ||||||
|  | 				console.log('new schedule'); | ||||||
|  | 				newSchedule(ev.target); | ||||||
|  | 			} else if (ev.target.matches('.js-schedules-list')) { | ||||||
|  | 				doLogin(); | ||||||
|  | 			} else { | ||||||
|  | 				return; | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			ev.preventDefault(); | ||||||
|  | 			ev.stopPropagation(); | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	function newSchedule() { | ||||||
|  | 		var $hook = $('.js-new-schedule'); | ||||||
|  | 		//var deviceId = $hook.closest('.js-new-schedule').querySelector('.js-id').value;
 | ||||||
|  | 		var schedule = { | ||||||
|  | 			date: $('.js-date', $hook).value, | ||||||
|  | 			time: $('.js-time', $hook).value, | ||||||
|  | 			tz: $('.js-tz', $hook).value, | ||||||
|  | 			webhooks: [] | ||||||
|  | 		}; | ||||||
|  | 		var hook = { | ||||||
|  | 			comment: $('.js-comment', $hook).value, | ||||||
|  | 			method: $('.js-method', $hook).value, | ||||||
|  | 			url: $('.js-url', $hook).value, | ||||||
|  | 			headers: {} | ||||||
|  | 		}; | ||||||
|  | 		schedule.webhooks.push(hook); | ||||||
|  | 		console.log('schedule:', schedule); | ||||||
|  | 		$$('.js-header', $hook).forEach(function($head) { | ||||||
|  | 			var key = $('.js-key', $head).value; | ||||||
|  | 			var val = $('.js-value', $head).value; | ||||||
|  | 			if (key && val) { | ||||||
|  | 				hook.headers[key] = val; | ||||||
|  | 			} | ||||||
|  | 		}); | ||||||
|  | 
 | ||||||
|  | 		var auth = { | ||||||
|  | 			user: $('.js-http-user', $hook).value || '', | ||||||
|  | 			pass: $('.js-http-pass', $hook).value || '' | ||||||
|  | 		}; | ||||||
|  | 		if (auth.user || auth.pass) { | ||||||
|  | 			hook.auth = auth; | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		var body = $('.js-body-template', $hook).value; | ||||||
|  | 		if ('json' === $('.js-body-type:checked', $hook).value) { | ||||||
|  | 			hook.json = (body && JSON.parse(body)) || undefined; | ||||||
|  | 		} else { | ||||||
|  | 			// TODO try query parse
 | ||||||
|  | 			hook.form = (body && JSON.parse(body)) || undefined; | ||||||
|  | 			// TODO raw string as well
 | ||||||
|  | 		} | ||||||
|  | 		// TODO update on template change and show preview
 | ||||||
|  | 
 | ||||||
|  | 		var opts = { | ||||||
|  | 			method: 'POST', | ||||||
|  | 			headers: { | ||||||
|  | 				Accept: 'application/json', | ||||||
|  | 				Authorization: getToken(), | ||||||
|  | 				'Content-Type': 'application/json' | ||||||
|  | 			}, | ||||||
|  | 			body: JSON.stringify(schedule), | ||||||
|  | 			cors: true | ||||||
|  | 		}; | ||||||
|  | 
 | ||||||
|  | 		/* | ||||||
|  |       state.account.devices | ||||||
|  |         .filter(function(d) { | ||||||
|  |           return d.accessToken == deviceId; | ||||||
|  |         })[0] | ||||||
|  |         .webhooks.push(hook); | ||||||
|  | 
 | ||||||
|  |       displayAccount(state.account); | ||||||
|  |       return; | ||||||
|  |     */ | ||||||
|  | 
 | ||||||
|  | 		window.fetch('/api/v0/schedules', opts).then(function(resp) { | ||||||
|  | 			return resp | ||||||
|  | 				.json() | ||||||
|  | 				.then(function(data) { | ||||||
|  | 					if (!data.date || !data.webhooks) { | ||||||
|  | 						console.error(data); | ||||||
|  | 						throw new Error('something bad happened'); | ||||||
|  | 					} | ||||||
|  | 
 | ||||||
|  | 					state.account.schedules.push(data); | ||||||
|  | 
 | ||||||
|  | 					displayAccount(state.account); | ||||||
|  | 				}) | ||||||
|  | 				.catch(function(e) { | ||||||
|  | 					console.error(e); | ||||||
|  | 					window.alert(e.message); | ||||||
|  | 				}); | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	function newWebhookHeader($newHeader) { | ||||||
|  | 		var $hs = $newHeader.closest('.js-headers'); | ||||||
|  | 		var $h = $newHeader.closest('.js-header'); | ||||||
|  | 		var $div = document.createElement('div'); | ||||||
|  | 		$div.innerHTML = $headerTpl; | ||||||
|  | 		$hs.append($('.js-header', $div)); | ||||||
|  | 		$newHeader.hidden = true; | ||||||
|  | 		$('.js-rm-header', $h).hidden = false; | ||||||
|  | 		$('.js-key', $h).required = 'required'; | ||||||
|  | 		$('.js-value', $h).required = 'required'; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	function rmWebhookHeader($rmHeader) { | ||||||
|  | 		var $h = $rmHeader.closest('.js-header'); | ||||||
|  | 		$h.parentElement.removeChild($h); | ||||||
|  | 	} | ||||||
|  | 	function deleteSchedule($sched) { | ||||||
|  | 		var schedId = $('.js-id', $sched).value; | ||||||
|  | 		var opts = { | ||||||
|  | 			method: 'DELETE', | ||||||
|  | 			headers: { | ||||||
|  | 				Accept: 'application/json', | ||||||
|  | 				Authorization: getToken() | ||||||
|  | 			}, | ||||||
|  | 			cors: true | ||||||
|  | 		}; | ||||||
|  | 		window.fetch('/api/v0/schedules/' + schedId, opts).then(function(resp) { | ||||||
|  | 			return resp.json().then(function(result) { | ||||||
|  | 				if (!result.webhooks) { | ||||||
|  | 					console.error(result); | ||||||
|  | 					window.alert('something went wrong: ' + JSON.stringify(result)); | ||||||
|  | 					return; | ||||||
|  | 				} | ||||||
|  | 				state.account.schedules = state.account.schedules.filter(function(g) { | ||||||
|  | 					return g.id !== result.id; | ||||||
|  | 				}); | ||||||
|  | 				displayAccount(state.account); | ||||||
|  | 			}); | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	function displayAccount(data) { | ||||||
|  | 		state.account = data; | ||||||
|  | 		console.log('[debug] Display Account:'); | ||||||
|  | 		console.log(data); | ||||||
|  | 		var $devs = $('.js-schedules'); | ||||||
|  | 		$devs.innerHTML = ''; | ||||||
|  | 		data.schedules.forEach(function(d) { | ||||||
|  | 			console.log('schedule', d); | ||||||
|  | 			var $dev = $.create($schedTpl); | ||||||
|  | 			$('.js-id', $dev).value = d.id; | ||||||
|  | 			$('.js-date', $dev).value = d.date; | ||||||
|  | 			$('.js-time', $dev).value = d.time; | ||||||
|  | 			$('.js-tz', $dev).value = d.tz; | ||||||
|  | 			d.webhooks.forEach(function(h) { | ||||||
|  | 				console.log('webhook', h); | ||||||
|  | 				var $hook = $.create($webhookTpl); | ||||||
|  | 				$('.js-id', $hook).innerText = h.id; | ||||||
|  | 				$('.js-comment', $hook).innerText = h.comment; | ||||||
|  | 				$('.js-method', $hook).innerText = h.method; | ||||||
|  | 				$('.js-url', $hook).innerText = h.url; | ||||||
|  | 				Object.keys(h.headers || {}).forEach(function(k) { | ||||||
|  | 					var $header = $.create($webhookHeaderTpl); | ||||||
|  | 					var v = h.headers[k]; | ||||||
|  | 					$('.js-key', $header).innerText = k; | ||||||
|  | 					$('.js-value', $header).innerText = v; | ||||||
|  | 					$('.js-headers', $hook).innerHTML += $header.innerHTML; | ||||||
|  | 				}); | ||||||
|  | 				$('.js-body-template', $hook).innerText = h.body || ''; | ||||||
|  | 				$('.js-webhooks', $dev).innerHTML += $hook.innerHTML; | ||||||
|  | 			}); | ||||||
|  | 			$devs.appendChild($dev); | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	console.info('[tzdb] requesting'); | ||||||
|  | 	window.fetch('./tzdb.json').then(function(resp) { | ||||||
|  | 		return resp.json().then(function(tzdb) { | ||||||
|  | 			console.info('[tzdb] received'); | ||||||
|  | 			var tz = Intl.DateTimeFormat().resolvedOptions().timeZone; | ||||||
|  | 			var options = $$('.js-tz option'); | ||||||
|  | 			var valOpt = options[0].outerHTML; // UTC
 | ||||||
|  | 			//var spaceOpt = options[1].outerHTML; // ----
 | ||||||
|  | 			var innerHTML = $('.js-new-schedule .js-tz').innerHTML; | ||||||
|  | 			/* | ||||||
|  | 			innerHTML = | ||||||
|  | 				'<option selected value="' + | ||||||
|  | 				tz + | ||||||
|  | 				'">    ' + | ||||||
|  | 				tz + | ||||||
|  | 				'</option>' + | ||||||
|  | 				spaceOpt + | ||||||
|  | 				innerHTML.replace(/>UTC/, '>    UTC'); | ||||||
|  |       */ | ||||||
|  | 			//$('.js-tz').innerHTML += spaceOpt;
 | ||||||
|  | 			//$('.js-tz').innerHTML += valOpt.replace(/UTC/g, 'custom');
 | ||||||
|  | 			Object.keys(tzdb) | ||||||
|  | 				.sort() | ||||||
|  | 				.forEach(function(k) { | ||||||
|  | 					var parts = k.split(' '); | ||||||
|  | 					//var sep = '── ' + parts[0];
 | ||||||
|  | 					var sep = parts[0]; | ||||||
|  | 					if (parts[0] !== parts[1]) { | ||||||
|  | 						sep += ' / ' + parts[1] + ' (DST)'; | ||||||
|  | 					} | ||||||
|  | 					//innerHTML += '<option disabled>' + sep + '</option>';
 | ||||||
|  | 					innerHTML += '<optgroup label="' + sep + '">'; | ||||||
|  | 					var areas = tzdb[k]; | ||||||
|  | 					areas.forEach(function(_tz) { | ||||||
|  | 						if (tz !== _tz) { | ||||||
|  | 							innerHTML += valOpt.replace(/UTC/g, _tz); | ||||||
|  | 						} else { | ||||||
|  | 							innerHTML += '<option selected value="' + tz + '">' + tz + '</option>'; | ||||||
|  | 						} | ||||||
|  | 					}); | ||||||
|  | 					innerHTML += '</optgroup>'; | ||||||
|  | 				}); | ||||||
|  | 			$('.js-new-schedule .js-tz').innerHTML = innerHTML; | ||||||
|  | 
 | ||||||
|  | 			console.info('[tzdb] loaded'); | ||||||
|  | 			run(); | ||||||
|  | 		}); | ||||||
|  | 	}); | ||||||
|  | 
 | ||||||
|  | 	var allSchedules = []; | ||||||
|  | 
 | ||||||
|  | 	function getToken() { | ||||||
|  | 		return JSON.parse(localStorage.getItem('session')).access_token; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	function doLogin() { | ||||||
|  | 		localStorage.setItem( | ||||||
|  | 			'session', | ||||||
|  | 			JSON.stringify({ | ||||||
|  | 				access_token: $('.js-auth-token').value | ||||||
|  | 			}) | ||||||
|  | 		); | ||||||
|  | 		$('.js-schedules-list').hidden = true; | ||||||
|  | 		return window | ||||||
|  | 			.fetch('/api/v0/schedules', { | ||||||
|  | 				headers: { Authorization: getToken() } | ||||||
|  | 			}) | ||||||
|  | 			.then(function(resp) { | ||||||
|  | 				return resp | ||||||
|  | 					.clone() | ||||||
|  | 					.json() | ||||||
|  | 					.then(function(schedules) { | ||||||
|  | 						console.log('schedules'); | ||||||
|  | 						console.log(schedules); | ||||||
|  | 						allSchedules = schedules; | ||||||
|  | 						renderSchedules(schedules); | ||||||
|  | 						state.account.schedules = schedules; | ||||||
|  | 						displayAccount(state.account); | ||||||
|  | 						$('.js-account').hidden = false; | ||||||
|  | 					}) | ||||||
|  | 					.catch(function(e) { | ||||||
|  | 						console.error("Didn't parse JSON:"); | ||||||
|  | 						console.error(e); | ||||||
|  | 						console.log(resp); | ||||||
|  | 						$('.js-schedules-list').hidden = false; | ||||||
|  | 						window.alert(resp.status + ': ' + resp.statusText); | ||||||
|  | 						return resp.text().then(function(text) { | ||||||
|  | 							window.alert(text); | ||||||
|  | 						}); | ||||||
|  | 					}); | ||||||
|  | 			}) | ||||||
|  | 			.catch(function(e) { | ||||||
|  | 				console.error('Request Error'); | ||||||
|  | 				console.error(e); | ||||||
|  | 				window.alert('Network error. Are you online?'); | ||||||
|  | 			}); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	function renderSchedules(schedules) { | ||||||
|  | 		document.querySelector('.js-schedules-output').innerText = JSON.stringify(schedules, null, 2); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	console.log('whatever'); | ||||||
|  | 	$('.js-auth-token').value = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; | ||||||
|  | 	//window.addEventListener('load', run);
 | ||||||
|  | })(); | ||||||
							
								
								
									
										136
									
								
								public/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								public/index.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,136 @@ | |||||||
|  | <!DOCTYPE html> | ||||||
|  | <html> | ||||||
|  | 	<head> | ||||||
|  | 		<title>Go Again</title> | ||||||
|  | 	</head> | ||||||
|  | 	<body> | ||||||
|  | 		<h1>Go Again</h1> | ||||||
|  | 		<h2>Webhooks, on time!</h2> | ||||||
|  | 
 | ||||||
|  | 		<form class="js-schedules-list"> | ||||||
|  | 			<label | ||||||
|  | 				>Token: | ||||||
|  | 				<input class="js-auth-token" type="text" required /> | ||||||
|  | 			</label> | ||||||
|  | 			<button>Login</button> | ||||||
|  | 		</form> | ||||||
|  | 
 | ||||||
|  | 		<div class="js-account" hidden> | ||||||
|  | 			<details> | ||||||
|  | 				<summary>Schedules</summary> | ||||||
|  | 				<h3>Schedules</h3> | ||||||
|  | 				<div class="js-schedules"> | ||||||
|  | 					<div class="js-schedule"> | ||||||
|  | 						<input type="hidden" class="js-id" /> | ||||||
|  | 						<input type="date" class="js-date" readonly /> | ||||||
|  | 						<input type="time" class="js-time" readonly /> | ||||||
|  | 						<input type="text" class="js-tz" readonly /> | ||||||
|  | 
 | ||||||
|  | 						<div class="doc-webhooks-container"> | ||||||
|  | 							<div class="js-webhooks"> | ||||||
|  | 								<div class="js-webhook"> | ||||||
|  | 									<h4><span class="js-comment"></span></h4> | ||||||
|  | 									<span class="js-id" hidden></span> | ||||||
|  | 									<span class="js-method"></span> | ||||||
|  | 									<span class="js-url"></span> | ||||||
|  | 									<br /> | ||||||
|  | 									<div class="js-headers"> | ||||||
|  | 										<div class="js-header"> | ||||||
|  | 											<span class="js-key"></span> | ||||||
|  | 											<span class="js-value"></span> | ||||||
|  | 										</div> | ||||||
|  | 									</div> | ||||||
|  | 									<pre><code class="js-body-template"></code></pre> | ||||||
|  | 								</div> | ||||||
|  | 							</div> | ||||||
|  | 						</div> | ||||||
|  | 						<button class="js-delete" type="button">Delete Schedule</button> | ||||||
|  | 						<br /> | ||||||
|  | 						<br /> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  | 				<br /> | ||||||
|  | 			</details> | ||||||
|  | 
 | ||||||
|  | 			<details> | ||||||
|  | 				<summary>Add Schedule</summary> | ||||||
|  | 				<h3>Add Schedule</h3> | ||||||
|  | 				<form class="js-new-schedule"> | ||||||
|  | 					<label>Date: <input type="date" class="js-date" required/></label> | ||||||
|  | 					<label>Time: <input type="time" class="js-time" step="300" required/></label> | ||||||
|  | 					<!-- TODO combo box --> | ||||||
|  | 					<label | ||||||
|  | 						>Location: | ||||||
|  | 						<select class="js-tz"> | ||||||
|  | 							<option value="UTC">UTC</option> | ||||||
|  | 							<option disabled>──────────</option> | ||||||
|  | 						</select> | ||||||
|  | 					</label> | ||||||
|  | 					<br /> | ||||||
|  | 
 | ||||||
|  | 					<h3>Webhook</h3> | ||||||
|  | 					<div class="js-new-webhook"> | ||||||
|  | 						<!-- | ||||||
|  | 							<select class="js-template"> | ||||||
|  | 								<option value="webhook" selected>Custom Webhook</option> | ||||||
|  | 								<option value="requestbin">RequestBin</option> | ||||||
|  | 								<option value="mailgun">Maligun</option> | ||||||
|  | 								<option value="twilio">Twilio</option> | ||||||
|  | 								<option value="pushbullet">Pushbullet</option> | ||||||
|  | 							</select> | ||||||
|  | 							<br /> | ||||||
|  |               --> | ||||||
|  | 						<input class="js-comment" type="text" placeholder="Webhook Name" required /> | ||||||
|  | 						<br /> | ||||||
|  | 						<select class="js-method"> | ||||||
|  | 							<option value="POST" selected>POST</option> | ||||||
|  | 							<option value="PUT">PUT</option> | ||||||
|  | 						</select> | ||||||
|  | 						<input placeholder="https://example.com/api/v1/updates" class="js-url" type="url" required /> | ||||||
|  | 						<br /> | ||||||
|  | 						HTTP Basic Auth (optional): | ||||||
|  | 						<input placeholder="username" class="js-http-user" type="text" /> | ||||||
|  | 						<input placeholder="password" class="js-http-pass" type="text" /> | ||||||
|  | 						<div class="js-headers"> | ||||||
|  | 							<div class="js-header"> | ||||||
|  | 								<input placeholder="Header" class="js-key" type="text" /> | ||||||
|  | 								<input placeholder="Value" class="js-value" type="text" /> | ||||||
|  | 								<button type="button" class="js-rm-header" hidden>[x]</button> | ||||||
|  | 								<button type="button" class="js-new-header">[+]</button> | ||||||
|  | 							</div> | ||||||
|  | 						</div> | ||||||
|  | 						<div class="js-body"> | ||||||
|  | 							Request Body Type: | ||||||
|  | 							<label><input name="-body-type" class="js-body-type" type="radio" value="json" checked /> JSON</label> | ||||||
|  | 							<label><input name="-body-type" class="js-body-type" type="radio" value="form" /> Form</label> | ||||||
|  | 							<br /> | ||||||
|  | 							<textarea | ||||||
|  | 								placeholder="Body template, use '{{ keyname }}' for template values." | ||||||
|  | 								class="js-body-template" | ||||||
|  | 							></textarea> | ||||||
|  | 							<!-- TODO preview template --> | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 
 | ||||||
|  | 					<br /> | ||||||
|  | 					<button class="js-create">Save Schedule</button> | ||||||
|  | 				</form> | ||||||
|  | 				<br /> | ||||||
|  | 				<br /> | ||||||
|  | 				<br /> | ||||||
|  | 			</details> | ||||||
|  | 
 | ||||||
|  | 			<details> | ||||||
|  | 				<summary>Debug Info</summary> | ||||||
|  | 				<h3>Debug Info</h3> | ||||||
|  | 				<pre><code class="js-schedules-output"> </code></pre> | ||||||
|  | 				<br /> | ||||||
|  | 				<br /> | ||||||
|  | 				<br /> | ||||||
|  | 			</details> | ||||||
|  | 		</div> | ||||||
|  | 
 | ||||||
|  | 		<script src="./ajquery.js"></script> | ||||||
|  | 		<script src="./app.js"></script> | ||||||
|  | 	</body> | ||||||
|  | </html> | ||||||
							
								
								
									
										54
									
								
								public/tzdb.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								public/tzdb.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,54 @@ | |||||||
|  | {"+00:00 +00:00":["Africa/Abidjan","Africa/Accra","Africa/Bissau","Africa/Monrovia","America/Danmarkshavn","Atlantic/Reykjavik"], | ||||||
|  | "+01:00 +01:00":["Africa/Algiers","Africa/Casablanca","Africa/Lagos","Africa/Ndjamena","Africa/Tunis"], | ||||||
|  | "+02:00 +02:00":["Africa/Cairo","Africa/Johannesburg","Africa/Khartoum","Africa/Maputo","Africa/Tripoli","Africa/Windhoek","Asia/Famagusta","Europe/Kaliningrad"], | ||||||
|  | "+01:00 +02:00":["Africa/Ceuta","Europe/Amsterdam","Europe/Andorra","Europe/Belgrade","Europe/Berlin","Europe/Brussels","Europe/Budapest","Europe/Copenhagen","Europe/Gibraltar","Europe/Luxembourg","Europe/Madrid","Europe/Malta","Europe/Monaco","Europe/Oslo","Europe/Paris","Europe/Prague","Europe/Rome","Europe/Stockholm","Europe/Tirane","Europe/Vienna","Europe/Warsaw","Europe/Zurich"], | ||||||
|  | "+00:00 +01:00":["Africa/El_Aaiun","Atlantic/Canary","Atlantic/Faroe","Atlantic/Madeira","Europe/Dublin","Europe/Lisbon","Europe/London"], | ||||||
|  | "+03:00 +03:00":["Africa/Juba","Africa/Nairobi","Antarctica/Syowa","Asia/Baghdad","Asia/Qatar","Asia/Riyadh","Europe/Istanbul","Europe/Kirov","Europe/Minsk","Europe/Moscow","Europe/Simferopol"], | ||||||
|  | "−10:00 −09:00":["America/Adak"], | ||||||
|  | "−09:00 −08:00":["America/Anchorage","America/Juneau","America/Metlakatla","America/Nome","America/Sitka","America/Yakutat"], | ||||||
|  | "−03:00 −03:00":["America/Araguaina","America/Argentina/Buenos_Aires","America/Argentina/Catamarca","America/Argentina/Cordoba","America/Argentina/Jujuy","America/Argentina/La_Rioja","America/Argentina/Mendoza","America/Argentina/Rio_Gallegos","America/Argentina/Salta","America/Argentina/San_Juan","America/Argentina/San_Luis","America/Argentina/Tucuman","America/Argentina/Ushuaia","America/Bahia","America/Belem","America/Cayenne","America/Fortaleza","America/Maceio","America/Montevideo","America/Paramaribo","America/Punta_Arenas","America/Recife","America/Santarem","Antarctica/Palmer","Antarctica/Rothera","Atlantic/Stanley"], | ||||||
|  | "−04:00 −03:00":["America/Asuncion","America/Campo_Grande","America/Cuiaba","America/Glace_Bay","America/Goose_Bay","America/Halifax","America/Moncton","America/Santiago","America/Thule","Atlantic/Bermuda"], | ||||||
|  | "−05:00 −05:00":["America/Atikokan","America/Bogota","America/Cancun","America/Eirunepe","America/Guayaquil","America/Jamaica","America/Lima","America/Panama","America/Rio_Branco"], | ||||||
|  | "−06:00 −05:00":["America/Bahia_Banderas","America/Chicago","America/Indiana/Knox","America/Indiana/Tell_City","America/Matamoros","America/Menominee","America/Merida","America/Mexico_City","America/Monterrey","America/North_Dakota/Beulah","America/North_Dakota/Center","America/North_Dakota/New_Salem","America/Rainy_River","America/Rankin_Inlet","America/Resolute","America/Winnipeg","Pacific/Easter"], | ||||||
|  | "−04:00 −04:00":["America/Barbados","America/Blanc-Sablon","America/Boa_Vista","America/Caracas","America/Curacao","America/Guyana","America/La_Paz","America/Manaus","America/Martinique","America/Port_of_Spain","America/Porto_Velho","America/Puerto_Rico","America/Santo_Domingo"], | ||||||
|  | "−06:00 −06:00":["America/Belize","America/Costa_Rica","America/El_Salvador","America/Guatemala","America/Managua","America/Regina","America/Swift_Current","America/Tegucigalpa","Pacific/Galapagos"], | ||||||
|  | "−07:00 −06:00":["America/Boise","America/Cambridge_Bay","America/Chihuahua","America/Denver","America/Edmonton","America/Inuvik","America/Mazatlan","America/Ojinaga","America/Yellowknife"], | ||||||
|  | "−07:00 −07:00":["America/Creston","America/Dawson_Creek","America/Fort_Nelson","America/Hermosillo","America/Phoenix"], | ||||||
|  | "−08:00 −07:00":["America/Dawson","America/Los_Angeles","America/Tijuana","America/Vancouver","America/Whitehorse"], | ||||||
|  | "−05:00 −04:00":["America/Detroit","America/Grand_Turk","America/Havana","America/Indiana/Indianapolis","America/Indiana/Marengo","America/Indiana/Petersburg","America/Indiana/Vevay","America/Indiana/Vincennes","America/Indiana/Winamac","America/Iqaluit","America/Kentucky/Louisville","America/Kentucky/Monticello","America/Nassau","America/New_York","America/Nipigon","America/Pangnirtung","America/Port-au-Prince","America/Thunder_Bay","America/Toronto"], | ||||||
|  | "−03:00 −02:00":["America/Godthab","America/Miquelon","America/Sao_Paulo"], | ||||||
|  | "−02:00 −02:00":["America/Noronha","Atlantic/South_Georgia"], | ||||||
|  | "−01:00 +00:00":["America/Scoresbysund","Atlantic/Azores"], | ||||||
|  | "−03:30 −02:30":["America/St_Johns"], | ||||||
|  | "+11:00 +11:00":["Antarctica/Casey","Antarctica/Macquarie","Asia/Magadan","Asia/Sakhalin","Asia/Srednekolymsk","Pacific/Bougainville","Pacific/Efate","Pacific/Guadalcanal","Pacific/Kosrae","Pacific/Norfolk","Pacific/Noumea","Pacific/Pohnpei"], | ||||||
|  | "+07:00 +07:00":["Antarctica/Davis","Asia/Bangkok","Asia/Barnaul","Asia/Ho_Chi_Minh","Asia/Hovd","Asia/Jakarta","Asia/Krasnoyarsk","Asia/Novokuznetsk","Asia/Novosibirsk","Asia/Pontianak","Asia/Tomsk","Indian/Christmas"], | ||||||
|  | "+10:00 +10:00":["Antarctica/DumontDUrville","Asia/Ust-Nera","Asia/Vladivostok","Australia/Brisbane","Australia/Lindeman","Pacific/Chuuk","Pacific/Guam","Pacific/Port_Moresby"], | ||||||
|  | "+05:00 +05:00":["Antarctica/Mawson","Asia/Aqtau","Asia/Aqtobe","Asia/Ashgabat","Asia/Atyrau","Asia/Dushanbe","Asia/Karachi","Asia/Oral","Asia/Qyzylorda","Asia/Samarkand","Asia/Tashkent","Asia/Yekaterinburg","Indian/Kerguelen","Indian/Maldives"], | ||||||
|  | "+00:00 +02:00":["Antarctica/Troll"], | ||||||
|  | "+06:00 +06:00":["Antarctica/Vostok","Asia/Almaty","Asia/Bishkek","Asia/Dhaka","Asia/Omsk","Asia/Thimphu","Asia/Urumqi","Indian/Chagos"], | ||||||
|  | "+02:00 +03:00":["Asia/Amman","Asia/Beirut","Asia/Damascus","Asia/Gaza","Asia/Hebron","Asia/Jerusalem","Europe/Athens","Europe/Bucharest","Europe/Chisinau","Europe/Helsinki","Europe/Kiev","Asia/Nicosia","Europe/Riga","Europe/Sofia","Europe/Tallinn","Europe/Uzhgorod","Europe/Vilnius","Europe/Zaporozhye"], | ||||||
|  | "+12:00 +12:00":["Asia/Anadyr","Asia/Kamchatka","Pacific/Funafuti","Pacific/Kwajalein","Pacific/Majuro","Pacific/Nauru","Pacific/Tarawa","Pacific/Wake","Pacific/Wallis"], | ||||||
|  | "+04:00 +04:00":["Asia/Baku","Asia/Dubai","Asia/Tbilisi","Asia/Yerevan","Europe/Astrakhan","Europe/Samara","Europe/Saratov","Europe/Ulyanovsk","Europe/Volgograd","Indian/Mahe","Indian/Mauritius","Indian/Reunion"], | ||||||
|  | "+08:00 +08:00":["Asia/Brunei","Asia/Choibalsan","Asia/Hong_Kong","Asia/Irkutsk","Asia/Kuala_Lumpur","Asia/Kuching","Asia/Macau","Asia/Makassar","Asia/Manila","Asia/Shanghai","Asia/Singapore","Asia/Taipei","Asia/Ulaanbaatar","Australia/Perth"], | ||||||
|  | "+09:00 +09:00":["Asia/Chita","Asia/Dili","Asia/Jayapura","Asia/Khandyga","Asia/Pyongyang","Asia/Seoul","Asia/Tokyo","Asia/Yakutsk","Pacific/Palau"], | ||||||
|  | "+05:30 +05:30":["Asia/Colombo","Asia/Kolkata"], | ||||||
|  | "+04:30 +04:30":["Asia/Kabul"], | ||||||
|  | "+05:45 +05:45":["Asia/Kathmandu"], | ||||||
|  | "+03:30 +04:30":["Asia/Tehran"], | ||||||
|  | "+06:30 +06:30":["Asia/Yangon","Indian/Cocos"], | ||||||
|  | "−01:00 −01:00":["Atlantic/Cape_Verde"], | ||||||
|  | "+09:30 +10:30":["Australia/Adelaide","Australia/Broken_Hill"], | ||||||
|  | "+10:00 +11:00":["Australia/Currie","Australia/Hobart","Australia/Melbourne","Australia/Sydney"], | ||||||
|  | "+09:30 +09:30":["Australia/Darwin"], | ||||||
|  | "+08:45 +08:45":["Australia/Eucla"], | ||||||
|  | "+10:30 +11:00":["Australia/Lord_Howe"], | ||||||
|  | "+13:00 +14:00":["Pacific/Apia","Pacific/Tongatapu"], | ||||||
|  | "+12:00 +13:00":["Pacific/Auckland","Pacific/Fiji"], | ||||||
|  | "+12:45 +13:45":["Pacific/Chatham"], | ||||||
|  | "+13:00 +13:00":["Pacific/Enderbury","Pacific/Fakaofo"], | ||||||
|  | "−09:00 −09:00":["Pacific/Gambier"], | ||||||
|  | "−10:00 −10:00":["Pacific/Honolulu","Pacific/Rarotonga","Pacific/Tahiti"], | ||||||
|  | "+14:00 +14:00":["Pacific/Kiritimati"], | ||||||
|  | "−09:30 −09:30":["Pacific/Marquesas"], | ||||||
|  | "−11:00 −11:00":["Pacific/Niue","Pacific/Pago_Pago"], | ||||||
|  | "−08:00 −08:00":["Pacific/Pitcairn"]} | ||||||
							
								
								
									
										31
									
								
								public/tzdb/scrape-wikipedia.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								public/tzdb/scrape-wikipedia.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,31 @@ | |||||||
|  | // https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
 | ||||||
|  | 
 | ||||||
|  | var zones = []; | ||||||
|  | var zoneMap = {}; | ||||||
|  | var all = document.body | ||||||
|  | 	.querySelector('.wikitable.sortable.jquery-tablesorter') | ||||||
|  | 	.querySelectorAll('tr'); | ||||||
|  | all = [].slice.call(all, 1); // remove header
 | ||||||
|  | 
 | ||||||
|  | all.forEach(function(el) { | ||||||
|  | 	if (/Alias|Deprecated|Etc\//.test(el.outerText)) { | ||||||
|  | 		$(el).remove(); | ||||||
|  | 		return; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	var fields = [].slice.call(el.querySelectorAll('td')); | ||||||
|  | 	var f = fields.map(function(td) { | ||||||
|  | 		return td.innerText.trim(); | ||||||
|  | 	}); | ||||||
|  | 	var id = f[5] + ' ' + f[6]; | ||||||
|  | 	if (!zoneMap[id]) { | ||||||
|  | 		zones.push([f[2], f[5], f[6]]); | ||||||
|  | 	} | ||||||
|  | 	zoneMap[id] = zoneMap[id] || []; | ||||||
|  | 	zoneMap[id].push(f[2]); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | // console.log(JSON.stringify(zones));
 | ||||||
|  | console.log('Total:', all.length); | ||||||
|  | console.log('Unique:', Object.keys(zoneMap).length); | ||||||
|  | console.log(zoneMap); | ||||||
							
								
								
									
										161
									
								
								webhooks/webhooks.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										161
									
								
								webhooks/webhooks.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,161 @@ | |||||||
|  | package webooks | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"fmt" | ||||||
|  | 	"log" | ||||||
|  | 	"net" | ||||||
|  | 	"net/http" | ||||||
|  | 	"net/url" | ||||||
|  | 	"strings" | ||||||
|  | 	"time" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | var logger chan string | ||||||
|  | 
 | ||||||
|  | func init() { | ||||||
|  | 	logger = make(chan string, 10) | ||||||
|  | 	go func() { | ||||||
|  | 		for { | ||||||
|  | 			msg := <-logger | ||||||
|  | 			log.Println(msg) | ||||||
|  | 		} | ||||||
|  | 	}() | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type Webhook struct { | ||||||
|  | 	ID      string            `json:"id,omitempty"` | ||||||
|  | 	Comment string            `json:"comment"` | ||||||
|  | 	Method  string            `json:"method"` | ||||||
|  | 	URL     string            `json:"url"` | ||||||
|  | 	TZ      string            `json:"-"` | ||||||
|  | 	Auth    map[string]string `json:"auth,omitempty"` | ||||||
|  | 	Headers map[string]string `json:"headers,omitempty"` | ||||||
|  | 	Form    map[string]string `json:"form,omitempty"` | ||||||
|  | 	JSON    map[string]string `json:"json,omitempty"` | ||||||
|  | 	Config  map[string]string `json:"config,omitempty"` | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func Log(str string, args ...interface{}) { | ||||||
|  | 	logger <- fmt.Sprintf(str, args...) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func Run(h Webhook) { | ||||||
|  | 	// TODO do this in main on config init | ||||||
|  | 	if "" == h.Method { | ||||||
|  | 		h.Method = "POST" | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	var body *strings.Reader | ||||||
|  | 	var err error | ||||||
|  | 	// TODO real templates | ||||||
|  | 	loc, err := time.LoadLocation(h.TZ) | ||||||
|  | 	if nil != err { | ||||||
|  | 		Log("Bad timezone", h.TZ) | ||||||
|  | 		loc, _ = time.LoadLocation("UTC") | ||||||
|  | 	} | ||||||
|  | 	t := time.Now().In(loc) | ||||||
|  | 	z, _ := t.Zone() | ||||||
|  | 	if 0 != len(h.Form) { | ||||||
|  | 		form := url.Values{} | ||||||
|  | 		for k := range h.Form { | ||||||
|  | 			v := h.Form[k] | ||||||
|  | 			// because `{{` gets urlencoded | ||||||
|  | 			//v = strings.Replace(v, "{{ .Name }}", d.Name, -1) | ||||||
|  | 			v = strings.Replace(v, "{{ .Datetime }}", t.Format("2006-01-02 3:04:05 MST"), -1) | ||||||
|  | 			v = strings.Replace(v, "{{ .Date }}", t.Format("2006-01-02"), -1) | ||||||
|  | 			v = strings.Replace(v, "{{ .Time }}", t.Format(time.Kitchen), -1) | ||||||
|  | 			v = strings.Replace(v, "{{ .Zone }}", z, -1) | ||||||
|  | 			Log("[HEADER] %s: %s", k, v) | ||||||
|  | 			form.Set(k, v) | ||||||
|  | 		} | ||||||
|  | 		body = strings.NewReader(form.Encode()) | ||||||
|  | 	} else if 0 != len(h.JSON) { | ||||||
|  | 		bodyBuf, err := json.Marshal(h.JSON) | ||||||
|  | 		if nil != err { | ||||||
|  | 			Log("[Notify] JSON Marshal Error for '%s': %s", h.Comment, err) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 		// `{{` is left alone in the body | ||||||
|  | 		bodyStr := string(bodyBuf) | ||||||
|  | 		bodyStr = strings.Replace(bodyStr, "{{ .Datetime }}", t.Format("2006-01-02 3:04:05 MST"), -1) | ||||||
|  | 		bodyStr = strings.Replace(bodyStr, "{{ .Date }}", t.Format("2006-01-02"), -1) | ||||||
|  | 		bodyStr = strings.Replace(bodyStr, "{{ .Time }}", t.Format("3:04:05PM"), -1) | ||||||
|  | 		bodyStr = strings.Replace(bodyStr, "{{ .Zone }}", z, -1) | ||||||
|  | 		body = strings.NewReader(bodyStr) | ||||||
|  | 		//body = strings.NewReader(string(bodyBuf)) | ||||||
|  | 	} | ||||||
|  | 	if nil == body { | ||||||
|  | 		body = strings.NewReader("") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	client := NewHTTPClient() | ||||||
|  | 	fmt.Println("bd?", h.Method, h.URL, body) | ||||||
|  | 	req, err := http.NewRequest(h.Method, h.URL, body) | ||||||
|  | 	if nil != err { | ||||||
|  | 		Log("[Notify] HTTP Client Network Error for '%s': %s", h.Comment, err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if 0 != len(h.Form) { | ||||||
|  | 		req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | ||||||
|  | 	} else if 0 != len(h.JSON) { | ||||||
|  | 		req.Header.Set("Content-Type", "application/json") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if 0 != len(h.Auth) { | ||||||
|  | 		user := h.Auth["user"] | ||||||
|  | 		if "" == user { | ||||||
|  | 			user = h.Auth["username"] | ||||||
|  | 		} | ||||||
|  | 		pass := h.Auth["pass"] | ||||||
|  | 		if "" == user { | ||||||
|  | 			pass = h.Auth["password"] | ||||||
|  | 		} | ||||||
|  | 		req.SetBasicAuth(user, pass) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	req.Header.Set("User-Agent", "Watchdog/1.0") | ||||||
|  | 	for k := range h.Headers { | ||||||
|  | 		req.Header.Set(k, h.Headers[k]) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	resp, err := client.Do(req) | ||||||
|  | 	if nil != err { | ||||||
|  | 		Log("[Notify] HTTP Client Error for '%s': %s", h.Comment, err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if !(resp.StatusCode >= 200 && resp.StatusCode < 300) { | ||||||
|  | 		Log("[Notify] Response Error for '%s': %s", h.Comment, resp.Status) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// TODO json vs xml vs txt | ||||||
|  | 	var data map[string]interface{} | ||||||
|  | 	req.Header.Add("Accept", "application/json") | ||||||
|  | 	decoder := json.NewDecoder(resp.Body) | ||||||
|  | 	err = decoder.Decode(&data) | ||||||
|  | 	if err != nil { | ||||||
|  | 		Log("[Notify] Response Body Error for '%s': %s", h.Comment, resp.Status) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// TODO some sort of way to determine if data is successful (keywords) | ||||||
|  | 	Log("[Notify] Success? %#v", data) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // The default http client uses unsafe defaults | ||||||
|  | func NewHTTPClient() *http.Client { | ||||||
|  | 	transport := &http.Transport{ | ||||||
|  | 		Dial: (&net.Dialer{ | ||||||
|  | 			Timeout: 10 * time.Second, | ||||||
|  | 		}).Dial, | ||||||
|  | 		TLSHandshakeTimeout: 5 * time.Second, | ||||||
|  | 	} | ||||||
|  | 	client := &http.Client{ | ||||||
|  | 		Timeout:   time.Second * 5, | ||||||
|  | 		Transport: transport, | ||||||
|  | 	} | ||||||
|  | 	return client | ||||||
|  | } | ||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user