diff --git a/.gitignore b/.gitignore index 3962b44..b66bc5d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ install.sh *.pid config-*.yml !config-test.yml +data diff --git a/Makefile b/Makefile index 4815468..58bdbe9 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,9 @@ ifeq ($(INSTALLDEST),) INSTALLDEST := /opt/${PROJECT_NAME} endif +VERSION := $(shell git describe --tags --exact HEAD || printf "%s" $(shell git rev-parse --short HEAD)) +BUILDDATE := $(shell date -Iminutes) + install: mkdir -p $(INSTALLDEST) cp -r dist/* $(INSTALLDEST) @@ -25,6 +28,8 @@ dev: done webroot: $(wildcard webroot/**) FORCE + mkdir -p dist + cp -r assets dist cp -r webroot dist (cd dist/webroot; ../../scripts/webroot-build.fish) @@ -37,7 +42,10 @@ clean: rm -rf dist dist/%: ${CMD_DIR}/% FORCE - go build -o $@ ${MODULE_PATH}/$< + go build \ + -ldflags "-X ${MODULE_PATH}/internal/version.Version=$(VERSION) \ + -X ${MODULE_PATH}/internal/version.BuildDate=$(BUILDDATE)" \ + -o $@ ${MODULE_PATH}/$< .PHONY: build clean FORCE: \ No newline at end of file diff --git a/assets/msg-canvas-grades.tpl.html b/assets/msg-canvas-grades.tpl.html new file mode 100644 index 0000000..be2f7f7 --- /dev/null +++ b/assets/msg-canvas-grades.tpl.html @@ -0,0 +1,29 @@ +

お姫様の成績がアップデートされました!

+

最近の採点

+ + + + + + + + + + + + + + {{ range .Grades }} + + + + + + + + + + {{ end }} + +
NameCourseAssignmentDueGradeGraded AtPosted At
{{ .SubmissionUserName }}{{ .CourseCode }}{{ .Name }}{{if eq .Due " -" }}No Due{{else}}{{.Due}}{{end}}{{if .GradeHidden }}(hidden){{else}}{{if le .Score -0.01}}(not graded){{else}}{{ .Score | sprintf "%.2f" + }} ({{ .Grade }}){{end}} /{{ .PossiblePoints | sprintf "%.2f" }} {{end}}{{ .GradedAt }} {{ .PostedAt }}
\ No newline at end of file diff --git a/cmd/server/server.go b/cmd/server/server.go index 56aa1d0..5b594ce 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -5,6 +5,8 @@ import ( "log" "github.com/eternal-flame-AD/yoake/config" + "github.com/eternal-flame-AD/yoake/internal/comm" + "github.com/eternal-flame-AD/yoake/internal/db" "github.com/eternal-flame-AD/yoake/server" "github.com/eternal-flame-AD/yoake/server/vault" "github.com/eternal-flame-AD/yoake/server/webroot" @@ -18,13 +20,18 @@ func init() { flag.Parse() config.ParseConfig(*flagConfig) + comm := comm.InitializeCommProvider() + db, err := db.New(config.Config()) + if err != nil { + log.Panicf("failed to initialize database: %v", err) + } conf := config.Config() for host, handler := range conf.Hosts { switch handler { case "vault": vault.Init(host) case "webroot": - webroot.Init(host) + webroot.Init(host, comm, db) default: log.Panicf("unknown handler for %s: %s", host, handler) } diff --git a/config.yml b/config.yml index 85ee865..89b91ab 100644 --- a/config.yml +++ b/config.yml @@ -23,6 +23,21 @@ auth: publicid: vvxxxxxxxxxxxxx role: admin +comm: + gotify: + baseurl: https://gotify.example.com/ + apptoken: Axxxxx + email: + testmode: off + smtp: + from: "me@example.com" + to: "me@example.com" + host: "smtp.example.com" + port: "587" + username: "me@example.com" + password: "xxxx" + defaultsubject: "Hi!" + webroot: sitename: "夜明け" root: dist diff --git a/config/canvas.go b/config/canvas.go new file mode 100644 index 0000000..65e7bbe --- /dev/null +++ b/config/canvas.go @@ -0,0 +1,20 @@ +package config + +type CanvasLMS struct { + Token string + Frequency string + MaxN string + Endpoint string + + SubmissionName string + + Message struct { + OnUpdate CanvasLMSMessage + OnStartup CanvasLMSMessage + } +} +type CanvasLMSMessage struct { + Comm string + Subject string + Template string +} diff --git a/config/comm.go b/config/comm.go new file mode 100644 index 0000000..0a26227 --- /dev/null +++ b/config/comm.go @@ -0,0 +1,26 @@ +package config + +type Communication struct { + Gotify CommGotify + Email CommEmail +} + +type CommEmail struct { + SMTP struct { + Host string + Port int + + From string + To string + + UserName string + Password string + + DefaultSubject string + } +} + +type CommGotify struct { + BaseURL string + AppToken string +} diff --git a/config/config.go b/config/config.go index 940e63a..323a606 100644 --- a/config/config.go +++ b/config/config.go @@ -15,6 +15,9 @@ type C struct { Key string } } + DB struct { + Badger DBBadger + } WebRoot struct { SiteName string Root string @@ -38,20 +41,21 @@ type C struct { SkipVerify bool BaseURL string } - Auth struct { + Comm Communication + CanvasLMS CanvasLMS + Auth struct { ValidMinutes int - Method struct { - UserPass struct { - } - Yubikey struct { - ClientId string - ClientKey string - Keys []struct { - Name string - PublicId string - Role string - } - } + DevMode struct { + GrantAll bool + } + Users map[string]struct { + Password string + PublicKeyId []string + Roles []string + } + Yubikey struct { + ClientId string + ClientKey string } } } diff --git a/config/db.go b/config/db.go new file mode 100644 index 0000000..7a60703 --- /dev/null +++ b/config/db.go @@ -0,0 +1,5 @@ +package config + +type DBBadger struct { + Dir string +} diff --git a/etc/service-install.fish b/etc/service-install.fish index c72ce6f..9a5d903 100755 --- a/etc/service-install.fish +++ b/etc/service-install.fish @@ -15,12 +15,10 @@ sudo systemctl daemon-reload sudo systemctl stop yoake-server.service -if [ -f ~caddy/yoake ] - sudo rm -rf ~caddy/yoake/* - or exit 2 -end +sudo find ~caddy/yoake -mindepth 1 -delete + or exit 2 -sudo mkdir -p ~caddy/yoake +sudo -ucaddy mkdir -p ~caddy/yoake or exit 2 sudo -ucaddy make INSTALLDEST=(echo ~caddy/yoake) install diff --git a/go.mod b/go.mod index e8a71be..9141876 100644 --- a/go.mod +++ b/go.mod @@ -4,33 +4,52 @@ go 1.19 require ( github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible + github.com/PuerkitoBio/goquery v1.8.0 + github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387 github.com/eternal-flame-AD/yubigo v0.0.0-20221005082707-ce0c8989e8b1 + github.com/gomarkdown/markdown v0.0.0-20221013030248-663e2500819c github.com/gorilla/context v1.1.1 github.com/gorilla/sessions v1.2.1 github.com/jinzhu/configor v1.2.1 github.com/labstack/echo/v4 v4.9.1 - github.com/stretchr/testify v1.8.1 github.com/twilio/twilio-go v1.1.1 + github.com/vanng822/go-premailer v1.20.1 + gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df ) require ( github.com/BurntSushi/toml v0.3.1 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/andybalholm/cascadia v1.3.1 // indirect + github.com/cespare/xxhash v1.1.0 // indirect + github.com/cespare/xxhash/v2 v2.1.1 // indirect + github.com/dgraph-io/badger/v3 v3.2103.4 // indirect + github.com/dgraph-io/ristretto v0.1.1 // indirect + github.com/dustin/go-humanize v1.0.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b // indirect + github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6 // indirect github.com/golang/mock v1.6.0 // indirect + github.com/golang/protobuf v1.3.1 // indirect + github.com/golang/snappy v0.0.3 // indirect + github.com/google/flatbuffers v1.12.1 // indirect + github.com/gorilla/css v1.0.0 // indirect github.com/gorilla/securecookie v1.1.1 // indirect + github.com/klauspost/compress v1.12.3 // indirect github.com/labstack/gommon v0.4.0 // indirect github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.14 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.8.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.1 // indirect + github.com/vanng822/css v1.0.1 // indirect + go.opencensus.io v0.22.5 // indirect golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect golang.org/x/net v0.0.0-20220728030405-41545e8bf201 // indirect - golang.org/x/sys v0.0.0-20220908164124-27713097b956 // indirect + golang.org/x/sys v0.0.0-20221010170243-090e33056c14 // indirect golang.org/x/text v0.3.7 // indirect golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/yaml.v2 v2.2.2 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 3509087..b9197a0 100644 --- a/go.sum +++ b/go.sum @@ -1,26 +1,82 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible h1:1G1pk05UrOh0NlF1oeaaix1x8XzrfjIDK47TY0Zehcw= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= +github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= +github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= +github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387 h1:loy0fjI90vF44BPW4ZYOkE3tDkGTy7yHURusOJimt+I= +github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387/go.mod h1:GuR5j/NW7AU7tDAQUDGCtpiPxWIOy/c3kiRDnlwiCHc= +github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= +github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgraph-io/badger/v3 v3.2103.4 h1:WE1B07YNTTJTtG9xjBcSW2wn0RJLyiV99h959RKZqM4= +github.com/dgraph-io/badger/v3 v3.2103.4/go.mod h1:4MPiseMeDQ3FNCYwRbbcBOGJLf5jsE0PPFzRiKjtcdw= +github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= +github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= github.com/eternal-flame-AD/yubigo v0.0.0-20221005082707-ce0c8989e8b1 h1:B+ad4UMWwNAUsZhLLQCCrEx+cfLsbf0+AbbcfG7RIv0= github.com/eternal-flame-AD/yubigo v0.0.0-20221005082707-ce0c8989e8b1/go.mod h1:kRnqsWaIjqWNPoCV14+cxs/B9eClc0hKL/I2a3LKOQ4= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6 h1:ZgQEtGgCBiWRM39fZuwSd1LwSqqSW0hOdXCYYDX0R3I= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/snappy v0.0.3 h1:fHPg5GQYlCeLIPB9BZqMVR5nR9A+IM5zcgeTdjMYmLA= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/gomarkdown/markdown v0.0.0-20221013030248-663e2500819c h1:iyaGYbCmcYK0Ja9a3OUa2Fo+EaN0cbLu0eKpBwPFzc8= +github.com/gomarkdown/markdown v0.0.0-20221013030248-663e2500819c/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= +github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw= +github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko= github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.12.3 h1:G5AfA94pHPysR56qqrkO2pxEexdDzrpFJ6yt/VqWxVU= +github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -30,20 +86,36 @@ github.com/labstack/echo/v4 v4.9.1/go.mod h1:Pop5HLc+xoc4qhTZ1ip6C0RtP7Z+4VzRLWZ github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8= github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275/go.mod h1:zt6UU74K6Z6oMOYJbJzYpYucqdcQwSMPBEdSvGiaUMw= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -51,52 +123,116 @@ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKs github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/twilio/twilio-go v1.1.1 h1:SBTIqN6wPWd7sykijHyQ2yWZBY9KgT/wUcqNpFupSwA= github.com/twilio/twilio-go v1.1.1/go.mod h1:tdnfQ5TjbewoAu4lf9bMsGvfuJ/QU9gYuv9yx3TSIXU= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/unrolled/render v1.0.3/go.mod h1:gN9T0NhL4Bfbwu8ann7Ry/TGHYfosul+J0obPf6NBdM= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4= github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/vanng822/css v1.0.1 h1:10yiXc4e8NI8ldU6mSrWmSWMuyWgPr9DZ63RSlsgDw8= +github.com/vanng822/css v1.0.1/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w= +github.com/vanng822/go-premailer v1.20.1 h1:2LTSIULXxNV5IOB5BSD3dlfOG95cq8qqExtRZMImTGA= +github.com/vanng822/go-premailer v1.20.1/go.mod h1:RAxbRFp6M/B171gsKu8dsyq+Y5NGsUUvYfg+WQWusbE= +github.com/vanng822/r2router v0.0.0-20150523112421-1023140a4f30/go.mod h1:1BVq8p2jVr55Ost2PkZWDrG86PiJ/0lxqcXoAcGxvWU= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.opencensus.io v0.22.5 h1:dntmOdLpSpHlVqbW5Eay97DelsZHe+55D+xC6i0dDS0= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220728030405-41545e8bf201 h1:bvOltf3SADAfG05iRml8lAB3qjoEX5RCyN4K6G5v3N0= golang.org/x/net v0.0.0-20220728030405-41545e8bf201/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220908164124-27713097b956 h1:XeJjHH1KiLpKGb6lvMiksZ9l0fVUh+AmGcm0nOMEBOY= -golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20221010170243-090e33056c14 h1:k5II8e6QD8mITdi+okbbmR/cIyEbeXLBhy5Ha4nevyc= +golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 h1:ftMN5LMiBFjbzleLqtoBZk7KdJwhuybIU+FckUHgoyQ= golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 6c938c3..6df9c6a 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1,13 +1,17 @@ package auth import ( + "crypto/subtle" + "fmt" "log" "net/http" "time" + "github.com/alexedwards/argon2id" "github.com/eternal-flame-AD/yoake/config" "github.com/gorilla/sessions" "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" ) const AuthSessionName = "auth_session" @@ -31,6 +35,45 @@ func (a RequestAuth) HasRole(role Role) bool { return false } +type RoleInsufficientError struct { + RoleRequired Role + RolesAvailable []string +} + +func (e RoleInsufficientError) Error() string { + return fmt.Sprintf("role insufficient: required %v, you have %v", e.RoleRequired, e.RolesAvailable) +} + +func (e RoleInsufficientError) Code() int { + if len(e.RolesAvailable) == 0 { + return http.StatusUnauthorized + } + return http.StatusForbidden +} + +func (a RequestAuth) RequireRole(role Role) error { + if config := config.Config(); config.Auth.DevMode.GrantAll && !config.Listen.Ssl.Use { + log.Printf("dev mode: role %v granted without checking", role) + return nil + } + if a.HasRole(role) { + return nil + } + return RoleInsufficientError{RoleRequired: role, RolesAvailable: a.Roles} +} + +func RequireMiddleware(role Role) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + auth := GetRequestAuth(c) + if err := auth.RequireRole(role); err != nil { + return err + } + return next(c) + } + } +} + func Middleware(store sessions.Store) echo.MiddlewareFunc { yubiAuthLazyInit() return func(next echo.HandlerFunc) echo.HandlerFunc { @@ -70,7 +113,7 @@ func Middleware(store sessions.Store) echo.MiddlewareFunc { } } -func issueSession(c echo.Context, period time.Duration, baseRole string) error { +func issueSession(c echo.Context, period time.Duration, roles []string) error { sess, _ := c.Get("auth_store").(sessions.Store).Get(c.Request(), AuthSessionName) sess.Options = &sessions.Options{ Path: "/", @@ -83,11 +126,6 @@ func issueSession(c echo.Context, period time.Duration, baseRole string) error { sess.Values["expire"] = (time.Time{}).Format(time.RFC3339) sess.Values["roles"] = "" } else { - roles := []string{baseRole} - if baseRole == string(RoleAdmin) { - roles = append(roles, string(RoleUser)) - } - sess.Values["expire"] = time.Now().Add(period).Format(time.RFC3339) sess.Values["roles"] = roles log.Printf("Issued session for %v, roles: %v", period, roles) @@ -95,40 +133,85 @@ func issueSession(c echo.Context, period time.Duration, baseRole string) error { return sess.Save(c.Request(), c.Response()) } -func Login(c echo.Context) (err error) { - if c.Request().Method == http.MethodDelete { - return issueSession(c, -1, "") - } - switch c.FormValue("type") { - case "userpass": - return echo.NewHTTPError(http.StatusNotImplemented, "userpass login not implemented") - // username, password := c.FormValue("username"), c.FormValue("password") - case "yubikey": - if yubiAuth == nil { - return echo.NewHTTPError(http.StatusNotImplemented, "Yubikey authentication not configured") - } - otp := c.FormValue("response") - if yr, ok, err := yubiAuth.Verify(otp); err != nil { - return echo.NewHTTPError(http.StatusUnauthorized, "Yubikey authentication failed: "+err.Error()) - } else if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Yubikey authentication failed") - } else { - // sessionUseCounter := yr.GetResultParameter("sessionuse") - // sessionCounter := yr.GetResultParameter("sessioncounter") - keyPublicId := yr.GetResultParameter("otp")[:12] - for _, authorizedKey := range config.Config().Auth.Method.Yubikey.Keys { - if authorizedKey.PublicId[:12] == keyPublicId { - issueSession(c, 0, authorizedKey.Role) - return nil - } - } - return echo.NewHTTPError(http.StatusUnauthorized, "Yubikey authentication failed: key "+keyPublicId+" not authorized") - } - default: - return echo.NewHTTPError(400, "invalid auth type") - } - +type LoginForm struct { + Username string `json:"username" form:"username"` + Password string `json:"password" form:"password"` + OtpResponse string `json:"otp_response" form:"otp_response"` } + +func Register(g *echo.Group) (err error) { + g.GET("/auth.json", func(c echo.Context) error { + return c.JSON(http.StatusOK, GetRequestAuth(c)) + }) + + loginRateLimiterStore := middleware.NewRateLimiterMemoryStoreWithConfig(middleware.RateLimiterMemoryStoreConfig{ + ExpiresIn: 300 * time.Second, + Rate: 2, + Burst: 4, + }) + loginRateLimiter := middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ + Store: loginRateLimiterStore, + }) + g.POST("/login", func(c echo.Context) error { + var form LoginForm + if err := c.Bind(&form); err != nil { + return err + } + var verifiedOtpPubId string + if form.OtpResponse != "" { + if yubiAuth == nil { + return echo.NewHTTPError(http.StatusNotImplemented, "Yubikey authentication not configured") + } + if yr, ok, err := yubiAuth.Verify(form.OtpResponse); err != nil { + return echo.NewHTTPError(http.StatusUnauthorized, "Yubikey authentication failed: "+err.Error()) + } else if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Yubikey authentication failed") + } else { + // sessionUseCounter := yr.GetResultParameter("sessionuse") + // sessionCounter := yr.GetResultParameter("sessioncounter") + keyPublicId := yr.GetResultParameter("otp")[:12] + verifiedOtpPubId = keyPublicId + } + } + + if form.Username == "" { + return echo.NewHTTPError(http.StatusBadRequest, "username required") + } + if form.Password == "" { + return echo.NewHTTPError(http.StatusBadRequest, "password required") + } + if user, ok := config.Config().Auth.Users[form.Username]; ok { + if len(user.PublicKeyId) > 0 { + if verifiedOtpPubId == "" { + return echo.NewHTTPError(http.StatusUnauthorized, "otp required") + } + found := 0 + for _, pubId := range user.PublicKeyId { + found += subtle.ConstantTimeCompare([]byte(pubId[:12]), []byte(verifiedOtpPubId)) + } + if found == 0 { + return echo.NewHTTPError(http.StatusUnauthorized, "incorrect key used") + } + } else if verifiedOtpPubId != "" { + return echo.NewHTTPError(http.StatusBadRequest, "otp not required but you provided one, this may be an configuration error") + } + + if match, _ := argon2id.ComparePasswordAndHash(form.Password, user.Password); match { + issueSession(c, 0, user.Roles) + c.JSON(http.StatusOK, map[string]interface{}{"message": "ok", "ok": true}) + return nil + } else { + return echo.NewHTTPError(http.StatusUnauthorized, "incorrect password") + } + } + return echo.NewHTTPError(http.StatusUnauthorized, "invalid username") + }, loginRateLimiter) + g.DELETE("/login", func(c echo.Context) error { + return issueSession(c, -1, nil) + }) + return nil +} + func GetRequestAuth(c echo.Context) RequestAuth { if a, ok := c.Get("auth_" + AuthSessionName).(RequestAuth); ok { return a diff --git a/internal/auth/kdf.go b/internal/auth/kdf.go new file mode 100644 index 0000000..7ca5f20 --- /dev/null +++ b/internal/auth/kdf.go @@ -0,0 +1,11 @@ +package auth + +import "github.com/alexedwards/argon2id" + +var Argon2IdParams = &argon2id.Params{ + Memory: 64 * 1024, + Iterations: 4, + Parallelism: 2, + SaltLength: 16, + KeyLength: 32, +} diff --git a/internal/auth/yubikey.go b/internal/auth/yubikey.go index 971d2f1..1aff354 100644 --- a/internal/auth/yubikey.go +++ b/internal/auth/yubikey.go @@ -14,8 +14,8 @@ func yubiAuthLazyInit() { return } conf := config.Config() - if conf.Auth.Method.Yubikey.ClientId != "" { - if a, err := yubigo.NewYubiAuth(conf.Auth.Method.Yubikey.ClientId, conf.Auth.Method.Yubikey.ClientKey); err != nil { + if conf.Auth.Yubikey.ClientId != "" { + if a, err := yubigo.NewYubiAuth(conf.Auth.Yubikey.ClientId, conf.Auth.Yubikey.ClientKey); err != nil { log.Panicf("failed to load yubigo: %v", err) } else { yubiAuth = a diff --git a/internal/canvaslms/graph.go b/internal/canvaslms/graph.go new file mode 100644 index 0000000..242025e --- /dev/null +++ b/internal/canvaslms/graph.go @@ -0,0 +1,146 @@ +package canvaslms + +import ( + "time" +) + +const GraphQuery = `query gradeQuery { + allCourses { + _id + id + name + state + courseCode + submissionsConnection(first: $maxn$, orderBy: {field: gradedAt, direction: descending}) { + nodes { + _id + id + assignment { + _id + id + name + dueAt + gradingType + pointsPossible + htmlUrl + } + score + enteredScore + grade + enteredGrade + gradingStatus + gradeHidden + gradedAt + posted + postedAt + state + user { + _id + id + name + sisId + email + } + } + } + } +}` + +type GraphResponse struct { + Data struct { + AllCourses []struct { + IDLegacy string `json:"_id"` + ID string `json:"id"` + Name string `json:"name"` + State string `json:"state"` + CourseCode string `json:"courseCode"` + SubmissionsConnection struct { + Nodes []GraphSubmissionResponse `json:"nodes"` + } `json:"submissionsConnection"` + } `json:"allCourses"` + } `json:"data"` +} +type GraphSubmissionResponse struct { + IDLegacy string `json:"_id"` + ID string `json:"id"` + Assignment struct { + IDLegacy string `json:"_id"` + ID string `json:"id"` + Name string `json:"name"` + DueAt *string `json:"dueAt"` + GradingType string `json:"gradingType"` + PointsPossible float64 `json:"pointsPossible"` + HTMLUrl string `json:"htmlUrl"` + } `json:"assignment"` + Score *float64 `json:"score"` + EnteredScore *float64 `json:"enteredScore"` + Grade *string `json:"grade"` + EnteredGrade *string `json:"enteredGrade"` + GradingStatus string `json:"gradingStatus"` + GradeHidden bool `json:"gradeHidden"` + GradedAt *string `json:"gradedAt"` + Posted bool `json:"posted"` + PostedAt *string `json:"postedAt"` + State string `json:"state"` + User struct { + IDLegacy string `json:"_id"` + ID string `json:"id"` + SISID *string `json:"sisId"` + Name string `json:"name"` + Email *string `json:"email"` + } +} + +type GraphSubmissionCompareFunc func(m1, m2 GraphSubmissionResponse) (m1HasPriority bool) + +func parseJSONTime(s string) time.Time { + t, _ := time.Parse(time.RFC3339, s) + return t +} + +func GraphSubmissionCompareByDue(m1, m2 GraphSubmissionResponse) (m1HasPriority bool) { + if m1.Assignment.DueAt == nil { + return false + } + if m2.Assignment.DueAt == nil { + return true + } + + m1Time, m2Time := parseJSONTime(*m1.Assignment.DueAt), parseJSONTime(*m2.Assignment.DueAt) + now := time.Now() + m1IsPast, m2IsPast := now.After(m1Time), now.After(m2Time) + if m1IsPast && m2IsPast { + return m1Time.After(m2Time) + } + if !m1IsPast && !m2IsPast { + return m1Time.Before(m2Time) + } + return !m1IsPast +} + +func laterTime(t1, t2 *string) *string { + if t1 == nil { + return t2 + } + if t2 == nil { + return t1 + } + t1T, t2T := parseJSONTime(*t1), parseJSONTime(*t2) + + if t1T.After(t2T) { + return t1 + } + return t2 +} + +func GraphSubmissionCompareByGradeTime(m1, m2 GraphSubmissionResponse) (m1HasPriority bool) { + m1LastUpdate := laterTime(m1.PostedAt, m1.GradedAt) + m2LastUpdate := laterTime(m2.PostedAt, m2.GradedAt) + if m2LastUpdate == nil { + return true + } + if m1LastUpdate == nil { + return false + } + return parseJSONTime(*m1LastUpdate).After(parseJSONTime(*m2LastUpdate)) +} diff --git a/internal/canvaslms/handler.go b/internal/canvaslms/handler.go new file mode 100644 index 0000000..25b8d56 --- /dev/null +++ b/internal/canvaslms/handler.go @@ -0,0 +1,318 @@ +package canvaslms + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/eternal-flame-AD/yoake/config" + "github.com/eternal-flame-AD/yoake/internal/auth" + "github.com/eternal-flame-AD/yoake/internal/comm" + "github.com/eternal-flame-AD/yoake/internal/echoerror" + "github.com/labstack/echo/v4" +) + +type Handler struct { + conf config.CanvasLMS + maxn int + respCache *responseCache + respCacheMutex sync.RWMutex + refreshPeriod time.Duration + comm *comm.CommProvider +} + +type GetGradesResponse struct { + LastRefresh string `json:"last_refresh"` + Grades []SubmissionScoreResponse `json:"grades"` +} + +type SubmissionScoreResponse struct { + Name string + Due string + AssignmentID string + AssignmentLegacyID string + AssignmentURL string + SubmissionID string + SubmissionLegacyID string + CourseID string + CourseLegacyID string + CourseName string + CourseCode string + + Graded string + Posted string + + State string + + Score float64 + EnteredScore float64 + PossiblePoints float64 + Grade string + EnteredGrade string + GradeHidden bool + GradedAt string + PostedAt string + + SubmissionUserLegacyID string + SubmissionUserID string + SubmissionUserName string + SubmissionUserSISID string + SubmissionUserEmail string +} + +func submissionScoreResponseFromQL(courselid, courseid, coursename, coursecode string, coursesubmission GraphSubmissionResponse) SubmissionScoreResponse { + res := SubmissionScoreResponse{ + Name: coursesubmission.Assignment.Name, + Due: "-", + AssignmentID: coursesubmission.Assignment.ID, + AssignmentLegacyID: coursesubmission.Assignment.IDLegacy, + AssignmentURL: coursesubmission.Assignment.HTMLUrl, + SubmissionID: coursesubmission.ID, + SubmissionLegacyID: coursesubmission.IDLegacy, + CourseID: courseid, + CourseLegacyID: courselid, + CourseName: coursename, + CourseCode: coursecode, + Graded: coursesubmission.GradingStatus, + Posted: strconv.FormatBool(coursesubmission.Posted), + State: coursesubmission.State, + Score: -1, + EnteredScore: -1, + PossiblePoints: coursesubmission.Assignment.PointsPossible, + Grade: "-", + EnteredGrade: "-", + GradeHidden: coursesubmission.GradeHidden, + GradedAt: "-", + PostedAt: "-", + SubmissionUserID: coursesubmission.User.ID, + SubmissionUserLegacyID: coursesubmission.User.IDLegacy, + SubmissionUserName: coursesubmission.User.Name, + } + if coursesubmission.Score != nil { + res.Score = *coursesubmission.Score + } + if coursesubmission.EnteredScore != nil { + res.EnteredScore = *coursesubmission.EnteredScore + } + if coursesubmission.Assignment.DueAt != nil { + res.Due = *coursesubmission.Assignment.DueAt + } + if coursesubmission.Grade != nil { + res.Grade = *coursesubmission.Grade + } + if coursesubmission.GradedAt != nil { + res.GradedAt = *coursesubmission.GradedAt + } + if coursesubmission.PostedAt != nil { + res.PostedAt = *coursesubmission.PostedAt + } + if coursesubmission.User.SISID != nil { + res.SubmissionUserSISID = *coursesubmission.User.SISID + } + if coursesubmission.User.Email != nil { + res.SubmissionUserEmail = *coursesubmission.User.Email + } + return res +} + +func (h *Handler) sortResponse(compare GraphSubmissionCompareFunc) (resp []SubmissionScoreResponse, err error) { + h.respCacheMutex.RLock() + defer h.respCacheMutex.RUnlock() + + res := make([]GraphSubmissionResponse, h.maxn) + resF := make([]SubmissionScoreResponse, h.maxn) + curL := 0 + + push := func(pos int, resp GraphSubmissionResponse, respF SubmissionScoreResponse) { + for i := curL - 1; i > pos; i-- { + res[i] = res[i-1] + resF[i] = resF[i-1] + } + res[pos] = resp + resF[pos] = respF + } + for _, course := range h.respCache.rawResponse.Data.AllCourses { + for _, submission := range course.SubmissionsConnection.Nodes { + pos := curL + for i := curL - 1; i >= 0; i-- { + if !compare(submission, res[i]) { + break + } + pos = i + } + if pos < curL || curL < h.maxn { + push(pos, submission, submissionScoreResponseFromQL(course.IDLegacy, course.ID, course.Name, course.CourseCode, submission)) + if curL < h.maxn { + curL++ + } + } + } + } + return resF, nil +} + +func (h *Handler) refresh() (hasUpdate bool, err error) { + h.respCacheMutex.Lock() + defer h.respCacheMutex.Unlock() + + client := http.Client{ + Timeout: h.refreshPeriod / 2, + } + buf := bytes.NewBufferString("") + e := json.NewEncoder(buf) + e.Encode(struct { + Query string `json:"query"` + OperationName string `json:"operationName"` + Variables *struct{} `json:"variables"` + }{strings.ReplaceAll(GraphQuery, "$maxn$", h.conf.MaxN), "gradeQuery", nil}) + now := time.Now() + req, err := http.NewRequest("POST", h.conf.Endpoint, buf) + if err != nil { + return false, err + } + req.Header.Set("content-type", "application/json") + req.Header.Set("Authorization", "Bearer "+h.conf.Token) + resp, err := client.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + + return false, fmt.Errorf("remote returned with %d", resp.StatusCode) + } + dec := json.NewDecoder(resp.Body) + respStruct := new(GraphResponse) + if err := dec.Decode(respStruct); err != nil { + return false, err + } + + hasUpdate = false + lastUpdateTime := make(map[string]time.Time) + for _, course := range respStruct.Data.AllCourses { + for _, submission := range course.SubmissionsConnection.Nodes { + lastUpdForSubmission := laterTime(submission.PostedAt, submission.GradedAt) + if lastUpdForSubmission != nil { + newUpdTime := parseJSONTime(*lastUpdForSubmission) + lastUpdateTime[submission.ID] = newUpdTime + + if h.respCache != nil && (h.conf.SubmissionName == "" || submission.User.Name == h.conf.SubmissionName) { + if lastUpdateTime, ok := h.respCache.submissionLastUpdate[submission.ID]; !ok || lastUpdateTime.UnixNano() != newUpdTime.UnixNano() { + hasUpdate = true + } + } + } + } + } + h.respCache = &responseCache{ + rawResponse: *respStruct, + requestTime: now, + submissionLastUpdate: lastUpdateTime, + } + if hasUpdate { + go h.SendGradeMessage(h.conf.Message.OnUpdate) + } + return hasUpdate, nil +} + +func (h *Handler) GetInformation(key string) (data interface{}, err error) { + switch key { + case "recent-graded": + return h.sortResponse(GraphSubmissionCompareByGradeTime) + case "recent-due": + return h.sortResponse(GraphSubmissionCompareByDue) + } + return nil, errors.New("unknown info request type") +} + +type responseCache struct { + rawResponse GraphResponse + requestTime time.Time + submissionLastUpdate map[string]time.Time +} + +func Register(g *echo.Group, comm *comm.CommProvider) (h *Handler, err error) { + h = &Handler{conf: config.Config().CanvasLMS, comm: comm} + if h.conf.Token == "" { + return nil, errors.New("canvas token not set") + } + maxn, err := strconv.Atoi(h.conf.MaxN) + if err != nil { + return nil, err + } + h.maxn = maxn + refreshperiod, err := time.ParseDuration(h.conf.Frequency) + if err != nil { + return nil, err + } + h.refreshPeriod = refreshperiod + + checkForUpdates := make(chan bool) + go func() { + if _, err := h.refresh(); err != nil { + log.Panicf("cannot access graphql endpoint: %v", err) + } else { + go h.SendGradeMessage(h.conf.Message.OnStartup) + } + for forced := range checkForUpdates { + _ = forced + h.refresh() + // TODO: notify if there is an update + } + }() + go func() { + for range time.NewTicker(h.refreshPeriod).C { + checkForUpdates <- false + } + }() + + gradesG := g.Group("/grades", auth.RequireMiddleware(auth.RoleAdmin), func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if h.respCache == nil { + return echoerror.NewHttp(http.StatusServiceUnavailable, errors.New("not yet initialized")) + } + return next(c) + } + }) + { + gradesG.GET("", func(c echo.Context) error { + if c.QueryParam("refresh") == "1" { + if _, err := h.refresh(); err != nil { + return fmt.Errorf("cannot access graphql endpoint: %v", err) + } + } + sortQuery := c.QueryParam("sort") + if sortQuery == "" { + sortQuery = "recent-graded" + } + var res GetGradesResponse + res.LastRefresh = h.respCache.requestTime.Format(time.RFC3339) + switch sortQuery { + case "recent-graded": + if grades, err := h.sortResponse(GraphSubmissionCompareByGradeTime); err != nil { + return err + } else { + res.Grades = grades + } + return c.JSON(http.StatusOK, res) + case "recent-due": + if grades, err := h.sortResponse(GraphSubmissionCompareByDue); err != nil { + return err + } else { + res.Grades = grades + } + return c.JSON(http.StatusOK, res) + } + return errors.New("unknown info request type") + }) + } + + return h, nil +} diff --git a/internal/canvaslms/message.go b/internal/canvaslms/message.go new file mode 100644 index 0000000..c0cd641 --- /dev/null +++ b/internal/canvaslms/message.go @@ -0,0 +1,38 @@ +package canvaslms + +import ( + "log" + "strings" + "time" + + "github.com/eternal-flame-AD/yoake/config" + "github.com/eternal-flame-AD/yoake/internal/comm/model" +) + +func (h *Handler) SendGradeMessage(conf config.CanvasLMSMessage) error { + if conf.Template == "" { + return nil + } + mime := "text/plain+text/template" + if strings.HasSuffix(conf.Template, ".html") && strings.HasPrefix(conf.Template, "@") { + mime = "text/html+html/template" + } + grades, err := h.sortResponse(GraphSubmissionCompareByGradeTime) + if err != nil { + return err + } + + if err := h.comm.SendGenericMessage(conf.Comm, model.GenericMessage{ + Subject: conf.Subject, + Body: conf.Template, + MIME: mime, + Context: GetGradesResponse{ + Grades: grades, + LastRefresh: h.respCache.requestTime.Format(time.RFC3339), + }, + }); err != nil { + log.Printf("error sending grade message: %v", err) + return err + } + return nil +} diff --git a/internal/comm/api.go b/internal/comm/api.go new file mode 100644 index 0000000..7381c65 --- /dev/null +++ b/internal/comm/api.go @@ -0,0 +1,68 @@ +package comm + +import ( + "github.com/eternal-flame-AD/yoake/internal/auth" + "github.com/eternal-flame-AD/yoake/internal/comm/model" + "github.com/labstack/echo/v4" +) + +type CommStatusResponse struct { + Communicators []struct { + Method string + SupportedMIME []string + } `json:"communicators"` +} + +func (c *CommProvider) RegisterAPIRoute(g *echo.Group) { + send := g.Group("/send", auth.RequireMiddleware(auth.RoleAdmin)) + { + send.POST("", func(ctx echo.Context) error { + var msg model.GenericMessage + if err := ctx.Bind(&msg); err != nil { + return err + } + if err := c.SendGenericMessage("", msg); err != nil { + return err + } + return nil + }) + send.POST("/:method", func(ctx echo.Context) error { + var msg model.GenericMessage + if err := ctx.Bind(&msg); err != nil { + return err + } + if err := c.SendGenericMessage(ctx.Param("method"), msg); err != nil { + return err + } + return nil + }) + } + + g.GET("/status", func(ctx echo.Context) error { + var communicators []struct { + Method string + SupportedMIME []string + } + for _, comm := range c.fallbackCommunicators { + communicators = append(communicators, struct { + Method string + SupportedMIME []string + }{ + Method: comm, + SupportedMIME: c.communicators[comm].SupportedMIME(), + }) + } + for key, comm := range c.communicators { + if !contains(c.fallbackCommunicators, key) { + communicators = append(communicators, struct { + Method string + SupportedMIME []string + }{ + Method: key, + SupportedMIME: comm.SupportedMIME(), + }) + } + } + return ctx.JSON(200, CommStatusResponse{Communicators: communicators}) + }) +} diff --git a/internal/comm/convert.go b/internal/comm/convert.go new file mode 100644 index 0000000..2cdd5f5 --- /dev/null +++ b/internal/comm/convert.go @@ -0,0 +1,116 @@ +package comm + +import ( + "bytes" + "fmt" + "html/template" + "log" + "path" + "strings" + textTemplate "text/template" + + "github.com/PuerkitoBio/goquery" + "github.com/eternal-flame-AD/yoake/internal/comm/model" + "github.com/eternal-flame-AD/yoake/internal/servetpl/funcmap" + "github.com/gomarkdown/markdown" +) + +func contains[T comparable](s []T, e T) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} + +type ErrorMIMENoOverlap struct { + MessageMIME string + supportedMIME []string +} + +func (e *ErrorMIMENoOverlap) Error() string { + return fmt.Sprintf("message MIME type %s is not supported by this communicator. Supported MIME types are: %s", e.MessageMIME, e.supportedMIME) +} + +func ConvertGenericMessage(msgOrig *model.GenericMessage, supportedMIMES []string) (*model.GenericMessage, error) { + if contains(supportedMIMES, msgOrig.MIME) { + return msgOrig, nil + } + msg := *msgOrig + if strings.HasSuffix(msgOrig.MIME, "+html/template") || + strings.HasSuffix(msgOrig.MIME, "+text/template") { + var output bytes.Buffer + var err error + var tplName string + if strings.HasSuffix(msgOrig.MIME, "+html/template") { + tpl := template.New("").Funcs(funcmap.GetFuncMap()) + if strings.HasPrefix(msgOrig.Body, "@") { + tplFiles := strings.Split(msgOrig.Body[1:], ",") + tplName = path.Base(tplFiles[len(tplFiles)-1]) + tpl, err = tpl.ParseFiles(tplFiles...) + if err != nil { + return nil, err + } + } else { + tpl, err = tpl.Parse(msgOrig.Body) + if err != nil { + return nil, err + } + } + log.Printf("template name is: %s %s", tpl.Name(), tpl.DefinedTemplates()) + if err := tpl.ExecuteTemplate(&output, tplName, msgOrig.Context); err != nil { + return nil, err + } + msg.MIME = strings.TrimSuffix(msgOrig.MIME, "+html/template") + } else { + tpl := textTemplate.New("").Funcs(funcmap.GetFuncMap()) + if strings.HasPrefix(msgOrig.Body, "@") { + tplFiles := strings.Split(msgOrig.Body[1:], ",") + tplName = path.Base(tplFiles[len(tplFiles)-1]) + tpl, err = tpl.ParseFiles(tplFiles...) + if err != nil { + return nil, err + } + } else { + tpl, err = tpl.Parse(msgOrig.Body) + if err != nil { + return nil, err + } + } + log.Printf("template name is: %s %s", tpl.Name(), tpl.DefinedTemplates()) + if err := tpl.ExecuteTemplate(&output, tplName, msgOrig.Context); err != nil { + return nil, err + } + msg.MIME = strings.TrimSuffix(msgOrig.MIME, "+text/template") + } + msg.Body = output.String() + } + if contains(supportedMIMES, msg.MIME) { + return &msg, nil + } + + // convert markdown to html + if msg.MIME == "text/markdown" && !contains(supportedMIMES, "text/markdown") { + msg.Body = string(markdown.ToHTML([]byte(msg.Body), nil, nil)) + msg.MIME = "text/html" + } + // convert html to text + if msg.MIME == "text/html" && !contains(supportedMIMES, "text/html") && contains(supportedMIMES, "text/plain") { + docBuf := strings.NewReader(msg.Body) + doc, err := goquery.NewDocumentFromReader(docBuf) + if err != nil { + return nil, err + } + msg.Body = doc.Text() + msg.MIME = "text/plain" + } + + if !contains(supportedMIMES, msg.MIME) { + return nil, &ErrorMIMENoOverlap{ + MessageMIME: msg.MIME, + supportedMIME: supportedMIMES, + } + } + return &msg, nil +} diff --git a/internal/comm/email/handler.go b/internal/comm/email/handler.go new file mode 100644 index 0000000..bce9d06 --- /dev/null +++ b/internal/comm/email/handler.go @@ -0,0 +1,80 @@ +package email + +import ( + "fmt" + "strings" + + "github.com/eternal-flame-AD/yoake/config" + "github.com/eternal-flame-AD/yoake/internal/comm/model" + "github.com/vanng822/go-premailer/premailer" + "gopkg.in/gomail.v2" +) + +type Message struct { + MIME string + Subject string + Message string + + To string +} + +type Handler struct { + dialer *gomail.Dialer +} + +func NewHandler() (*Handler, error) { + conf := config.Config().Comm.Email + if conf.SMTP.Host == "" || conf.SMTP.Port == 0 { + return nil, fmt.Errorf("invalid email configuration") + } + dialer := gomail.NewDialer(conf.SMTP.Host, conf.SMTP.Port, conf.SMTP.UserName, conf.SMTP.Password) + return &Handler{ + dialer: dialer, + }, nil +} + +func (h *Handler) SendGenericMessage(gmsg model.GenericMessage) error { + msg := Message{ + MIME: gmsg.MIME, + Subject: gmsg.Subject, + Message: gmsg.Body, + } + return h.SendEmail(msg) +} + +func (h *Handler) SupportedMIME() []string { + return []string{"text/plain", "text/html"} +} + +func (h *Handler) SendEmail(msg Message) error { + conf := config.Config().Comm.Email + if !strings.HasPrefix(msg.MIME, "text/html") && + !strings.HasPrefix(msg.MIME, "text/plain") { + return fmt.Errorf("does not know how to send MIME type %s", msg.MIME) + } + if msg.MIME == "text/html" { + prem, err := premailer.NewPremailerFromString(msg.Message, premailer.NewOptions()) + if err != nil { + return err + } + msg.Message, err = prem.Transform() + if err != nil { + return err + } + } + + email := gomail.NewMessage() + email.SetHeader("From", conf.SMTP.From) + if msg.To != "" { + email.SetHeader("To", msg.To) + } else { + email.SetHeader("To", conf.SMTP.To) + } + if msg.Subject != "" { + email.SetHeader("Subject", msg.Subject) + } else { + email.SetHeader("Subject", conf.SMTP.DefaultSubject) + } + email.SetBody(msg.MIME, msg.Message, gomail.SetPartEncoding("base64")) + return h.dialer.DialAndSend(email) +} diff --git a/internal/comm/gotify/model.go b/internal/comm/gotify/model.go new file mode 100644 index 0000000..e59d384 --- /dev/null +++ b/internal/comm/gotify/model.go @@ -0,0 +1,29 @@ +package gotify + +type Message struct { + Message string `json:"message"` + Title string `json:"title"` + Priority int `json:"priority"` + + Extras struct { + ClientDisplay struct { + ContentType string `json:"contentType"` + } `json:"client::display,omitempty"` + ClientNotification struct { + Click struct { + URL string `json:"url"` + } `json:"click,omitempty"` + } `json:"client::notification,omitempty"` + } `json:"extras"` +} + +type Health struct { + Database string `json:"database"` + Health string `json:"health"` +} + +type Version struct { + BuildDate string `json:"buildDate"` + Commit string `json:"commit"` + Version string `json:"version"` +} diff --git a/internal/comm/gotify/send.go b/internal/comm/gotify/send.go new file mode 100644 index 0000000..55e25be --- /dev/null +++ b/internal/comm/gotify/send.go @@ -0,0 +1,103 @@ +package gotify + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/eternal-flame-AD/yoake/config" + "github.com/eternal-flame-AD/yoake/internal/comm/model" + "github.com/eternal-flame-AD/yoake/internal/echoerror" +) + +type Client struct { + conf *config.CommGotify + httpClient *http.Client + baseURL url.URL + serverVersion Version +} + +func NewClient() (*Client, error) { + conf := config.Config().Comm.Gotify + baseURL, err := url.Parse(conf.BaseURL) + if err != nil { + return nil, err + } + + httpClient := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := httpClient.Get(baseURL.ResolveReference(urlVersion).String()) + if err != nil { + return nil, fmt.Errorf("failed to obtain gotify version: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("gotify returned with %d", resp.StatusCode) + } + var version Version + if err := json.NewDecoder(resp.Body).Decode(&version); err != nil { + return nil, fmt.Errorf("failed to decode gotify version: %w", err) + } + resp, err = httpClient.Get(baseURL.ResolveReference(urlHealth).String()) + if err != nil { + return nil, fmt.Errorf("failed to obtain gotify health: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("gotify returned with %d", resp.StatusCode) + } + var health Health + if err := json.NewDecoder(resp.Body).Decode(&health); err != nil { + return nil, fmt.Errorf("failed to decode gotify health: %w", err) + } + + return &Client{ + conf: &conf, + httpClient: httpClient, + baseURL: *baseURL, + serverVersion: version, + }, nil +} + +func (c *Client) SendGenericMessage(gmsg model.GenericMessage) error { + msg := Message{ + Message: gmsg.Body, + Title: gmsg.Subject, + } + msg.Extras.ClientDisplay.ContentType = gmsg.MIME + return c.SendMessage(msg) +} + +func (c *Client) SupportedMIME() []string { + return []string{"text/plain", "text/markdown"} +} + +func (c *Client) SendMessage(msg Message) error { + + reader, writer := io.Pipe() + e := json.NewEncoder(writer) + req, err := http.NewRequest("POST", c.baseURL.ResolveReference(urlMessage).String(), reader) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Gotify-Key", c.conf.AppToken) + go func() { + defer writer.Close() + e.Encode(msg) + }() + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return echoerror.NewHttp(resp.StatusCode, fmt.Errorf("gotify returned with %d", resp.StatusCode)) + } + return nil +} diff --git a/internal/comm/gotify/url.go b/internal/comm/gotify/url.go new file mode 100644 index 0000000..7a7c27e --- /dev/null +++ b/internal/comm/gotify/url.go @@ -0,0 +1,17 @@ +package gotify + +import "net/url" + +var ( + urlMessage = urlMustParse("/message") + urlHealth = urlMustParse("/health") + urlVersion = urlMustParse("/version") +) + +func urlMustParse(s string) *url.URL { + u, err := url.Parse(s) + if err != nil { + panic(err) + } + return u +} diff --git a/internal/comm/interface.go b/internal/comm/interface.go new file mode 100644 index 0000000..30804d0 --- /dev/null +++ b/internal/comm/interface.go @@ -0,0 +1,15 @@ +package comm + +import ( + "github.com/eternal-flame-AD/yoake/internal/comm/model" + "github.com/labstack/echo/v4" +) + +type Communicator interface { + SupportedMIME() []string + SendGenericMessage(message model.GenericMessage) error +} + +type CommunicatorWithRoute interface { + RegisterRoute(g *echo.Group) +} diff --git a/internal/comm/model/generic.go b/internal/comm/model/generic.go new file mode 100644 index 0000000..9c436de --- /dev/null +++ b/internal/comm/model/generic.go @@ -0,0 +1,9 @@ +package model + +type GenericMessage struct { + Subject string `json:"subject" form:"subject" query:"subject"` + Body string `json:"body" form:"body" query:"body"` + MIME string `json:"mime" form:"mime" query:"mime"` + + Context interface{} +} diff --git a/internal/comm/provider.go b/internal/comm/provider.go new file mode 100644 index 0000000..24f54a5 --- /dev/null +++ b/internal/comm/provider.go @@ -0,0 +1,72 @@ +package comm + +import ( + "errors" + "log" + + "github.com/eternal-flame-AD/yoake/internal/comm/email" + "github.com/eternal-flame-AD/yoake/internal/comm/gotify" + "github.com/eternal-flame-AD/yoake/internal/comm/model" +) + +type CommProvider struct { + communicators map[string]Communicator + fallbackCommunicators []string +} + +var ( + errMethodNotSupported = errors.New("method not supported") +) + +func (c *CommProvider) actualSendGenericMessage(tryMethod string, message model.GenericMessage) error { + if comm, ok := c.communicators[tryMethod]; ok { + if convertedMsg, err := ConvertGenericMessage(&message, comm.SupportedMIME()); err == nil { + return comm.SendGenericMessage(*convertedMsg) + } else { + return err + } + } + return errMethodNotSupported +} + +func (c *CommProvider) SendGenericMessage(preferredMethod string, message model.GenericMessage) error { + if preferredMethod == "" { + preferredMethod = c.fallbackCommunicators[0] + } + if err := c.actualSendGenericMessage(preferredMethod, message); err != nil { + log.Printf("Failed to send message using preferred method %s: %v. trying fallback methods", preferredMethod, err) + for _, fallback := range c.fallbackCommunicators { + if fallback == preferredMethod { + continue + } + if err := c.actualSendGenericMessage(fallback, message); err == nil { + log.Printf("Sent message using fallback method %s", fallback) + return nil + } else { + log.Printf("Failed to send message using fallback method %s: %v", fallback, err) + } + } + return err + } + return nil +} + +func InitializeCommProvider() *CommProvider { + comm := &CommProvider{ + communicators: make(map[string]Communicator), + } + if emailHandler, err := email.NewHandler(); err == nil { + comm.communicators["email"] = emailHandler + comm.fallbackCommunicators = append(comm.fallbackCommunicators, "email") + } else { + log.Printf("Failed to initialize email communicator: %v", err) + } + if gotifyHandler, err := gotify.NewClient(); err == nil { + comm.communicators["gotify"] = gotifyHandler + comm.fallbackCommunicators = append(comm.fallbackCommunicators, "gotify") + } else { + log.Printf("Failed to initialize gotify communicator: %v", err) + } + + return comm +} diff --git a/internal/db/badger.go b/internal/db/badger.go new file mode 100644 index 0000000..775c1a5 --- /dev/null +++ b/internal/db/badger.go @@ -0,0 +1,41 @@ +package db + +import "github.com/dgraph-io/badger/v3" + +type BadgerDB struct { + db *badger.DB +} + +func (b *BadgerDB) NewTransaction(readonly bool) DBTxn { + return &BadgerDBTxn{ + txn: b.db.NewTransaction(readonly), + } +} + +type BadgerDBTxn struct { + txn *badger.Txn +} + +func (t *BadgerDBTxn) Set(key []byte, value []byte) error { + return t.txn.Set(key, value) +} + +func (t *BadgerDBTxn) Delete(key []byte) error { + return t.txn.Delete(key) +} + +func (t *BadgerDBTxn) Get(key []byte) ([]byte, error) { + item, err := t.txn.Get(key) + if err != nil { + return nil, err + } + return item.ValueCopy(nil) +} + +func (t *BadgerDBTxn) Commit() error { + return t.txn.Commit() +} + +func (t *BadgerDBTxn) Discard() { + t.txn.Discard() +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..e715802 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,49 @@ +package db + +import ( + "encoding/json" + "errors" + + badger "github.com/dgraph-io/badger/v3" + "github.com/eternal-flame-AD/yoake/config" +) + +type DB interface { + NewTransaction(update bool) DBTxn +} + +type DBTxn interface { + Set(key, value []byte) error + Delete(key []byte) error + Get(key []byte) ([]byte, error) + Commit() error + Discard() +} + +func GetJSON(t DBTxn, key []byte, v interface{}) error { + if data, err := t.Get(key); err != nil { + return err + } else { + return json.Unmarshal(data, v) + } +} + +func SetJSON(t DBTxn, key []byte, v interface{}) error { + if data, err := json.Marshal(v); err != nil { + return err + } else { + return t.Set(key, data) + } +} + +func New(conf config.C) (DB, error) { + if conf.DB.Badger.Dir != "" { + opts := badger.DefaultOptions(conf.DB.Badger.Dir) + if db, err := badger.Open(opts); err != nil { + return nil, err + } else { + return &BadgerDB{db}, nil + } + } + return nil, errors.New("no database configured") +} diff --git a/internal/db/error.go b/internal/db/error.go new file mode 100644 index 0000000..3fccfef --- /dev/null +++ b/internal/db/error.go @@ -0,0 +1,14 @@ +package db + +import ( + "errors" + + "github.com/dgraph-io/badger/v3" +) + +func IsNotFound(err error) bool { + if errors.Is(err, badger.ErrKeyNotFound) { + return true + } + return false +} diff --git a/internal/echoerror/httperror.go b/internal/echoerror/httperror.go new file mode 100644 index 0000000..89e0e8d --- /dev/null +++ b/internal/echoerror/httperror.go @@ -0,0 +1,23 @@ +package echoerror + +type HTTPError interface { + error + Code() int +} + +type httpError struct { + code int + err error +} + +func (e *httpError) Error() string { + return e.err.Error() +} + +func (e *httpError) Code() int { + return e.code +} + +func NewHttp(code int, err error) HTTPError { + return &httpError{code, err} +} diff --git a/internal/echoerror/middleware.go b/internal/echoerror/middleware.go new file mode 100644 index 0000000..f024d09 --- /dev/null +++ b/internal/echoerror/middleware.go @@ -0,0 +1,95 @@ +package echoerror + +import ( + "encoding/json" + "fmt" + "html/template" + "net/http" + + "github.com/labstack/echo/v4" +) + +var htmlErrorTemplate = template.Must(template.New("").Parse(` + + + + + Error + + +

{{.Code}} {{.CodeText}}

+
+

Refer to this message: {{.Message}}

+`)) + +type htmlErrorTemplateCtx struct { + Code int + CodeText string + Message string +} + +type ErrorWriter func(c echo.Context, err error) error + +var ( + JSONWriter = func(c echo.Context, err error) error { + if httpError, ok := err.(HTTPError); ok { + jsonStr, err := json.Marshal(map[string]interface{}{ + "code": httpError.Code(), + "ok": false, + "message": httpError.Error(), + "error": err, + }) + if err != nil { + jsonStr, err = json.Marshal(map[string]interface{}{ + "code": httpError.Code(), + "ok": false, + "message": httpError.Error(), + "error": nil, + }) + if err != nil { + return err + } + } + return c.JSONBlob(httpError.Code(), jsonStr) + } + return c.JSON(500, map[string]interface{}{ + "code": 500, + "ok": false, + "message": err.Error(), + }) + } + + HTMLWriter = func(c echo.Context, err error) error { + errContext := htmlErrorTemplateCtx{ + Code: 500, + Message: "Internal Server Error", + } + if httpError, ok := err.(HTTPError); ok { + errContext.Code = httpError.Code() + errContext.Message = httpError.Error() + } else { + errContext.Message = err.Error() + } + errContext.CodeText = http.StatusText(errContext.Code) + c.Response().Status = errContext.Code + c.Response().Header().Set(echo.HeaderContentType, echo.MIMETextHTMLCharsetUTF8) + return htmlErrorTemplate.Execute(c.Response().Writer, errContext) + } +) + +func Middleware(errorWriter ErrorWriter) func(next echo.HandlerFunc) echo.HandlerFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + err := next(c) + if err != nil { + if errEcho, ok := err.(*echo.HTTPError); ok { + err = NewHttp(errEcho.Code, fmt.Errorf("%s", errEcho.Message)) + } + if err := errorWriter(c, err); err != nil { + return err + } + } + return nil + } + } +} diff --git a/internal/entertainment/api.go b/internal/entertainment/api.go new file mode 100644 index 0000000..7d90b9c --- /dev/null +++ b/internal/entertainment/api.go @@ -0,0 +1,11 @@ +package entertainment + +import ( + "github.com/eternal-flame-AD/yoake/internal/db" + "github.com/labstack/echo/v4" +) + +func Register(g *echo.Group, database db.DB) { + youtube := g.Group("/youtube") + registerYoutube(youtube, database) +} diff --git a/internal/entertainment/util.go b/internal/entertainment/util.go new file mode 100644 index 0000000..86a110e --- /dev/null +++ b/internal/entertainment/util.go @@ -0,0 +1,10 @@ +package entertainment + +func contain[V comparable](slice []V, value V) bool { + for _, v := range slice { + if v == value { + return true + } + } + return false +} diff --git a/internal/entertainment/youtube.go b/internal/entertainment/youtube.go new file mode 100644 index 0000000..1349bf3 --- /dev/null +++ b/internal/entertainment/youtube.go @@ -0,0 +1,421 @@ +package entertainment + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/eternal-flame-AD/yoake/internal/auth" + "github.com/eternal-flame-AD/yoake/internal/db" + "github.com/eternal-flame-AD/yoake/internal/echoerror" + "github.com/labstack/echo/v4" +) + +type YoutubeVideoStore struct { + VideoID string `json:"video_id" form:"video_id" query:"video_id"` + Meta *YoutubeVideoEmbedMeta `json:"meta,omitempty"` + Tags []string `json:"tags"` + Category string `param:"category" json:"category" form:"category" query:"category"` + + Comment string `json:"comment" form:"comment"` +} + +type YoutubeVideoEmbedMeta struct { + Title string `json:"title"` + AuthorName string `json:"author_name"` + AuthorURL string `json:"author_url"` + Type string `json:"type"` + ProviderName string `json:"provider_name"` + ProviderURL string `json:"provider_url"` + ThumbnailURL string `json:"thumbnail_url"` + ThumbnailWidth int `json:"thumbnail_width"` + ThumbnailHeight int `json:"thumbnail_height"` + Html string `json:"html"` + Version string `json:"version"` + Height int `json:"height"` + Width int `json:"width"` +} + +func GetYoutubeVideoInfo(videoUrl string) (info *YoutubeVideoEmbedMeta, err error) { + client := &http.Client{ + Timeout: 10 * time.Second, + } + resp, err := client.Get("https://www.youtube.com/oembed?format=json&url=https://www.youtube.com/watch?v=" + url.QueryEscape(videoUrl)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + info = new(YoutubeVideoEmbedMeta) + err = json.NewDecoder(resp.Body).Decode(info) + if err != nil { + return nil, err + } + return info, nil +} + +type YoutubeCategoryStore struct { + ID string `json:"id" form:"id"` + DisplayName string `json:"display_name" form:"display_name"` +} + +type YoutubeTagStore struct { + ID string `json:"id" form:"id"` + DisplayName string `json:"display_name" form:"display_name"` +} + +type YoutubeVideoDBTxn struct { + txn db.DBTxn +} + +func newYoutubeDBTxn(txn db.DBTxn) *YoutubeVideoDBTxn { + return &YoutubeVideoDBTxn{ + txn: txn, + } +} + +func (t *YoutubeVideoDBTxn) GetCategories() (categories []YoutubeCategoryStore, err error) { + err = db.GetJSON(t.txn, []byte("youtube_categories"), &categories) + return +} + +func (t *YoutubeVideoDBTxn) SetCategories(categories []YoutubeCategoryStore) (err error) { + return db.SetJSON(t.txn, []byte("youtube_categories"), categories) +} + +func (t *YoutubeVideoDBTxn) DeleteCategory(category string) (err error) { + categories, err := t.GetCategories() + if err != nil { + return err + } + newCategories := make([]YoutubeCategoryStore, 0, len(categories)) + for _, categoryS := range categories { + if categoryS.ID != category { + newCategories = append(newCategories, categoryS) + } + } + if err = t.SetCategories(newCategories); err != nil { + return err + } + + if err := t.txn.Delete([]byte("youtube_category:" + category + "_tags")); err != nil && !db.IsNotFound(err) { + return err + } + if err := t.txn.Delete([]byte("youtube_category:" + category + "_videos")); err != nil && !db.IsNotFound(err) { + return err + } + return nil +} + +func (t *YoutubeVideoDBTxn) GetTags(category string) (tags []YoutubeTagStore, err error) { + categories, err := t.GetCategories() + if err != nil { + return nil, err + } + for _, categoryS := range categories { + if categoryS.ID == category { + err = db.GetJSON(t.txn, []byte("youtube_category:"+category+"_tags"), &tags) + return + } + } + return nil, echoerror.NewHttp(404, fmt.Errorf("category not found")) +} + +func (t *YoutubeVideoDBTxn) SetTags(category string, tags []YoutubeTagStore) (err error) { + categories, err := t.GetCategories() + if err != nil { + return err + } + for _, categoryS := range categories { + if categoryS.ID == category { + + return db.SetJSON(t.txn, []byte("youtube_category:"+category+"_tags"), tags) + } + } + return echoerror.NewHttp(404, fmt.Errorf("category not found")) +} + +func (t *YoutubeVideoDBTxn) GetVideos(category string, tags []string) (videos []YoutubeVideoStore, err error) { + videos = make([]YoutubeVideoStore, 0, 16) + categories, err := t.GetCategories() + if err != nil { + return nil, err + } + for _, categoryS := range categories { + if categoryS.ID == category { + tagsAvail, err := t.GetTags(category) + if err != nil { + return nil, err + } + var tagSelected []string + for _, tag := range tags { + for _, tagAvail := range tagsAvail { + if tagAvail.ID == tag { + tagSelected = append(tagSelected, tag) + break + } + } + } + + var videosS []YoutubeVideoStore + if err = db.GetJSON(t.txn, []byte("youtube_category:"+category+"_videos"), &videosS); err != nil { + return nil, err + } + if len(tagSelected) == 0 { + return videosS, nil + } + for _, video := range videosS { + matchtag: + for _, tagA := range tagSelected { + for _, tag := range video.Tags { + if tagA == tag { + videos = append(videos, video) + break matchtag + } + } + } + } + } + } + return videos, nil +} + +func (t *YoutubeVideoDBTxn) SetVideos(category string, videos []YoutubeVideoStore) (err error) { + categories, err := t.GetCategories() + if err != nil { + return err + } + for _, categoryS := range categories { + if categoryS.ID == category { + existingTags, err := t.GetTags(category) + if err != nil { + return err + } + tagsUsed := make(map[string]YoutubeTagStore) + for _, video := range videos { + for _, tag := range video.Tags { + found := false + for _, existingTag := range existingTags { + if existingTag.ID == tag { + tagsUsed[tag] = existingTag + found = true + break + } + } + if !found { + return echoerror.NewHttp(400, fmt.Errorf("tag %s not found", tag)) + } + } + } + tagsUsedList := make([]YoutubeTagStore, 0, len(tagsUsed)) + for _, tag := range tagsUsed { + tagsUsedList = append(tagsUsedList, tag) + } + if err = t.SetTags(category, tagsUsedList); err != nil { + return err + } + return db.SetJSON(t.txn, []byte("youtube_category:"+category+"_videos"), videos) + } + } + return echoerror.NewHttp(404, fmt.Errorf("category not found")) +} + +func registerYoutube(g *echo.Group, database db.DB) { + g.GET("/categories", func(c echo.Context) error { + txn := newYoutubeDBTxn(database.NewTransaction(false)) + defer txn.txn.Discard() + + categories, err := txn.GetCategories() + if err != nil { + return err + } + return c.JSON(http.StatusOK, categories) + }) + + g.GET("/category/:category/tags", func(c echo.Context) error { + txn := newYoutubeDBTxn(database.NewTransaction(false)) + defer txn.txn.Discard() + + tags, err := txn.GetTags(c.Param("category")) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, tags) + }) + g.GET("/category/:category/videos", func(c echo.Context) error { + tags := strings.Split(c.QueryParam("tags"), ",") + txn := newYoutubeDBTxn(database.NewTransaction(false)) + defer txn.txn.Discard() + + videos, err := txn.GetVideos(c.Param("category"), tags) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, videos) + }) + adminG := g.Group("", auth.RequireMiddleware(auth.RoleAdmin)) + { + adminG.POST("/categories", func(c echo.Context) error { + var category YoutubeCategoryStore + if err := c.Bind(&category); err != nil { + return err + } + if category.DisplayName == "" { + return echoerror.NewHttp(400, fmt.Errorf("display name is required")) + } + if category.ID == "" { + category.ID = strings.ToLower( + regexp.MustCompile(`[^0-9a-zA-Z]`).ReplaceAllString(category.DisplayName, "-")) + } + txn := newYoutubeDBTxn(database.NewTransaction(true)) + defer txn.txn.Discard() + + updatedExisting := false + existingCategories, err := txn.GetCategories() + if err != nil { + if !db.IsNotFound(err) { + return err + } + } else { + for i, existingCategory := range existingCategories { + if existingCategory.ID == category.ID { + existingCategories[i].DisplayName = category.DisplayName + updatedExisting = true + } + } + } + if !updatedExisting { + existingCategories = append(existingCategories, category) + } + if err = txn.SetCategories(existingCategories); err != nil { + return err + } + if err := txn.txn.Commit(); err != nil { + return err + } + return c.JSON(http.StatusOK, category) + }) + + adminG.DELETE("/category/:category", func(c echo.Context) error { + txn := newYoutubeDBTxn(database.NewTransaction(true)) + defer txn.txn.Discard() + + if err := txn.DeleteCategory(c.Param("category")); err != nil { + return err + } + + if err := txn.txn.Commit(); err != nil { + return err + } + return c.NoContent(http.StatusOK) + }) + + adminG.POST("/category/:category/tags", func(c echo.Context) error { + var tag YoutubeTagStore + if err := c.Bind(&tag); err != nil { + return err + } + if tag.DisplayName == "" { + return echoerror.NewHttp(400, fmt.Errorf("display name is required")) + } + if tag.ID == "" { + tag.ID = strings.ToLower( + regexp.MustCompile(`[^0-9a-zA-Z]`).ReplaceAllString(tag.DisplayName, "-")) + } + txn := newYoutubeDBTxn(database.NewTransaction(true)) + defer txn.txn.Discard() + updatedExisting := false + + existingTags, err := txn.GetTags(c.Param("category")) + if err != nil { + if !db.IsNotFound(err) { + return err + } + } else { + for i, existingTag := range existingTags { + if existingTag.ID == tag.ID { + existingTags[i].DisplayName = tag.DisplayName + updatedExisting = true + } + } + } + if !updatedExisting { + existingTags = append(existingTags, tag) + } + if err = txn.SetTags(c.Param("category"), existingTags); err != nil { + return err + } + if err := txn.txn.Commit(); err != nil { + return err + } + return c.JSON(http.StatusOK, tag) + }) + + adminG.POST("/category/:category/videos", func(c echo.Context) error { + var video YoutubeVideoStore + if err := c.Bind(&video); err != nil { + return err + } + meta, err := GetYoutubeVideoInfo(video.VideoID) + if err != nil { + return err + } + video.Meta = meta + txn := newYoutubeDBTxn(database.NewTransaction(true)) + defer txn.txn.Discard() + videos, err := txn.GetVideos(c.Param("category"), nil) + updatedExisting := false + if err != nil { + if !db.IsNotFound(err) { + return err + } + } else { + for i, existingVideo := range videos { + if existingVideo.VideoID == video.VideoID { + videos[i].Tags = video.Tags + videos[i].Category = video.Category + videos[i].Meta = video.Meta + videos[i].Comment = video.Comment + updatedExisting = true + } + } + } + if !updatedExisting { + videos = append(videos, video) + } + if err := txn.SetVideos(c.Param("category"), videos); err != nil { + return err + } + if err := txn.txn.Commit(); err != nil { + return err + } + return c.JSON(http.StatusOK, video) + }) + + adminG.DELETE("/category/:category/video/:id", func(c echo.Context) error { + txn := newYoutubeDBTxn(database.NewTransaction(true)) + defer txn.txn.Discard() + videos, err := txn.GetVideos(c.Param("category"), nil) + if err != nil { + return err + } + for i, video := range videos { + if video.VideoID == c.Param("id") { + videos = append(videos[:i], videos[i+1:]...) + } + } + if err := txn.SetVideos(c.Param("category"), videos); err != nil { + return err + } + if err := txn.txn.Commit(); err != nil { + return err + } + return c.NoContent(http.StatusOK) + }) + } +} diff --git a/internal/servetpl/funcmap/auth.go b/internal/servetpl/funcmap/auth.go index ca5eed3..c999008 100644 --- a/internal/servetpl/funcmap/auth.go +++ b/internal/servetpl/funcmap/auth.go @@ -13,11 +13,3 @@ func AuthGet(c echo.Context) auth.RequestAuth { return a } } - -func AuthLogin(c echo.Context) error { - if e := auth.Login(c); e != nil { - c.Error(e) - return e - } - return nil -} diff --git a/internal/servetpl/funcmap/funcmap.go b/internal/servetpl/funcmap/funcmap.go index 2a6be46..7453240 100644 --- a/internal/servetpl/funcmap/funcmap.go +++ b/internal/servetpl/funcmap/funcmap.go @@ -109,6 +109,10 @@ func GetFuncMap() map[string]interface{} { "parse_json": ParseJSON, "json": MarshalJSON, "get_auth": AuthGet, - "auth_login": AuthLogin, + "sprintf": func(format string, input ...interface{}) interface{} { + return fmt.Sprintf(format, input...) + }, + "http": HttpRequest, + "version": Version, } } diff --git a/internal/servetpl/funcmap/http.go b/internal/servetpl/funcmap/http.go new file mode 100644 index 0000000..5309674 --- /dev/null +++ b/internal/servetpl/funcmap/http.go @@ -0,0 +1,52 @@ +package funcmap + +import ( + "io/ioutil" + "net/http" + + "github.com/PuerkitoBio/goquery" +) + +const ( + ResponseTypeHTML = "html" + ResponseTypeStrippedHTML = "html_stripped" + ResponseTypeText = "text" +) + +func HttpRequest(method string, URL string, selector string, responseType string) (data interface{}, err error) { + if method == "" { + method = http.MethodGet + } + if responseType == "" { + responseType = ResponseTypeHTML + } + req, err := http.NewRequest(method, URL, nil) + if err != nil { + return nil, err + } + client := http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if responseType == ResponseTypeHTML || responseType == ResponseTypeStrippedHTML { + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + return nil, err + } + result := doc.Contents() + if selector != "" { + result = result.Find(selector) + } + if responseType == ResponseTypeStrippedHTML { + return result.Text(), nil + } + return result.Html() + } + response, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return string(response), nil +} diff --git a/internal/servetpl/funcmap/version.go b/internal/servetpl/funcmap/version.go new file mode 100644 index 0000000..2210b20 --- /dev/null +++ b/internal/servetpl/funcmap/version.go @@ -0,0 +1,15 @@ +package funcmap + +import "github.com/eternal-flame-AD/yoake/internal/version" + +type V struct { + Version string + BuildDate string +} + +func Version() (*V, error) { + return &V{ + Version: version.Version, + BuildDate: version.BuildDate, + }, nil +} diff --git a/internal/servetpl/middleware.go b/internal/servetpl/middleware.go index fffc2d4..b9ee9c4 100644 --- a/internal/servetpl/middleware.go +++ b/internal/servetpl/middleware.go @@ -7,6 +7,7 @@ import ( "html/template" "io" "log" + "mime" "net/http" "os" "path" @@ -107,11 +108,11 @@ func ServeTemplateDir(dir string) echo.MiddlewareFunc { log.Printf("parsing template: %s", file.Name) if path.Ext(file.File) == ".html" { - if _, err := parseTemplateFileAs[template.FuncMap](templates, file.Name, file.File); err != nil { + if _, err := ParseTemplateFileAs[template.FuncMap](templates, file.Name, file.File); err != nil { log.Panicf("templates failed to parse: %s", err) } } else { - if _, err := parseTemplateFileAs[textTemplate.FuncMap](textTemplates, file.Name, file.File); err != nil { + if _, err := ParseTemplateFileAs[textTemplate.FuncMap](textTemplates, file.Name, file.File); err != nil { log.Panicf("templates failed to parse: %s", err) } } @@ -147,6 +148,8 @@ func ServeTemplateDir(dir string) echo.MiddlewareFunc { return func(c echo.Context) error { req, resp := c.Request(), c.Response() p := path.Clean("/" + req.URL.Path) + ext := path.Ext(p) + c.Response().Header().Set(echo.HeaderContentType, mime.TypeByExtension(ext)) body := &bodyBuffer{resp: resp} defer body.WriteHeader() diff --git a/internal/servetpl/parse.go b/internal/servetpl/parse.go index 5dd9d26..628f2d0 100644 --- a/internal/servetpl/parse.go +++ b/internal/servetpl/parse.go @@ -9,7 +9,7 @@ import ( "github.com/eternal-flame-AD/yoake/internal/servetpl/funcmap" ) -func parseTemplateFileAs[M interface{ ~map[string]any }, T interface { +func ParseTemplateFileAs[M interface{ ~map[string]any }, T interface { *template.Template | *textTemplate.Template Parse(string) (T, error) New(name string) T diff --git a/internal/utilapi/handler.go b/internal/utilapi/handler.go new file mode 100644 index 0000000..682d6bf --- /dev/null +++ b/internal/utilapi/handler.go @@ -0,0 +1,41 @@ +package utilapi + +import ( + "errors" + "time" + + "github.com/alexedwards/argon2id" + "github.com/eternal-flame-AD/yoake/internal/auth" + "github.com/eternal-flame-AD/yoake/internal/echoerror" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +func Register(g *echo.Group) (err error) { + limiterStore := middleware.NewRateLimiterMemoryStoreWithConfig(middleware.RateLimiterMemoryStoreConfig{ + Rate: 1, + Burst: 5, + ExpiresIn: 1 * time.Minute, + }) + + cryptoG := g.Group("/crypto") + { + cryptoG.POST("/argon2id", func(c echo.Context) error { + if passwd := c.FormValue("password"); passwd != "" { + if hash, err := argon2id.CreateHash(passwd, auth.Argon2IdParams); err != nil { + return err + } else { + return c.JSON(200, map[string]string{"hash": hash}) + } + } + return echoerror.NewHttp(400, errors.New("password not provided")) + }, middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ + Skipper: func(c echo.Context) bool { + return auth.GetRequestAuth(c).HasRole(auth.RoleAdmin) + }, + Store: limiterStore, + })) + } + + return nil +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..389d5c5 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,6 @@ +package version + +var ( + Version = "unknown" + BuildDate = "unknown" +) diff --git a/server/webroot/log.go b/server/webroot/log.go index c5bbd14..a25513b 100644 --- a/server/webroot/log.go +++ b/server/webroot/log.go @@ -101,11 +101,14 @@ func logMiddleware(category string, backend echo.MiddlewareFunc) echo.Middleware return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { logSetRequestCategory(c, category) - wrappedNext := func(c echo.Context) error { - logRemoveRequestCategory(c, category) - return next(c) + if backend != nil { + wrappedNext := func(c echo.Context) error { + logRemoveRequestCategory(c, category) + return next(c) + } + return backend(wrappedNext)(c) } - return backend(wrappedNext)(c) + return next(c) } } } diff --git a/server/webroot/server.go b/server/webroot/server.go index b3d74fb..2760152 100644 --- a/server/webroot/server.go +++ b/server/webroot/server.go @@ -7,9 +7,15 @@ import ( "github.com/eternal-flame-AD/yoake/config" "github.com/eternal-flame-AD/yoake/internal/auth" + "github.com/eternal-flame-AD/yoake/internal/canvaslms" + "github.com/eternal-flame-AD/yoake/internal/comm" + "github.com/eternal-flame-AD/yoake/internal/db" + "github.com/eternal-flame-AD/yoake/internal/echoerror" + "github.com/eternal-flame-AD/yoake/internal/entertainment" "github.com/eternal-flame-AD/yoake/internal/servetpl" "github.com/eternal-flame-AD/yoake/internal/session" "github.com/eternal-flame-AD/yoake/internal/twilio" + "github.com/eternal-flame-AD/yoake/internal/utilapi" "github.com/eternal-flame-AD/yoake/server" "github.com/gorilla/context" "github.com/gorilla/sessions" @@ -17,7 +23,7 @@ import ( "github.com/labstack/echo/v4/middleware" ) -func Init(hostname string) { +func Init(hostname string, comm *comm.CommProvider, database db.DB) { e := echo.New() webroot := config.Config().WebRoot @@ -57,14 +63,25 @@ func Init(hostname string) { e.Use(middleware.RequestLoggerWithConfig(lc)) } - e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - defer context.Clear(c.Request()) - c.Set(session.SessionStoreKeyPrefix+"cookie", (sessions.Store)(sessionCookie)) - c.Set(session.SessionStoreKeyPrefix+"fs", (sessions.Store)(fsCookie)) - return next(c) - } - }, + api := e.Group("/api", echoerror.Middleware(echoerror.JSONWriter)) + { + canvaslms.Register(api.Group("/canvas", logMiddleware("api_canvas", nil)), comm) + utilapi.Register(api.Group("/util", logMiddleware("api_util", nil))) + comm.RegisterAPIRoute(api.Group("/comm", logMiddleware("api_comm", nil))) + auth.Register(api.Group("/auth", logMiddleware("api_auth", nil))) + entertainment.Register(api.Group("/entertainment", logMiddleware("api_entertainment", nil)), database) + } + + e.Use( + echoerror.Middleware(echoerror.HTMLWriter), + func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + defer context.Clear(c.Request()) + c.Set(session.SessionStoreKeyPrefix+"cookie", (sessions.Store)(sessionCookie)) + c.Set(session.SessionStoreKeyPrefix+"fs", (sessions.Store)(fsCookie)) + return next(c) + } + }, middleware.Gzip(), auth.Middleware(sessionCookie), logMiddleware("twilio", twilio.VerifyMiddleware("/twilio", config.Config().Twilio.BaseURL)), diff --git a/webroot/auth.tpl.json b/webroot/auth.tpl.json deleted file mode 100644 index 2b7d036..0000000 --- a/webroot/auth.tpl.json +++ /dev/null @@ -1,10 +0,0 @@ -{{ $method := .Request.Method }} -{{ if eq $method "POST" }} - {{ if not ($err := (auth_login .C)) }} - { "status": "success", "message": "Login successful", "success": true } - {{ end }} -{{ else if eq $method "DELETE" }} - {{ void (auth_login .C) }} -{{ else if eq $method "GET" }} -{{ (get_auth .C) | json }} -{{ end }} \ No newline at end of file diff --git a/webroot/includes/head.tpl.html b/webroot/includes/head.tpl.html index bb03ca8..2bc29d6 100644 --- a/webroot/includes/head.tpl.html +++ b/webroot/includes/head.tpl.html @@ -1,4 +1,4 @@ - + @@ -10,7 +10,19 @@ - {{ with get .Global "Title" }}{{ . }} | {{ end }}{{ (call .Config).WebRoot.SiteName }} + {{ with get .Global "Title" }}{{ . }}{{else}}Welcome{{end}} | {{ (call .Config).WebRoot.SiteName }} + + + + + + @@ -23,7 +35,9 @@ + + + + +
+ + + + + + + + + + + -
+
{{ template "/includes/page-dashboard.tpl.html" . }}
-
+
{{ template "/includes/page-twilio.tpl.html" . }}
-
+
{{ template "/includes/page-twilio-recordings.tpl.html" . }}
-
+
+ {{ template "/includes/page-entertainment-youtube.tpl.html" . }} +
+ +
+ {{ template "/includes/page-canvas-grades.tpl.html" . }} +
+ +
+ {{ template "/includes/page-gotify-ui.tpl.html" . }} +
+ +
+ {{ template "/includes/page-vault-ui.tpl.html" . }} +
+ + +
Not Found
diff --git a/webroot/lib/auth.js b/webroot/lib/auth.js index c6a4cde..6ebfa77 100644 --- a/webroot/lib/auth.js +++ b/webroot/lib/auth.js @@ -1,5 +1,5 @@ async function getAuth() { - let body = await fetch("/auth.json?" + new Date(), { method: "GET" }) + let body = await fetch("/api/auth/auth.json?" + new Date(), { method: "GET" }) let bodyJSON = await body.json() return bodyJSON } @@ -36,7 +36,7 @@ function signin() { function signout() { $.ajax({ type: 'DELETE', - url: '/auth.json', + url: '/api/auth/login', success: function (data) { window.location.reload(); }, diff --git a/webroot/lib/error.js b/webroot/lib/error.js new file mode 100644 index 0000000..b43bcb2 --- /dev/null +++ b/webroot/lib/error.js @@ -0,0 +1,42 @@ +function displayErrorToast(title, message) { + const errorToastId = "error-toast"; + if (!document.getElementById(errorToastId)) { + let toastDiv = document.createElement("div"); + toastDiv.classList.add("toast-container", "end-0", "position-fixed", "p-3", "bottom-0"); + toastDiv.innerHTML = ` +