Compare commits
	
		
			No commits in common. "wip" and "master" have entirely different histories.
		
	
	
		
	
		
							
								
								
									
										10
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -1,13 +1,3 @@ | ||||
| tz | ||||
| tzdb | ||||
| 
 | ||||
| db.json | ||||
| *.bak | ||||
| *.tmp | ||||
| .*.sw* | ||||
| /cmd/again/again | ||||
| /again | ||||
| 
 | ||||
| # ---> Go | ||||
| # Binaries for programs and plugins | ||||
| *.exe | ||||
|  | ||||
							
								
								
									
										64
									
								
								again.go
									
									
									
									
									
								
							
							
						
						
									
										64
									
								
								again.go
									
									
									
									
									
								
							| @ -3,32 +3,10 @@ package again | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"time" | ||||
| 
 | ||||
| 	webhooks "git.rootprojects.org/root/go-again/webhooks" | ||||
| ) | ||||
| 
 | ||||
| type Webhook webhooks.Webhook | ||||
| 
 | ||||
| type Schedule struct { | ||||
| 	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] | ||||
| 	NextRunAt time.Time | ||||
| } | ||||
| 
 | ||||
| // https://yourbasic.org/golang/time-change-convert-location-timezone/ | ||||
| @ -60,7 +38,7 @@ func Run() { | ||||
| 		[]int{2019, 11, 10, 23, 59, 59, 0}, | ||||
| 		[]int{2019, 11, 31, 23, 0, 0, 0}, | ||||
| 	} { | ||||
| 		_, err := Exists(st, "America/Denver") | ||||
| 		err := Exists(st, "America/Denver") | ||||
| 		if nil != err { | ||||
| 			fmt.Println(err) | ||||
| 		} | ||||
| @ -129,58 +107,58 @@ func (err ErrNoExist) Error() string { | ||||
| //     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" | ||||
| // | ||||
| func Exists(st []int, tzstr string) (*time.Time, error) { | ||||
| func Exists(st []int, tzstr string) error { | ||||
| 	tz, err := time.LoadLocation(tzstr) | ||||
| 	if nil != err { | ||||
| 		return nil, err | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	m := time.Month(st[1]) | ||||
| 	t1 := time.Date(st[0], m, st[2], st[3], st[4], st[5], 0, tz) | ||||
| 	t1 := time.Date(st[0], m, st[2], st[3], st[4], st[5], st[6], tz) | ||||
| 	if st[5] != t1.Second() { | ||||
| 		return nil, ErrNoExist{ | ||||
| 		return ErrNoExist{ | ||||
| 			t: st, | ||||
| 			z: tzstr, | ||||
| 			e: "invalid second, probably just bad math on your part", | ||||
| 		} | ||||
| 	} | ||||
| 	if st[4] != t1.Minute() { | ||||
| 		return nil, ErrNoExist{ | ||||
| 		return ErrNoExist{ | ||||
| 			t: st, | ||||
| 			z: tzstr, | ||||
| 			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() { | ||||
| 		return nil, ErrNoExist{ | ||||
| 		return ErrNoExist{ | ||||
| 			t: st, | ||||
| 			z: tzstr, | ||||
| 			e: "invalid hour, possibly a Daylight Savings or Summer Time error, or perhaps bad math on your part", | ||||
| 		} | ||||
| 	} | ||||
| 	if st[2] != t1.Day() { | ||||
| 		return nil, ErrNoExist{ | ||||
| 		return ErrNoExist{ | ||||
| 			t: st, | ||||
| 			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", | ||||
| 		} | ||||
| 	} | ||||
| 	if st[1] != int(t1.Month()) { | ||||
| 		return nil, ErrNoExist{ | ||||
| 		return ErrNoExist{ | ||||
| 			t: st, | ||||
| 			z: tzstr, | ||||
| 			e: "invalid month, most likely bad math on your part. Remember: Decemberween isn't until next year", | ||||
| 		} | ||||
| 	} | ||||
| 	if st[0] != t1.Year() { | ||||
| 		return nil, ErrNoExist{ | ||||
| 		return ErrNoExist{ | ||||
| 			t: st, | ||||
| 			z: tzstr, | ||||
| 			e: "invalid year, must have reached the end of time...", | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return &t1, nil | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // Check if the time happens more than once in a given timezone. | ||||
| @ -220,21 +198,21 @@ func IsAmbiguous(st []int, tzstr string) error { | ||||
| 	if nil != err { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	m := time.Month(st[1]) | ||||
| 	t1 := time.Date(st[0], m, st[2], st[3], st[4], st[5], 0, tz) | ||||
| 	t1 := time.Date(st[0], m, st[2], st[3], st[4], st[5], st[6], tz) | ||||
| 	u1 := t1.UTC() | ||||
| 	// Australia/Lord_Howe has a 30-minute DST | ||||
| 	// 60-minute DST is common | ||||
| 	// 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) | ||||
| 	// A better way to do this would probably be to parse the timezone database, but... yeah... | ||||
| 	for _, n := range []int{ /*-120, -60,*/ 30, 60, 120} { | ||||
| 		t2 := time.Date(st[0], m, st[2], st[3], st[4]+n, st[5], st[6], tz) | ||||
| 		u2 := t2.UTC() | ||||
| 		if u1.Equal(u2) { | ||||
| 			return fmt.Errorf("Ambiguous: %s, %s, %+d\n", t1, t2, n) | ||||
| 			fmt.Println("Ambiguous Time") | ||||
| 			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 | ||||
| } | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										
											BIN
										
									
								
								cmd/again/again
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								cmd/again/again
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							| @ -1,30 +1,22 @@ | ||||
| package main | ||||
| 
 | ||||
| import ( | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"flag" | ||||
| 	"fmt" | ||||
| 	"log" | ||||
| 	"math/rand" | ||||
| 	"net/http" | ||||
| 	"os" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"time" | ||||
| 
 | ||||
| 	again "git.rootprojects.org/root/go-again" | ||||
| 	"git.rootprojects.org/root/go-again/data/jsondb" | ||||
| 	webhooks "git.rootprojects.org/root/go-again/webhooks" | ||||
| ) | ||||
| 
 | ||||
| func main() { | ||||
| 	portEnv := os.Getenv("PORT") | ||||
| 	dbEnv := os.Getenv("DATABASE_URL") | ||||
| 
 | ||||
| 	portInt := flag.Int("port", 0, "port 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() | ||||
| 
 | ||||
| 	if "" != portEnv { | ||||
| @ -40,300 +32,24 @@ func main() { | ||||
| 		*portInt = n | ||||
| 	} | ||||
| 	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 | ||||
| 	} | ||||
| 	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{ | ||||
| 		Addr:           fmt.Sprintf("%s:%s", *addr, portEnv), | ||||
| 		Handler:        mux, | ||||
| 		Handler:        http.HandlerFunc(handleFunc), | ||||
| 		ReadTimeout:    10 * time.Second, | ||||
| 		WriteTimeout:   10 * time.Second, | ||||
| 		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) | ||||
| 	log.Fatal(server.ListenAndServe()) | ||||
| } | ||||
| 
 | ||||
| type ScheduleDB interface { | ||||
| 	List(string) ([]*again.Schedule, error) | ||||
| 	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) | ||||
| func handleFunc(w http.ResponseWriter, r *http.Request) { | ||||
| 	jsondb.List() | ||||
| 	w.Write([]byte("Hello, World!")) | ||||
| } | ||||
|  | ||||
| @ -1,290 +1,11 @@ | ||||
| // 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 | ||||
| 
 | ||||
| import ( | ||||
| 	"crypto/rand" | ||||
| 	"crypto/subtle" | ||||
| 	"encoding/hex" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"net/url" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"sort" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
| 	"errors" | ||||
| 
 | ||||
| 	"git.rootprojects.org/root/go-again" | ||||
| 	again "git.rootprojects.org/root/go-again" | ||||
| ) | ||||
| 
 | ||||
| type JSONDB struct { | ||||
| 	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 | ||||
| func List() ([]again.Schedule, error) { | ||||
| 	return nil, errors.New("Not Implemented") | ||||
| } | ||||
|  | ||||
| @ -1,8 +0,0 @@ | ||||
| { | ||||
|   "bracketSpacing": true, | ||||
|   "printWidth": 120, | ||||
|   "singleQuote": true, | ||||
|   "tabWidth": 2, | ||||
|   "trailingComma": "none", | ||||
|   "useTabs": true | ||||
| } | ||||
| @ -1,13 +0,0 @@ | ||||
| '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
									
									
									
									
									
								
							
							
						
						
									
										349
									
								
								public/app.js
									
									
									
									
									
								
							| @ -1,349 +0,0 @@ | ||||
| (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);
 | ||||
| })(); | ||||
| @ -1,136 +0,0 @@ | ||||
| <!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> | ||||
| @ -1,54 +0,0 @@ | ||||
| {"+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"]} | ||||
| @ -1,31 +0,0 @@ | ||||
| // 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); | ||||
| @ -1,161 +0,0 @@ | ||||
| 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