Compare commits

...

10 Commits
master ... wip

Author SHA1 Message Date
1a4b4f3d0a ignore tz db 2019-09-14 00:10:53 -06:00
7e60e39a11 WIP: Works! 2019-06-23 19:02:31 -06:00
d87b197cc0 WIP delete works too now 2019-06-23 03:01:24 -06:00
9db30c7e80 show old stuff correctly 2019-06-23 00:44:50 -06:00
b619b70d22 WIP: saves, finally 2019-06-23 00:08:05 -06:00
7db71b94b1 WIP: stuff for saving 2019-06-22 21:50:17 -06:00
f135020914 wip can send event 2019-06-22 17:11:14 -06:00
0c6003a894 long list of timezones 2019-06-22 13:28:03 -06:00
4c986119f9 WIP: progress 2019-06-22 01:28:02 -06:00
6712864da0 WIP: more nae nae 2019-06-22 01:10:21 -06:00
12 changed files with 1377 additions and 30 deletions

10
.gitignore vendored
View File

@ -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

View File

@ -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
} }

Binary file not shown.

View File

@ -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)
} }

View File

@ -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
View File

@ -0,0 +1,8 @@
{
"bracketSpacing": true,
"printWidth": 120,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none",
"useTabs": true
}

13
public/ajquery.js Normal file
View 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
View 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 +
'">&nbsp;&nbsp;&nbsp;&nbsp;' +
tz +
'</option>' +
spaceOpt +
innerHTML.replace(/>UTC/, '>&nbsp;&nbsp;&nbsp;&nbsp;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
View 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
View 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"]}

View 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
View 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
}