Compare commits

..

No commits in common. "wip" and "master" have entirely different histories.
wip ... master

12 changed files with 30 additions and 1377 deletions

10
.gitignore vendored
View File

@ -1,13 +1,3 @@
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,32 +3,10 @@ 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 {
ID string `json:"id" db:"id"` NextRunAt time.Time
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/
@ -60,7 +38,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)
} }
@ -129,58 +107,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) (*time.Time, error) { func Exists(st []int, tzstr string) error {
tz, err := time.LoadLocation(tzstr) tz, err := time.LoadLocation(tzstr)
if nil != err { if nil != err {
return nil, 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], 0, tz) t1 := time.Date(st[0], m, st[2], st[3], st[4], st[5], st[6], tz)
if st[5] != t1.Second() { if st[5] != t1.Second() {
return nil, ErrNoExist{ return 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 nil, ErrNoExist{ return 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 nil, ErrNoExist{ return 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 nil, ErrNoExist{ return 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 nil, ErrNoExist{ return 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 nil, ErrNoExist{ return 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 &t1, nil return 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.
@ -220,21 +198,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], 0, tz) t1 := time.Date(st[0], m, st[2], st[3], st[4], st[5], st[6], tz)
u1 := t1.UTC() u1 := t1.UTC()
// Australia/Lord_Howe has a 30-minute DST // A better way to do this would probably be to parse the timezone database, but... yeah...
// 60-minute DST is common for _, n := range []int{ /*-120, -60,*/ 30, 60, 120} {
// Antarctica/Troll has a 120-minute DST t2 := time.Date(st[0], m, st[2], st[3], st[4]+n, st[5], st[6], tz)
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) {
return fmt.Errorf("Ambiguous: %s, %s, %+d\n", t1, t2, n) fmt.Println("Ambiguous Time")
fmt.Printf("%s, %s, %+d\n", t1, u1, n)
fmt.Printf("%s, %s, %+d\n", t2, u2, n)
return fmt.Errorf("Ambiguous")
} }
} }
//ta :=
return nil return nil
} }

BIN
cmd/again/again Executable file

Binary file not shown.

View File

@ -1,30 +1,22 @@
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 {
@ -40,300 +32,24 @@ 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: mux, Handler: http.HandlerFunc(handleFunc),
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())
} }
type ScheduleDB interface { func handleFunc(w http.ResponseWriter, r *http.Request) {
List(string) ([]*again.Schedule, error) jsondb.List()
Set(again.Schedule) (*again.Schedule, error) w.Write([]byte("Hello, World!"))
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,290 +1,11 @@
// A JSON storage strategy for Go Again
// SQL would be a better choice, but... meh
//
// Note that we use mutexes instead of channels
// because everything is both synchronous and
// sequential. Meh.
package jsondb package jsondb
import ( import (
"crypto/rand" "errors"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"git.rootprojects.org/root/go-again" again "git.rootprojects.org/root/go-again"
) )
type JSONDB struct { func List() ([]again.Schedule, error) {
dburl string return nil, errors.New("Not Implemented")
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
} }

View File

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

View File

@ -1,13 +0,0 @@
'use strict';
var $ = function(sel, el) {
return (el || window.document).querySelector(sel);
};
$.create = function(html) {
var div = document.createElement('div');
div.innerHTML = html;
return div;
};
var $$ = function(sel, el) {
return (el || window.document).querySelectorAll(sel);
};

View File

@ -1,349 +0,0 @@
(function() {
'use strict';
// AJ Query
var $ = window.$;
var $$ = window.$$;
var state = { account: { schedules: [] } };
var $schedTpl;
var $headerTpl;
var $webhookTpl;
var $webhookHeaderTpl;
function pad(i) {
i = String(i);
while (i.length < 2) {
i = '0' + i;
}
return i;
}
function run() {
$headerTpl = $('.js-new-webhook .js-header').outerHTML;
$webhookHeaderTpl = $('.js-schedule .js-webhook .js-header').outerHTML;
$('.js-schedule .js-webhooks .js-headers').innerHTML = '';
$webhookTpl = $('.js-schedule .js-webhook').outerHTML;
$('.js-schedule .js-webhooks').innerHTML = '';
// after blanking all inner templates
$schedTpl = $('.js-schedule').outerHTML;
var $form = $('.js-new-schedule');
// Pick a date and time on an even number
// between 10 and 15 minutes in the future
var d = new Date(Date.now() + 10 * 60 * 1000);
var minutes = d.getMinutes() + (5 - (d.getMinutes() % 5)) - d.getMinutes();
d = new Date(d.valueOf() + minutes * 60 * 1000);
$('.js-date', $form).value = d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
$('.js-time', $form).value = pad(d.getHours()) + ':' + pad(d.getMinutes());
$('.js-url', $form).value = 'https://enfqtbjh5ghw.x.pipedream.net';
console.log('hello');
$('body').addEventListener('click', function(ev) {
if (ev.target.matches('.js-new-header')) {
newWebhookHeader(ev.target);
} else if (ev.target.matches('.js-rm-header')) {
rmWebhookHeader(ev.target);
} else if (ev.target.matches('.js-delete') && ev.target.closest('.js-schedule')) {
deleteSchedule(ev.target.closest('.js-schedule'));
} else {
return;
}
ev.preventDefault();
ev.stopPropagation();
});
$('body').addEventListener('change', function(ev) {
var $hook = ev.target.closest('.js-new-webhook');
if (ev.target.matches('.js-url') && $hook) {
if (!$('.js-comment', $hook).value) {
$('.js-comment', $hook).value = ev.target.value.replace(/https:\/\//, '').replace(/\/.*/, '');
}
}
});
$('body').addEventListener('submit', function(ev) {
if (ev.target.matches('.js-new-schedule')) {
console.log('new schedule');
newSchedule(ev.target);
} else if (ev.target.matches('.js-schedules-list')) {
doLogin();
} else {
return;
}
ev.preventDefault();
ev.stopPropagation();
});
}
function newSchedule() {
var $hook = $('.js-new-schedule');
//var deviceId = $hook.closest('.js-new-schedule').querySelector('.js-id').value;
var schedule = {
date: $('.js-date', $hook).value,
time: $('.js-time', $hook).value,
tz: $('.js-tz', $hook).value,
webhooks: []
};
var hook = {
comment: $('.js-comment', $hook).value,
method: $('.js-method', $hook).value,
url: $('.js-url', $hook).value,
headers: {}
};
schedule.webhooks.push(hook);
console.log('schedule:', schedule);
$$('.js-header', $hook).forEach(function($head) {
var key = $('.js-key', $head).value;
var val = $('.js-value', $head).value;
if (key && val) {
hook.headers[key] = val;
}
});
var auth = {
user: $('.js-http-user', $hook).value || '',
pass: $('.js-http-pass', $hook).value || ''
};
if (auth.user || auth.pass) {
hook.auth = auth;
}
var body = $('.js-body-template', $hook).value;
if ('json' === $('.js-body-type:checked', $hook).value) {
hook.json = (body && JSON.parse(body)) || undefined;
} else {
// TODO try query parse
hook.form = (body && JSON.parse(body)) || undefined;
// TODO raw string as well
}
// TODO update on template change and show preview
var opts = {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: getToken(),
'Content-Type': 'application/json'
},
body: JSON.stringify(schedule),
cors: true
};
/*
state.account.devices
.filter(function(d) {
return d.accessToken == deviceId;
})[0]
.webhooks.push(hook);
displayAccount(state.account);
return;
*/
window.fetch('/api/v0/schedules', opts).then(function(resp) {
return resp
.json()
.then(function(data) {
if (!data.date || !data.webhooks) {
console.error(data);
throw new Error('something bad happened');
}
state.account.schedules.push(data);
displayAccount(state.account);
})
.catch(function(e) {
console.error(e);
window.alert(e.message);
});
});
}
function newWebhookHeader($newHeader) {
var $hs = $newHeader.closest('.js-headers');
var $h = $newHeader.closest('.js-header');
var $div = document.createElement('div');
$div.innerHTML = $headerTpl;
$hs.append($('.js-header', $div));
$newHeader.hidden = true;
$('.js-rm-header', $h).hidden = false;
$('.js-key', $h).required = 'required';
$('.js-value', $h).required = 'required';
}
function rmWebhookHeader($rmHeader) {
var $h = $rmHeader.closest('.js-header');
$h.parentElement.removeChild($h);
}
function deleteSchedule($sched) {
var schedId = $('.js-id', $sched).value;
var opts = {
method: 'DELETE',
headers: {
Accept: 'application/json',
Authorization: getToken()
},
cors: true
};
window.fetch('/api/v0/schedules/' + schedId, opts).then(function(resp) {
return resp.json().then(function(result) {
if (!result.webhooks) {
console.error(result);
window.alert('something went wrong: ' + JSON.stringify(result));
return;
}
state.account.schedules = state.account.schedules.filter(function(g) {
return g.id !== result.id;
});
displayAccount(state.account);
});
});
}
function displayAccount(data) {
state.account = data;
console.log('[debug] Display Account:');
console.log(data);
var $devs = $('.js-schedules');
$devs.innerHTML = '';
data.schedules.forEach(function(d) {
console.log('schedule', d);
var $dev = $.create($schedTpl);
$('.js-id', $dev).value = d.id;
$('.js-date', $dev).value = d.date;
$('.js-time', $dev).value = d.time;
$('.js-tz', $dev).value = d.tz;
d.webhooks.forEach(function(h) {
console.log('webhook', h);
var $hook = $.create($webhookTpl);
$('.js-id', $hook).innerText = h.id;
$('.js-comment', $hook).innerText = h.comment;
$('.js-method', $hook).innerText = h.method;
$('.js-url', $hook).innerText = h.url;
Object.keys(h.headers || {}).forEach(function(k) {
var $header = $.create($webhookHeaderTpl);
var v = h.headers[k];
$('.js-key', $header).innerText = k;
$('.js-value', $header).innerText = v;
$('.js-headers', $hook).innerHTML += $header.innerHTML;
});
$('.js-body-template', $hook).innerText = h.body || '';
$('.js-webhooks', $dev).innerHTML += $hook.innerHTML;
});
$devs.appendChild($dev);
});
}
console.info('[tzdb] requesting');
window.fetch('./tzdb.json').then(function(resp) {
return resp.json().then(function(tzdb) {
console.info('[tzdb] received');
var tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
var options = $$('.js-tz option');
var valOpt = options[0].outerHTML; // UTC
//var spaceOpt = options[1].outerHTML; // ----
var innerHTML = $('.js-new-schedule .js-tz').innerHTML;
/*
innerHTML =
'<option selected value="' +
tz +
'">&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);
})();

View File

@ -1,136 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Go Again</title>
</head>
<body>
<h1>Go Again</h1>
<h2>Webhooks, on time!</h2>
<form class="js-schedules-list">
<label
>Token:
<input class="js-auth-token" type="text" required />
</label>
<button>Login</button>
</form>
<div class="js-account" hidden>
<details>
<summary>Schedules</summary>
<h3>Schedules</h3>
<div class="js-schedules">
<div class="js-schedule">
<input type="hidden" class="js-id" />
<input type="date" class="js-date" readonly />
<input type="time" class="js-time" readonly />
<input type="text" class="js-tz" readonly />
<div class="doc-webhooks-container">
<div class="js-webhooks">
<div class="js-webhook">
<h4><span class="js-comment"></span></h4>
<span class="js-id" hidden></span>
<span class="js-method"></span>
<span class="js-url"></span>
<br />
<div class="js-headers">
<div class="js-header">
<span class="js-key"></span>
<span class="js-value"></span>
</div>
</div>
<pre><code class="js-body-template"></code></pre>
</div>
</div>
</div>
<button class="js-delete" type="button">Delete Schedule</button>
<br />
<br />
</div>
</div>
<br />
</details>
<details>
<summary>Add Schedule</summary>
<h3>Add Schedule</h3>
<form class="js-new-schedule">
<label>Date: <input type="date" class="js-date" required/></label>
<label>Time: <input type="time" class="js-time" step="300" required/></label>
<!-- TODO combo box -->
<label
>Location:
<select class="js-tz">
<option value="UTC">UTC</option>
<option disabled>──────────</option>
</select>
</label>
<br />
<h3>Webhook</h3>
<div class="js-new-webhook">
<!--
<select class="js-template">
<option value="webhook" selected>Custom Webhook</option>
<option value="requestbin">RequestBin</option>
<option value="mailgun">Maligun</option>
<option value="twilio">Twilio</option>
<option value="pushbullet">Pushbullet</option>
</select>
<br />
-->
<input class="js-comment" type="text" placeholder="Webhook Name" required />
<br />
<select class="js-method">
<option value="POST" selected>POST</option>
<option value="PUT">PUT</option>
</select>
<input placeholder="https://example.com/api/v1/updates" class="js-url" type="url" required />
<br />
HTTP Basic Auth (optional):
<input placeholder="username" class="js-http-user" type="text" />
<input placeholder="password" class="js-http-pass" type="text" />
<div class="js-headers">
<div class="js-header">
<input placeholder="Header" class="js-key" type="text" />
<input placeholder="Value" class="js-value" type="text" />
<button type="button" class="js-rm-header" hidden>[x]</button>
<button type="button" class="js-new-header">[+]</button>
</div>
</div>
<div class="js-body">
Request Body Type:
<label><input name="-body-type" class="js-body-type" type="radio" value="json" checked /> JSON</label>
<label><input name="-body-type" class="js-body-type" type="radio" value="form" /> Form</label>
<br />
<textarea
placeholder="Body template, use '{{ keyname }}' for template values."
class="js-body-template"
></textarea>
<!-- TODO preview template -->
</div>
</div>
<br />
<button class="js-create">Save Schedule</button>
</form>
<br />
<br />
<br />
</details>
<details>
<summary>Debug Info</summary>
<h3>Debug Info</h3>
<pre><code class="js-schedules-output"> </code></pre>
<br />
<br />
<br />
</details>
</div>
<script src="./ajquery.js"></script>
<script src="./app.js"></script>
</body>
</html>

View File

@ -1,54 +0,0 @@
{"+00:00 +00:00":["Africa/Abidjan","Africa/Accra","Africa/Bissau","Africa/Monrovia","America/Danmarkshavn","Atlantic/Reykjavik"],
"+01:00 +01:00":["Africa/Algiers","Africa/Casablanca","Africa/Lagos","Africa/Ndjamena","Africa/Tunis"],
"+02:00 +02:00":["Africa/Cairo","Africa/Johannesburg","Africa/Khartoum","Africa/Maputo","Africa/Tripoli","Africa/Windhoek","Asia/Famagusta","Europe/Kaliningrad"],
"+01:00 +02:00":["Africa/Ceuta","Europe/Amsterdam","Europe/Andorra","Europe/Belgrade","Europe/Berlin","Europe/Brussels","Europe/Budapest","Europe/Copenhagen","Europe/Gibraltar","Europe/Luxembourg","Europe/Madrid","Europe/Malta","Europe/Monaco","Europe/Oslo","Europe/Paris","Europe/Prague","Europe/Rome","Europe/Stockholm","Europe/Tirane","Europe/Vienna","Europe/Warsaw","Europe/Zurich"],
"+00:00 +01:00":["Africa/El_Aaiun","Atlantic/Canary","Atlantic/Faroe","Atlantic/Madeira","Europe/Dublin","Europe/Lisbon","Europe/London"],
"+03:00 +03:00":["Africa/Juba","Africa/Nairobi","Antarctica/Syowa","Asia/Baghdad","Asia/Qatar","Asia/Riyadh","Europe/Istanbul","Europe/Kirov","Europe/Minsk","Europe/Moscow","Europe/Simferopol"],
"10:00 09:00":["America/Adak"],
"09:00 08:00":["America/Anchorage","America/Juneau","America/Metlakatla","America/Nome","America/Sitka","America/Yakutat"],
"03:00 03:00":["America/Araguaina","America/Argentina/Buenos_Aires","America/Argentina/Catamarca","America/Argentina/Cordoba","America/Argentina/Jujuy","America/Argentina/La_Rioja","America/Argentina/Mendoza","America/Argentina/Rio_Gallegos","America/Argentina/Salta","America/Argentina/San_Juan","America/Argentina/San_Luis","America/Argentina/Tucuman","America/Argentina/Ushuaia","America/Bahia","America/Belem","America/Cayenne","America/Fortaleza","America/Maceio","America/Montevideo","America/Paramaribo","America/Punta_Arenas","America/Recife","America/Santarem","Antarctica/Palmer","Antarctica/Rothera","Atlantic/Stanley"],
"04:00 03:00":["America/Asuncion","America/Campo_Grande","America/Cuiaba","America/Glace_Bay","America/Goose_Bay","America/Halifax","America/Moncton","America/Santiago","America/Thule","Atlantic/Bermuda"],
"05:00 05:00":["America/Atikokan","America/Bogota","America/Cancun","America/Eirunepe","America/Guayaquil","America/Jamaica","America/Lima","America/Panama","America/Rio_Branco"],
"06:00 05:00":["America/Bahia_Banderas","America/Chicago","America/Indiana/Knox","America/Indiana/Tell_City","America/Matamoros","America/Menominee","America/Merida","America/Mexico_City","America/Monterrey","America/North_Dakota/Beulah","America/North_Dakota/Center","America/North_Dakota/New_Salem","America/Rainy_River","America/Rankin_Inlet","America/Resolute","America/Winnipeg","Pacific/Easter"],
"04:00 04:00":["America/Barbados","America/Blanc-Sablon","America/Boa_Vista","America/Caracas","America/Curacao","America/Guyana","America/La_Paz","America/Manaus","America/Martinique","America/Port_of_Spain","America/Porto_Velho","America/Puerto_Rico","America/Santo_Domingo"],
"06:00 06:00":["America/Belize","America/Costa_Rica","America/El_Salvador","America/Guatemala","America/Managua","America/Regina","America/Swift_Current","America/Tegucigalpa","Pacific/Galapagos"],
"07:00 06:00":["America/Boise","America/Cambridge_Bay","America/Chihuahua","America/Denver","America/Edmonton","America/Inuvik","America/Mazatlan","America/Ojinaga","America/Yellowknife"],
"07:00 07:00":["America/Creston","America/Dawson_Creek","America/Fort_Nelson","America/Hermosillo","America/Phoenix"],
"08:00 07:00":["America/Dawson","America/Los_Angeles","America/Tijuana","America/Vancouver","America/Whitehorse"],
"05:00 04:00":["America/Detroit","America/Grand_Turk","America/Havana","America/Indiana/Indianapolis","America/Indiana/Marengo","America/Indiana/Petersburg","America/Indiana/Vevay","America/Indiana/Vincennes","America/Indiana/Winamac","America/Iqaluit","America/Kentucky/Louisville","America/Kentucky/Monticello","America/Nassau","America/New_York","America/Nipigon","America/Pangnirtung","America/Port-au-Prince","America/Thunder_Bay","America/Toronto"],
"03:00 02:00":["America/Godthab","America/Miquelon","America/Sao_Paulo"],
"02:00 02:00":["America/Noronha","Atlantic/South_Georgia"],
"01:00 +00:00":["America/Scoresbysund","Atlantic/Azores"],
"03:30 02:30":["America/St_Johns"],
"+11:00 +11:00":["Antarctica/Casey","Antarctica/Macquarie","Asia/Magadan","Asia/Sakhalin","Asia/Srednekolymsk","Pacific/Bougainville","Pacific/Efate","Pacific/Guadalcanal","Pacific/Kosrae","Pacific/Norfolk","Pacific/Noumea","Pacific/Pohnpei"],
"+07:00 +07:00":["Antarctica/Davis","Asia/Bangkok","Asia/Barnaul","Asia/Ho_Chi_Minh","Asia/Hovd","Asia/Jakarta","Asia/Krasnoyarsk","Asia/Novokuznetsk","Asia/Novosibirsk","Asia/Pontianak","Asia/Tomsk","Indian/Christmas"],
"+10:00 +10:00":["Antarctica/DumontDUrville","Asia/Ust-Nera","Asia/Vladivostok","Australia/Brisbane","Australia/Lindeman","Pacific/Chuuk","Pacific/Guam","Pacific/Port_Moresby"],
"+05:00 +05:00":["Antarctica/Mawson","Asia/Aqtau","Asia/Aqtobe","Asia/Ashgabat","Asia/Atyrau","Asia/Dushanbe","Asia/Karachi","Asia/Oral","Asia/Qyzylorda","Asia/Samarkand","Asia/Tashkent","Asia/Yekaterinburg","Indian/Kerguelen","Indian/Maldives"],
"+00:00 +02:00":["Antarctica/Troll"],
"+06:00 +06:00":["Antarctica/Vostok","Asia/Almaty","Asia/Bishkek","Asia/Dhaka","Asia/Omsk","Asia/Thimphu","Asia/Urumqi","Indian/Chagos"],
"+02:00 +03:00":["Asia/Amman","Asia/Beirut","Asia/Damascus","Asia/Gaza","Asia/Hebron","Asia/Jerusalem","Europe/Athens","Europe/Bucharest","Europe/Chisinau","Europe/Helsinki","Europe/Kiev","Asia/Nicosia","Europe/Riga","Europe/Sofia","Europe/Tallinn","Europe/Uzhgorod","Europe/Vilnius","Europe/Zaporozhye"],
"+12:00 +12:00":["Asia/Anadyr","Asia/Kamchatka","Pacific/Funafuti","Pacific/Kwajalein","Pacific/Majuro","Pacific/Nauru","Pacific/Tarawa","Pacific/Wake","Pacific/Wallis"],
"+04:00 +04:00":["Asia/Baku","Asia/Dubai","Asia/Tbilisi","Asia/Yerevan","Europe/Astrakhan","Europe/Samara","Europe/Saratov","Europe/Ulyanovsk","Europe/Volgograd","Indian/Mahe","Indian/Mauritius","Indian/Reunion"],
"+08:00 +08:00":["Asia/Brunei","Asia/Choibalsan","Asia/Hong_Kong","Asia/Irkutsk","Asia/Kuala_Lumpur","Asia/Kuching","Asia/Macau","Asia/Makassar","Asia/Manila","Asia/Shanghai","Asia/Singapore","Asia/Taipei","Asia/Ulaanbaatar","Australia/Perth"],
"+09:00 +09:00":["Asia/Chita","Asia/Dili","Asia/Jayapura","Asia/Khandyga","Asia/Pyongyang","Asia/Seoul","Asia/Tokyo","Asia/Yakutsk","Pacific/Palau"],
"+05:30 +05:30":["Asia/Colombo","Asia/Kolkata"],
"+04:30 +04:30":["Asia/Kabul"],
"+05:45 +05:45":["Asia/Kathmandu"],
"+03:30 +04:30":["Asia/Tehran"],
"+06:30 +06:30":["Asia/Yangon","Indian/Cocos"],
"01:00 01:00":["Atlantic/Cape_Verde"],
"+09:30 +10:30":["Australia/Adelaide","Australia/Broken_Hill"],
"+10:00 +11:00":["Australia/Currie","Australia/Hobart","Australia/Melbourne","Australia/Sydney"],
"+09:30 +09:30":["Australia/Darwin"],
"+08:45 +08:45":["Australia/Eucla"],
"+10:30 +11:00":["Australia/Lord_Howe"],
"+13:00 +14:00":["Pacific/Apia","Pacific/Tongatapu"],
"+12:00 +13:00":["Pacific/Auckland","Pacific/Fiji"],
"+12:45 +13:45":["Pacific/Chatham"],
"+13:00 +13:00":["Pacific/Enderbury","Pacific/Fakaofo"],
"09:00 09:00":["Pacific/Gambier"],
"10:00 10:00":["Pacific/Honolulu","Pacific/Rarotonga","Pacific/Tahiti"],
"+14:00 +14:00":["Pacific/Kiritimati"],
"09:30 09:30":["Pacific/Marquesas"],
"11:00 11:00":["Pacific/Niue","Pacific/Pago_Pago"],
"08:00 08:00":["Pacific/Pitcairn"]}

View File

@ -1,31 +0,0 @@
// https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
var zones = [];
var zoneMap = {};
var all = document.body
.querySelector('.wikitable.sortable.jquery-tablesorter')
.querySelectorAll('tr');
all = [].slice.call(all, 1); // remove header
all.forEach(function(el) {
if (/Alias|Deprecated|Etc\//.test(el.outerText)) {
$(el).remove();
return;
}
var fields = [].slice.call(el.querySelectorAll('td'));
var f = fields.map(function(td) {
return td.innerText.trim();
});
var id = f[5] + ' ' + f[6];
if (!zoneMap[id]) {
zones.push([f[2], f[5], f[6]]);
}
zoneMap[id] = zoneMap[id] || [];
zoneMap[id].push(f[2]);
});
// console.log(JSON.stringify(zones));
console.log('Total:', all.length);
console.log('Unique:', Object.keys(zoneMap).length);
console.log(zoneMap);

View File

@ -1,161 +0,0 @@
package webooks
import (
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"net/url"
"strings"
"time"
)
var logger chan string
func init() {
logger = make(chan string, 10)
go func() {
for {
msg := <-logger
log.Println(msg)
}
}()
}
type Webhook struct {
ID string `json:"id,omitempty"`
Comment string `json:"comment"`
Method string `json:"method"`
URL string `json:"url"`
TZ string `json:"-"`
Auth map[string]string `json:"auth,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Form map[string]string `json:"form,omitempty"`
JSON map[string]string `json:"json,omitempty"`
Config map[string]string `json:"config,omitempty"`
}
func Log(str string, args ...interface{}) {
logger <- fmt.Sprintf(str, args...)
}
func Run(h Webhook) {
// TODO do this in main on config init
if "" == h.Method {
h.Method = "POST"
}
var body *strings.Reader
var err error
// TODO real templates
loc, err := time.LoadLocation(h.TZ)
if nil != err {
Log("Bad timezone", h.TZ)
loc, _ = time.LoadLocation("UTC")
}
t := time.Now().In(loc)
z, _ := t.Zone()
if 0 != len(h.Form) {
form := url.Values{}
for k := range h.Form {
v := h.Form[k]
// because `{{` gets urlencoded
//v = strings.Replace(v, "{{ .Name }}", d.Name, -1)
v = strings.Replace(v, "{{ .Datetime }}", t.Format("2006-01-02 3:04:05 MST"), -1)
v = strings.Replace(v, "{{ .Date }}", t.Format("2006-01-02"), -1)
v = strings.Replace(v, "{{ .Time }}", t.Format(time.Kitchen), -1)
v = strings.Replace(v, "{{ .Zone }}", z, -1)
Log("[HEADER] %s: %s", k, v)
form.Set(k, v)
}
body = strings.NewReader(form.Encode())
} else if 0 != len(h.JSON) {
bodyBuf, err := json.Marshal(h.JSON)
if nil != err {
Log("[Notify] JSON Marshal Error for '%s': %s", h.Comment, err)
return
}
// `{{` is left alone in the body
bodyStr := string(bodyBuf)
bodyStr = strings.Replace(bodyStr, "{{ .Datetime }}", t.Format("2006-01-02 3:04:05 MST"), -1)
bodyStr = strings.Replace(bodyStr, "{{ .Date }}", t.Format("2006-01-02"), -1)
bodyStr = strings.Replace(bodyStr, "{{ .Time }}", t.Format("3:04:05PM"), -1)
bodyStr = strings.Replace(bodyStr, "{{ .Zone }}", z, -1)
body = strings.NewReader(bodyStr)
//body = strings.NewReader(string(bodyBuf))
}
if nil == body {
body = strings.NewReader("")
}
client := NewHTTPClient()
fmt.Println("bd?", h.Method, h.URL, body)
req, err := http.NewRequest(h.Method, h.URL, body)
if nil != err {
Log("[Notify] HTTP Client Network Error for '%s': %s", h.Comment, err)
return
}
if 0 != len(h.Form) {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
} else if 0 != len(h.JSON) {
req.Header.Set("Content-Type", "application/json")
}
if 0 != len(h.Auth) {
user := h.Auth["user"]
if "" == user {
user = h.Auth["username"]
}
pass := h.Auth["pass"]
if "" == user {
pass = h.Auth["password"]
}
req.SetBasicAuth(user, pass)
}
req.Header.Set("User-Agent", "Watchdog/1.0")
for k := range h.Headers {
req.Header.Set(k, h.Headers[k])
}
resp, err := client.Do(req)
if nil != err {
Log("[Notify] HTTP Client Error for '%s': %s", h.Comment, err)
return
}
if !(resp.StatusCode >= 200 && resp.StatusCode < 300) {
Log("[Notify] Response Error for '%s': %s", h.Comment, resp.Status)
return
}
// TODO json vs xml vs txt
var data map[string]interface{}
req.Header.Add("Accept", "application/json")
decoder := json.NewDecoder(resp.Body)
err = decoder.Decode(&data)
if err != nil {
Log("[Notify] Response Body Error for '%s': %s", h.Comment, resp.Status)
return
}
// TODO some sort of way to determine if data is successful (keywords)
Log("[Notify] Success? %#v", data)
}
// The default http client uses unsafe defaults
func NewHTTPClient() *http.Client {
transport := &http.Transport{
Dial: (&net.Dialer{
Timeout: 10 * time.Second,
}).Dial,
TLSHandshakeTimeout: 5 * time.Second,
}
client := &http.Client{
Timeout: time.Second * 5,
Transport: transport,
}
return client
}