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
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
|
||||
64
again.go
64
again.go
@ -3,10 +3,32 @@ package again
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
webhooks "git.rootprojects.org/root/go-again/webhooks"
|
||||
)
|
||||
|
||||
type Webhook webhooks.Webhook
|
||||
|
||||
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/
|
||||
@ -38,7 +60,7 @@ func Run() {
|
||||
[]int{2019, 11, 10, 23, 59, 59, 0},
|
||||
[]int{2019, 11, 31, 23, 0, 0, 0},
|
||||
} {
|
||||
err := Exists(st, "America/Denver")
|
||||
_, err := Exists(st, "America/Denver")
|
||||
if nil != err {
|
||||
fmt.Println(err)
|
||||
}
|
||||
@ -107,58 +129,58 @@ func (err ErrNoExist) Error() string {
|
||||
// fmt.Println(time.Date(2016, time.December, 31, 23, 59, 60, 0, time.UTC))
|
||||
// "2020-12-02 02:00:00 +0000 UTC" // should be "2016-12-31 23:59:60 +0000 UTC"
|
||||
//
|
||||
func Exists(st []int, tzstr string) error {
|
||||
func Exists(st []int, tzstr string) (*time.Time, error) {
|
||||
tz, err := time.LoadLocation(tzstr)
|
||||
if nil != err {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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() {
|
||||
return ErrNoExist{
|
||||
return nil, ErrNoExist{
|
||||
t: st,
|
||||
z: tzstr,
|
||||
e: "invalid second, probably just bad math on your part",
|
||||
}
|
||||
}
|
||||
if st[4] != t1.Minute() {
|
||||
return ErrNoExist{
|
||||
return nil, ErrNoExist{
|
||||
t: st,
|
||||
z: tzstr,
|
||||
e: "invalid minute, probably just bad math on your part, but perhaps a half-hour daylight savings or summer time",
|
||||
}
|
||||
}
|
||||
if st[3] != t1.Hour() {
|
||||
return ErrNoExist{
|
||||
return nil, ErrNoExist{
|
||||
t: st,
|
||||
z: tzstr,
|
||||
e: "invalid hour, possibly a Daylight Savings or Summer Time error, or perhaps bad math on your part",
|
||||
}
|
||||
}
|
||||
if st[2] != t1.Day() {
|
||||
return ErrNoExist{
|
||||
return nil, ErrNoExist{
|
||||
t: st,
|
||||
z: tzstr,
|
||||
e: "invalid day of month, most likely bad math on your part. Remember: 31 28¼ 31 30 31 30 31 31 30 31 30 31",
|
||||
}
|
||||
}
|
||||
if st[1] != int(t1.Month()) {
|
||||
return ErrNoExist{
|
||||
return nil, ErrNoExist{
|
||||
t: st,
|
||||
z: tzstr,
|
||||
e: "invalid month, most likely bad math on your part. Remember: Decemberween isn't until next year",
|
||||
}
|
||||
}
|
||||
if st[0] != t1.Year() {
|
||||
return ErrNoExist{
|
||||
return nil, ErrNoExist{
|
||||
t: st,
|
||||
z: tzstr,
|
||||
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.
|
||||
@ -198,21 +220,21 @@ func IsAmbiguous(st []int, tzstr string) error {
|
||||
if nil != err {
|
||||
return err
|
||||
}
|
||||
|
||||
m := time.Month(st[1])
|
||||
t1 := time.Date(st[0], m, st[2], st[3], st[4], st[5], st[6], tz)
|
||||
t1 := time.Date(st[0], m, st[2], st[3], st[4], st[5], 0, tz)
|
||||
u1 := t1.UTC()
|
||||
// A better way to do this would probably be to parse the timezone database, but... yeah...
|
||||
for _, n := range []int{ /*-120, -60,*/ 30, 60, 120} {
|
||||
t2 := time.Date(st[0], m, st[2], st[3], st[4]+n, st[5], st[6], tz)
|
||||
// Australia/Lord_Howe has a 30-minute DST
|
||||
// 60-minute DST is common
|
||||
// Antarctica/Troll has a 120-minute DST
|
||||
for _, n := range []int{30, 60, 120} {
|
||||
t2 := time.Date(st[0], m, st[2], st[3], st[4]+n, st[5], 0, tz)
|
||||
u2 := t2.UTC()
|
||||
if u1.Equal(u2) {
|
||||
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")
|
||||
return fmt.Errorf("Ambiguous: %s, %s, %+d\n", t1, t2, n)
|
||||
}
|
||||
}
|
||||
//ta :=
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
BIN
cmd/again/again
BIN
cmd/again/again
Binary file not shown.
@ -1,22 +1,30 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
again "git.rootprojects.org/root/go-again"
|
||||
"git.rootprojects.org/root/go-again/data/jsondb"
|
||||
webhooks "git.rootprojects.org/root/go-again/webhooks"
|
||||
)
|
||||
|
||||
func main() {
|
||||
portEnv := os.Getenv("PORT")
|
||||
dbEnv := os.Getenv("DATABASE_URL")
|
||||
|
||||
portInt := flag.Int("port", 0, "port on which to serve http")
|
||||
addr := flag.String("addr", "", "address on which to serve http")
|
||||
dburl := flag.String("database-url", "", "For example: json://relative-path/db.json or json:///absolute-path/db.json")
|
||||
flag.Parse()
|
||||
|
||||
if "" != portEnv {
|
||||
@ -32,24 +40,300 @@ func main() {
|
||||
*portInt = n
|
||||
}
|
||||
if *portInt < 1024 || *portInt > 65535 {
|
||||
log.Fatalf("port should be between 1024 and 65535, not %d.", *portInt)
|
||||
log.Fatalf("`port` should be between 1024 and 65535, not %d.", *portInt)
|
||||
return
|
||||
}
|
||||
portEnv = strconv.Itoa(*portInt)
|
||||
|
||||
if "" != dbEnv {
|
||||
if "" != *dburl {
|
||||
log.Fatal("You may set DATABASE_URL or --database-url, but not both.")
|
||||
return
|
||||
}
|
||||
*dburl = dbEnv
|
||||
// TODO parse string?
|
||||
// TODO have each connector try in sequence by registering with build tags like go-migrate does?
|
||||
}
|
||||
if "" == *dburl {
|
||||
log.Fatalf("`database-url` must be specified." +
|
||||
" Something like --database-url='json:///var/go-again/db.json' should do nicely.")
|
||||
return
|
||||
}
|
||||
|
||||
db, err := jsondb.Connect(*dburl)
|
||||
if nil != err {
|
||||
log.Fatalf("Could not connect to database %q: %s", *dburl, err)
|
||||
return
|
||||
}
|
||||
|
||||
s := &scheduler{
|
||||
DB: db,
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%s", *addr, portEnv),
|
||||
Handler: http.HandlerFunc(handleFunc),
|
||||
Handler: mux,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
}
|
||||
//mux.Handle("/api/", http.HandlerFunc(handleFunc))
|
||||
mux.HandleFunc("/api/v0/schedules", s.Handle)
|
||||
mux.HandleFunc("/api/v0/schedules/", s.Handle)
|
||||
|
||||
// TODO Filebox FS
|
||||
mux.Handle("/", http.FileServer(http.Dir("./public")))
|
||||
|
||||
go s.RunTasks()
|
||||
|
||||
fmt.Println("Listening on", server.Addr)
|
||||
log.Fatal(server.ListenAndServe())
|
||||
}
|
||||
|
||||
func handleFunc(w http.ResponseWriter, r *http.Request) {
|
||||
jsondb.List()
|
||||
w.Write([]byte("Hello, World!"))
|
||||
type ScheduleDB interface {
|
||||
List(string) ([]*again.Schedule, error)
|
||||
Set(again.Schedule) (*again.Schedule, error)
|
||||
Delete(accessID string, id string) (*again.Schedule, error)
|
||||
Upcoming(min time.Time, max time.Time) ([]*again.Schedule, error)
|
||||
}
|
||||
|
||||
type scheduler struct {
|
||||
DB ScheduleDB
|
||||
}
|
||||
|
||||
func (s *scheduler) RunTasks() {
|
||||
log.Println("[info] Task Queue Started")
|
||||
// Tick every 4 minutes,
|
||||
// but run tasks for up to 5 minutes before getting more.
|
||||
ticker := time.NewTicker(4 * time.Minute)
|
||||
|
||||
// TODO some way to add things to the live queue
|
||||
// (maybe a select between the ticker and an incoming channel)
|
||||
|
||||
// 'min' should be >= 'last' at least one second
|
||||
last := time.Now()
|
||||
for {
|
||||
min := time.Now()
|
||||
if last.Unix() > min.Unix() {
|
||||
min = last
|
||||
}
|
||||
max := min.Add(5 * time.Minute)
|
||||
scheds, err := s.DB.Upcoming(min, max)
|
||||
if nil != err {
|
||||
// this seems pretty unrecoverable
|
||||
// TODO check DB, reconnect
|
||||
os.Exit(911)
|
||||
return
|
||||
}
|
||||
log.Printf("[info] Got %d upcoming tasks", len(scheds))
|
||||
log.Println(time.Now())
|
||||
log.Println(min)
|
||||
log.Println(max)
|
||||
log.Println()
|
||||
log.Println(time.Now().UTC())
|
||||
log.Println(min.UTC())
|
||||
log.Println(max.UTC())
|
||||
|
||||
for i := range scheds {
|
||||
sched := scheds[i]
|
||||
fmt.Println("it's in the queue")
|
||||
sleep := sched.NextRunAt.Sub(time.Now())
|
||||
// TODO create ticker to select on instead
|
||||
time.Sleep(sleep)
|
||||
|
||||
fmt.Println("it's happening")
|
||||
for i := range sched.Webhooks {
|
||||
h := sched.Webhooks[i]
|
||||
h.TZ = sched.TZ
|
||||
webhooks.Run(webhooks.Webhook(h))
|
||||
}
|
||||
|
||||
// we only deal in second resulotion
|
||||
last = sched.NextRunAt.Add(1 * time.Second)
|
||||
}
|
||||
|
||||
<-ticker.C
|
||||
}
|
||||
}
|
||||
|
||||
func (s *scheduler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
// note: no go-routines reading body in handlers to follow
|
||||
defer r.Body.Close()
|
||||
|
||||
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if 32 != len(token) {
|
||||
http.Error(w, "Authorization Header did not contain a valid token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, "token", token)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
s.List(w, r)
|
||||
return
|
||||
case http.MethodPost:
|
||||
s.Create(w, r)
|
||||
return
|
||||
case http.MethodDelete:
|
||||
s.Delete(w, r)
|
||||
return
|
||||
default:
|
||||
http.Error(w, "Not Implemented", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (s *scheduler) List(w http.ResponseWriter, r *http.Request) {
|
||||
accessID := r.Context().Value("token").(string)
|
||||
schedules, err := s.DB.List(accessID)
|
||||
if nil != err {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
buf, err := json.Marshal(schedules)
|
||||
if nil != err {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Write(buf)
|
||||
}
|
||||
|
||||
func (s *scheduler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO validate user
|
||||
accessID := r.Context().Value("token").(string)
|
||||
|
||||
/*
|
||||
br, bw := io.Pipe()
|
||||
b := io.TeeReader(r.Body, bw)
|
||||
go func() {
|
||||
x, _ := ioutil.ReadAll(b)
|
||||
fmt.Println("[debug] http body", string(x))
|
||||
bw.Close()
|
||||
}()
|
||||
decoder := json.NewDecoder(br)
|
||||
*/
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
sched := &again.Schedule{}
|
||||
err := decoder.Decode(sched)
|
||||
if nil != err {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
fmt.Printf("New Schedule:\n%#v\n", sched)
|
||||
|
||||
// TODO validate and modify
|
||||
dateParts := strings.Split(sched.Date, "-")
|
||||
if 3 != len(dateParts) {
|
||||
http.Error(w, "Invalid date", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
timeParts := strings.Split(sched.Time, ":")
|
||||
if 2 == len(timeParts) {
|
||||
timeParts = append(timeParts, "00")
|
||||
}
|
||||
if 3 != len(timeParts) {
|
||||
http.Error(w, "Invalid time", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// sub-minute resolution not supported yet
|
||||
timeParts[2] = "00"
|
||||
|
||||
dtParts := []int{0, 0, 0, 0, 0, 0}
|
||||
for i := range dateParts {
|
||||
n, err := strconv.Atoi(dateParts[i])
|
||||
if nil != err {
|
||||
http.Error(w, fmt.Sprintf("Invalid date part '%s'", n), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
dtParts[i] = n
|
||||
}
|
||||
for i := range timeParts {
|
||||
n, err := strconv.Atoi(timeParts[i])
|
||||
if nil != err {
|
||||
http.Error(w, fmt.Sprintf("Invalid time part '%s'", n), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
dtParts[i+3] = n
|
||||
}
|
||||
|
||||
_, err = again.Exists(dtParts, time.UTC.String())
|
||||
if nil != err {
|
||||
http.Error(w, fmt.Sprintf("Invalid datetime: %s", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO warn on non-existant / ambiguous timing
|
||||
loc, err := time.LoadLocation(sched.TZ)
|
||||
if nil != err {
|
||||
http.Error(w, fmt.Sprintf("Invalid timezone: %s", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
t := time.Date(dtParts[0], time.Month(dtParts[1]), dtParts[2], dtParts[3], dtParts[4], dtParts[5], 0, loc).UTC()
|
||||
now := time.Now().UTC()
|
||||
// 4.5 minutes (about 5 minutes)
|
||||
ahead := t.Sub(now)
|
||||
if ahead < 270 {
|
||||
http.Error(w,
|
||||
fmt.Sprintf("Invalid datetime: should be 5+ minutes into the future, not just %d seconds", ahead),
|
||||
http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("Time in UTC:", t)
|
||||
// stagger
|
||||
t = t.Add(time.Duration(rand.Intn(300*1000)-150*1000) * time.Millisecond)
|
||||
// Avoid the Leap Second
|
||||
if 23 == t.Hour() && 59 == t.Minute() && 59 == t.Second() {
|
||||
j := rand.Intn(1) - 2
|
||||
n := rand.Intn(3)
|
||||
// +/- 3 seconds
|
||||
t = t.Add(time.Duration(j*n) * time.Second)
|
||||
}
|
||||
fmt.Println("Staggered Time:", t)
|
||||
|
||||
// TODO add to immediate queue if soon enough
|
||||
sched.NextRunAt = t
|
||||
sched.AccessID = accessID
|
||||
sched2, err := s.DB.Set(*sched)
|
||||
if nil != err {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
buf, err := json.Marshal(sched2)
|
||||
if nil != err {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Write(buf)
|
||||
}
|
||||
|
||||
func (s *scheduler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO validate user
|
||||
accessID := r.Context().Value("token").(string)
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
|
||||
// ""/"api"/"v0"/"schedules"/":id"
|
||||
if 5 != len(parts) {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
id := parts[4]
|
||||
sched2, err := s.DB.Delete(accessID, id)
|
||||
if nil != err {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
buf, err := json.Marshal(sched2)
|
||||
if nil != err {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Write(buf)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
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) {
|
||||
return nil, errors.New("Not Implemented")
|
||||
type JSONDB struct {
|
||||
dburl string
|
||||
path string
|
||||
json *dbjson
|
||||
mux sync.Mutex
|
||||
fmux sync.Mutex
|
||||
}
|
||||
|
||||
type dbjson struct {
|
||||
Schedules []Schedule `json:"schedules"`
|
||||
}
|
||||
|
||||
func Connect(dburl string) (*JSONDB, error) {
|
||||
u, err := url.Parse(dburl)
|
||||
if nil != err {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// json:/abspath/to/db.json
|
||||
path := u.Opaque
|
||||
if "" == path {
|
||||
// json:///abspath/to/db.json
|
||||
path = u.Path
|
||||
if "" == path {
|
||||
// json:relpath/to/db.json
|
||||
// json://relpath/to/db.json
|
||||
path = strings.TrimSuffix(u.Host+"/"+u.Path, "/")
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0700)
|
||||
if nil != err {
|
||||
return nil, fmt.Errorf("Couldn't open %q: %s", path, err)
|
||||
}
|
||||
|
||||
stat, err := f.Stat()
|
||||
if 0 == stat.Size() {
|
||||
_, err := f.Write([]byte(`{"schedules":[]}`))
|
||||
f.Close()
|
||||
if nil != err {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f, err = os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0700)
|
||||
if nil != err {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(f)
|
||||
db := &dbjson{}
|
||||
err = decoder.Decode(db)
|
||||
f.Close()
|
||||
if nil != err {
|
||||
return nil, fmt.Errorf("Couldn't parse %q as JSON: %s", path, err)
|
||||
}
|
||||
|
||||
wd, _ := os.Getwd()
|
||||
fmt.Println("jsondb:", filepath.Join(wd, path))
|
||||
return &JSONDB{
|
||||
dburl: dburl,
|
||||
path: path,
|
||||
json: db,
|
||||
mux: sync.Mutex{},
|
||||
fmux: sync.Mutex{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// A copy of again.Schedule, but with access_id json-able
|
||||
type Schedule struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
AccessID string `json:"access_id" db:"access_id"`
|
||||
Date string `json:"date" db:"date"`
|
||||
Time string `json:"time" db:"time"`
|
||||
TZ string `json:"tz" db:"tz"`
|
||||
NextRunAt time.Time `json:"next_run_at" db:"next_run_at"`
|
||||
Disabled bool `json:"disabled" db:"disabled"`
|
||||
Webhooks []again.Webhook `json:"webhooks" db"webhooks"`
|
||||
}
|
||||
|
||||
func (db *JSONDB) List(accessID string) ([]*again.Schedule, error) {
|
||||
nowish := time.Now().Add(time.Duration(30) * time.Second)
|
||||
schedules := []*again.Schedule{}
|
||||
for i := range db.json.Schedules {
|
||||
s := db.json.Schedules[i]
|
||||
if !s.Disabled && ctcmp(accessID, s.AccessID) && s.NextRunAt.Sub(nowish) > 0 {
|
||||
schedules = append(schedules, &again.Schedule{
|
||||
ID: s.ID,
|
||||
AccessID: s.AccessID,
|
||||
Date: s.Date,
|
||||
Time: s.Time,
|
||||
TZ: s.TZ,
|
||||
NextRunAt: s.NextRunAt,
|
||||
Webhooks: s.Webhooks,
|
||||
})
|
||||
}
|
||||
}
|
||||
return schedules, nil
|
||||
}
|
||||
|
||||
func (db *JSONDB) Set(s again.Schedule) (*again.Schedule, error) {
|
||||
exists := false
|
||||
index := -1
|
||||
if "" == s.ID {
|
||||
id, err := genID(16)
|
||||
if nil != err {
|
||||
return nil, err
|
||||
}
|
||||
s.ID = id
|
||||
} else {
|
||||
i, old := db.get(s.ID)
|
||||
index = i
|
||||
exists = nil != old
|
||||
// TODO constant time bail
|
||||
if !exists || !ctcmp(old.AccessID, s.AccessID) {
|
||||
return nil, fmt.Errorf("invalid id")
|
||||
}
|
||||
}
|
||||
|
||||
schedule := Schedule{
|
||||
ID: s.ID,
|
||||
AccessID: s.AccessID,
|
||||
Date: s.Date,
|
||||
Time: s.Time,
|
||||
TZ: s.TZ,
|
||||
NextRunAt: s.NextRunAt,
|
||||
Webhooks: s.Webhooks,
|
||||
}
|
||||
|
||||
if exists {
|
||||
db.mux.Lock()
|
||||
db.json.Schedules[index] = schedule
|
||||
db.mux.Unlock()
|
||||
} else {
|
||||
db.mux.Lock()
|
||||
db.json.Schedules = append(db.json.Schedules, schedule)
|
||||
db.mux.Unlock()
|
||||
}
|
||||
|
||||
err := db.save(s.AccessID)
|
||||
if nil != err {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (db *JSONDB) Delete(accessID string, id string) (*again.Schedule, error) {
|
||||
_, old := db.get(id)
|
||||
exists := nil != old
|
||||
// TODO constant time bail
|
||||
if !exists || !ctcmp(old.AccessID, accessID) {
|
||||
return nil, fmt.Errorf("invalid id")
|
||||
}
|
||||
|
||||
// Copy everything we keep into its own array
|
||||
newSchedules := []Schedule{}
|
||||
for i := range db.json.Schedules {
|
||||
schedule := db.json.Schedules[i]
|
||||
if old.ID != schedule.ID {
|
||||
newSchedules = append(newSchedules, schedule)
|
||||
}
|
||||
}
|
||||
db.mux.Lock()
|
||||
db.json.Schedules = newSchedules
|
||||
db.mux.Unlock()
|
||||
|
||||
err := db.save(accessID)
|
||||
if nil != err {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &again.Schedule{
|
||||
ID: old.ID,
|
||||
AccessID: old.AccessID,
|
||||
Date: old.Date,
|
||||
Time: old.Time,
|
||||
TZ: old.TZ,
|
||||
NextRunAt: old.NextRunAt,
|
||||
Webhooks: old.Webhooks,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (db *JSONDB) Upcoming(min time.Time, max time.Time) ([]*again.Schedule, error) {
|
||||
schedules := []*again.Schedule{}
|
||||
for i := range db.json.Schedules {
|
||||
s := db.json.Schedules[i]
|
||||
if !s.Disabled && s.NextRunAt.Sub(min) > 0 && max.Sub(s.NextRunAt) > 0 {
|
||||
schedules = append(schedules, &again.Schedule{
|
||||
ID: s.ID,
|
||||
AccessID: s.AccessID,
|
||||
Date: s.Date,
|
||||
Time: s.Time,
|
||||
TZ: s.TZ,
|
||||
NextRunAt: s.NextRunAt,
|
||||
Webhooks: s.Webhooks,
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.Sort(again.Schedules(schedules))
|
||||
return []*again.Schedule(schedules), nil
|
||||
}
|
||||
|
||||
func ctcmp(x string, y string) bool {
|
||||
return 1 == subtle.ConstantTimeCompare([]byte(x), []byte(y))
|
||||
}
|
||||
|
||||
func (db *JSONDB) get(id string) (int, *Schedule) {
|
||||
db.mux.Lock()
|
||||
scheds := db.json.Schedules
|
||||
db.mux.Unlock()
|
||||
for i := range scheds {
|
||||
schedule := scheds[i]
|
||||
if ctcmp(id, schedule.ID) {
|
||||
return i, &schedule
|
||||
}
|
||||
}
|
||||
return -1, nil
|
||||
}
|
||||
|
||||
func genID(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
_, err := rand.Read(b)
|
||||
if nil != err {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func (db *JSONDB) save(accessID string) error {
|
||||
// TODO per-user files, maybe
|
||||
// or probably better to spend that time building the postgres adapter
|
||||
rnd, err := genID(4)
|
||||
tmppath := db.path + "." + rnd + ".tmp"
|
||||
bakpath := db.path + ".bak"
|
||||
|
||||
os.Remove(tmppath) // ignore error
|
||||
f, err := os.OpenFile(tmppath, os.O_RDWR|os.O_CREATE, 0700)
|
||||
if nil != err {
|
||||
return err
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(f)
|
||||
err = encoder.Encode(db.json)
|
||||
f.Close()
|
||||
if nil != err {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO could make async and debounce...
|
||||
// or spend that time on something useful
|
||||
db.fmux.Lock()
|
||||
defer db.fmux.Unlock()
|
||||
os.Remove(bakpath) // ignore error
|
||||
err = os.Rename(db.path, bakpath)
|
||||
if nil != err {
|
||||
return err
|
||||
}
|
||||
err = os.Rename(tmppath, db.path)
|
||||
if nil != err {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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