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