This commit is contained in:
ゆめ 2022-11-11 17:15:22 -05:00
parent 808d25e901
commit d8ec9bb883
61 changed files with 3134 additions and 163 deletions

1
.gitignore vendored
View file

@ -3,3 +3,4 @@ install.sh
*.pid
config-*.yml
!config-test.yml
data

View file

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

View file

@ -0,0 +1,29 @@
<h2>お姫様の成績がアップデートされました!</h2>
<h2><a href="https://yoake.yumechi.jp/#canvas-grades">最近の採点</h2>
<table>
<thead>
<tr>
<td>Name</td>
<td>Course</td>
<td>Assignment</td>
<td>Due</td>
<td>Grade</td>
<td>Graded At</td>
<td>Posted At</td>
</tr>
</thead>
<tbody>
{{ range .Grades }}
<tr>
<td>{{ .SubmissionUserName }}</td>
<td>{{ .CourseCode }}</td>
<td><a href="{{.AssignmentURL}}"">{{ .Name }}</a></td>
<td>{{if eq .Due " -" }}No Due{{else}}{{.Due}}{{end}}</td>
<td>{{if .GradeHidden }}(hidden){{else}}{{if le .Score -0.01}}(not graded){{else}}{{ .Score | sprintf "%.2f"
}} ({{ .Grade }}){{end}} /{{ .PossiblePoints | sprintf "%.2f" }} {{end}}</td>
<td>{{ .GradedAt }} </td>
<td>{{ .PostedAt }}</td>
</tr>
{{ end }}
</tbody>
</table>

View file

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

View file

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

20
config/canvas.go Normal file
View file

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

26
config/comm.go Normal file
View file

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

View file

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

5
config/db.go Normal file
View file

@ -0,0 +1,5 @@
package config
type DBBadger struct {
Dir string
}

View file

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

29
go.mod
View file

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

140
go.sum
View file

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

View file

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

11
internal/auth/kdf.go Normal file
View file

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

View file

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

146
internal/canvaslms/graph.go Normal file
View file

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

View file

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

View file

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

68
internal/comm/api.go Normal file
View file

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

116
internal/comm/convert.go Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

72
internal/comm/provider.go Normal file
View file

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

41
internal/db/badger.go Normal file
View file

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

49
internal/db/db.go Normal file
View file

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

14
internal/db/error.go Normal file
View file

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

View file

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

View file

@ -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(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<h1>{{.Code}} {{.CodeText}}</h1>
<hr>
<p>Refer to this message: {{.Message}}</p>
`))
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
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,6 @@
package version
var (
Version = "unknown"
BuildDate = "unknown"
)

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
@ -10,7 +10,19 @@
<meta name="author" content="">
<link rel="icon" href="../../favicon.ico">
<title>{{ with get .Global "Title" }}{{ . }} | {{ end }}{{ (call .Config).WebRoot.SiteName }}</title>
<title>{{ with get .Global "Title" }}{{ . }}{{else}}Welcome{{end}} | {{ (call .Config).WebRoot.SiteName }}</title>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1.11.6/dayjs.min.js"
integrity="sha256-EfJOqCcshFS/2TxhArURu3Wn8b/XDA4fbPWKSwZ+1B8=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1.11.6/plugin/relativeTime.js"
integrity="sha256-muryXOPFkVJcJO1YFmhuKyXYmGDT2TYVxivG0MCgRzg=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1.11.6/plugin/localizedFormat.js"
integrity="sha256-g+gxm1xmRq4IecSRujv2eKyUCo/i1b5kRnWNcSbYEO0=" crossorigin="anonymous"></script>
<script>
dayjs.extend(window.dayjs_plugin_relativeTime);
dayjs.extend(window.dayjs_plugin_localizedFormat);
</script>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.6.1/dist/jquery.min.js"
integrity="sha256-o88AwQnZB+VDvE9tvIXrMQaPlFFSUTR+nldQm1LuPXQ=" crossorigin="anonymous"></script>
@ -23,7 +35,9 @@
<link rel="stylesheet" href="/style.css">
<script src="/lib/lib.js"></script>
<script src="/lib/auth.js"></script>
<script src="/lib/error.js"></script>
<!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
<!--[if lt IE 9]>

View file

@ -7,66 +7,43 @@
</div>
<div class="modal-body">
<div class="container-fluid">
<ul class="nav nav-tabs" id="loginTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="btn btn-primary active" data-bs-toggle="tab"
data-bs-target="#login-tab-userpass" type="button" role="tab" aria-controls="userpass"
aria-selected="true" id="login-tab-label-userpass">UserPass</button>
</li>
<li class="nav-item" role="presentation">
<button class="btn btn-link" data-bs-toggle="tab" data-bs-target="#login-tab-yubikey"
type="button" role="tab" aria-controls="yubikey" aria-selected="false"
id="login-tab-label-yubikey">Yubikey</button>
<li class="nav-item" role="presentation"></li>
</ul>
<hr />
<div class="alert alert-danger d-none align-items-center" role="alert" id="login-form-error">
<img src={{ trima_img "ja/btn_stop.gif" "url" }} style="height: 4em" class="d-block">
<hr>
<span>An error occured.</span>
</div>
<form action="/api/auth/login" onsubmit="submitLoginForm(this,event)">
<div class="mb-3">
<div class="mb-2">
<label for="username">Username</label>
<input type="text" class="form-control" id="username" name="username"
placeholder="Username">
</div>
<div class="tab-content" id="loginTabContents">
<div class="tab-pane show active" id="login-tab-userpass"
aria-labelledby="login-tab-label-userpass">
<form action="/auth.json" onsubmit="submitLoginForm(this,event)">
<input type="hidden" name="type" value="userpass">
<div class="mb-2">
<label for="username">Username</label>
<input type="text" class="form-control" id="username" placeholder="Username">
</div>
<div class="mb-2">
<label for="password">Password</label>
<input type="password" class="form-control" id="password" placeholder="Password">
</div>
</form>
<div class="mb-2">
<label for="password">Password</label>
<input type="password" class="form-control" name="password" id="password"
placeholder="Password">
</div>
<label for="otp_response">OTP Response (Optional)</label>
<input type="text" class="form-control" name="otp_response" placeholder="OTP Response">
</div>
<div class="tab-pane" id="login-tab-yubikey" aria-labelledby="login-tab-label-yubikey">
<form action="/auth.json" onsubmit="submitLoginForm(this,event)">
<input type="hidden" name="type" value="yubikey">
<div class="mb-1">
<label for="username">OTP Response</label>
<input type="text" class="form-control" name="response" placeholder="OTP Response">
</div>
</form>
</div>
</div>
</form>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="history.back()"
data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="
let form = $('#login-modal div.tab-pane.active form');
form.submit();
">Submit</button>
let form = $('#login-modal form');
form.submit();
">Submit</button>
</div>
</div>
</div>
</div>
</div>
<script>
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(tabEl =>
tabEl.addEventListener('shown.bs.tab', function (event) {

View file

@ -1,5 +1,10 @@
{{ $version := version }}
<header class="navbar navbar-dark sticky-top bg-dark flex-md-nowrap p-0 shadow">
<a class="navbar-brand col-md-3 col-lg-2 me-0 px-3 fs-6" href="#">{{ (call .Config).WebRoot.SiteName }} </a>
<a class="navbar-brand col-md-3 col-lg-2 me-0 px-3 fs-6" href="#">
{{- (call .Config).WebRoot.SiteName -}}
<small class="fw-lighter text-muted px-2">{{ $version.Version }} - {{ $version.BuildDate }}</small>
</a>
<button class="navbar-toggler position-absolute d-md-none collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#sidebar" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>

View file

@ -0,0 +1,191 @@
{{ $auth := (get_auth .C) }}
<h1 class="page-header">Canvas Gradings</h1>
<div>
<input type="button" class="btn btn-primary" id="canvas-grading-refresh-btn" value="Refresh"
onclick="updateConvasGradingTable(1);">
Last refresh: <span id="canvas-grading-last-refresh">never</span>
</div>
<div class="table-responsive">
<table class="table" id="canvas-grading-table">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Assignment</th>
<th scope="col">Name</th>
<th scope="col">Due</th>
<th scope="col">Grade</th>
<th scope="col">Graded At</th>
<th scope="col">Posted At</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row" class="placeholder">Loading...</th>
<td class="placeholder"></td>
<td class="placeholder"></td>
<td class="placeholder"></td>
<td class="placeholder"></td>
<td class="placeholder"></td>
<td class="placeholder"></td>
</tr>
</tbody>
</table>
</div>
{{ if $auth.Valid }}
<script>
(() => {
"use strict";
let last_refresh = dayjs(0);
setInterval(() => {
document.querySelector('#canvas-grading-last-refresh').innerText = last_refresh.fromNow();
}, 2000);
window.updateConvasGradingTable = function (force) {
let refreshBtn = document.getElementById('canvas-grading-refresh-btn');
refreshBtn.disabled = true;
refreshBtn.value = 'Refreshing...';
const newColumn = (ele, ...classList) => {
const td = document.createElement('td');
td.classList.add(...classList);
if (ele instanceof HTMLElement) {
td.appendChild(ele);
} else {
let p = document.createElement('p');
p.innerText = ele;
td.appendChild(p);
}
return td;
};
const relativeTimeTag = (tag, time) => {
if (!(tag instanceof HTMLElement)) {
tag = document.createElement(tag);
}
time = dayjs(time);
if (time.isValid()) {
tag.innerText = time.fromNow();
tag.setAttribute("data-bs-toggle", "tooltip");
tag.setAttribute("data-bs-title", time.format("L LT"));
new bootstrap.Tooltip(tag);
}
else {
tag.innerText = "N/A";
}
return tag;
};
$.ajax({
method: "GET",
url: "/api/canvas/grades" + (force ? "?refresh=1" : ""),
dataType: "json",
success: function (data) {
refreshBtn.disabled = false;
refreshBtn.value = 'Refresh';
let tbody = document.querySelector('#canvas-grading-table tbody');
let newRows = [];
last_refresh = dayjs(data.last_refresh);
document.querySelector('#canvas-grading-last-refresh').innerText = last_refresh.fromNow();
for (const [index, grade] of data.grades.entries()) {
let row = document.createElement("tr");
{
row.appendChild(newColumn(index + 1));
}
{
let assignmentLink = document.createElement("a");
assignmentLink.href = grade.AssignmentURL;
assignmentLink.innerText = grade.Name;
let courseCode = document.createElement("a");
courseCode.href = "https://utexas.instructure.com/courses/" + grade.CourseLegacyID;
courseCode.innerText = grade.CourseCode.replace(/\s*\(.*\)/, "");
courseCode.setAttribute("data-bs-toggle", "tooltip");
courseCode.setAttribute("data-bs-title", grade.CourseCode + " - " + grade.CourseName);
new bootstrap.Tooltip(courseCode);
let assignment = document.createElement("p");
assignment.appendChild(courseCode);
assignment.appendChild(document.createTextNode(" - "));
assignment.appendChild(assignmentLink);
row.appendChild(newColumn(assignment));
}
row.appendChild(newColumn(grade.SubmissionUserName));
{
if (grade.Due == "-") {
row.appendChild(newColumn("No Due"));
} else {
let dueTd = relativeTimeTag("p", grade.Due);
row.appendChild(newColumn(dueTd));
}
}
{
let gradeEle = document.createElement("p");
let gradeTdClass = "";
if (grade.GradeHidden) {
gradeEle.innerText = "(Hidden)";
gradeTdClass = "table-secondary";
} else {
if (grade.Score < 0) {
gradeEle.innerText = "(Not Graded)";
gradeTdClass = "table-light";
} else {
const calculatedPercentage = grade.Score / grade.PossiblePoints * 100;
if (!calculatedPercentage || calculatedPercentage < 50) {
gradeTdClass = "table-danger";
} else if (calculatedPercentage < 80) {
gradeTdClass = "table-warning";
} else if (calculatedPercentage < 95) {
gradeTdClass = "table-info";
} else {
gradeTdClass = "table-success";
}
gradeEle.innerText = `${grade.Score.toFixed(2)} (${grade.Grade})/` +
`${grade.PossiblePoints.toFixed(2)}`;
if (grade.State != "graded") {
let sub = document.createElement("sub");
sub.innerText = `(${grade.State.replace(/_/g, " ")})`;
gradeEle.appendChild(sub);
}
}
}
row.appendChild(newColumn(gradeEle, gradeTdClass));
}
row.appendChild(newColumn(relativeTimeTag("p", grade.GradedAt)));
row.appendChild(newColumn(relativeTimeTag("p", grade.PostedAt)));
newRows.push(row);
}
tbody.replaceChildren(...newRows);
console.log(data);
},
error: function (err) {
refreshBtn.disabled = false;
refreshBtn.value = 'Refresh';
}
})
}
let updateTimer;
document.addEventListener("sidebar-activate", e => {
if (e.detail.page == "canvas-grades") {
updateConvasGradingTable();
updateTimer = setInterval(updateConvasGradingTable, 10000);
} else {
clearInterval(updateTimer);
}
})
})()
</script>
{{ end }}

View file

@ -0,0 +1,430 @@
<h1 class="page-header">Youtube Playlist</h1>
{{ $auth := (get_auth .C) }}
<div class="container">
<div class="row">
<div class="col">
<form class="g-3" id="entertainment-youtube-filter-form" onsubmit="">
<div class="mb-3">
<label class="form-label">
What type of content do you like?
</label>
</div>
<div class="mb-3" class="filter-category" id="entertainment-youtube-filter-form-categories">
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" value="">
<label class="form-check-label">
ExampleCategory
</label>
</div>
</div>
<div class="mb-3">
<label>Tags:</label>
</div>
<div class="mb-3" id="entertainment-youtube-filter-form-tags">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" value="" id="flexCheckChecked" checked>
<label class="form-check-label" for="flexCheckChecked">
ExampleTag1
</label>
</div>
</div>
<div class="mb-3">
<label>I found <span name="result-count">0</span> videos based on this filter, Enjoy!</label>
</div>
<textbox hidden name="filter-results">
</textbox>
<div class="mb-3">
<input type="button" class="btn btn-primary" value="Watch All" name="watch-all">
</div>
</form>
</div>
<div class="col">
{{ template "/partials/ytplayer.tpl.html" . }}
{{ if $auth.Valid }}
{{ if eq (index $auth.Roles 0) "admin" }}
<details>
<summary>Manage</summary>
<form id="entertainment-youtube-edit-form">
<div class="row g-3">
<div class="input-group mb-3">
<span class="input-group-text">Category</span>
<div class="input-group-text">
<input class="form-check-input mt-0" type="checkbox" name="category-use-existing"
aria-label="Use Selected Category" checked>
</div>
<input type="text" class="form-control" aria-label="Category ID" name="category-id"
disabled>
<input type="text" class="form-control" aria-label="Category Name" name="category-name">
<input type="button" class="btn btn-danger" value="DELETE"
onclick="window.submitYoutubeEditForm('category', 'DELETE')">
<input type="button" class="btn btn-warning" value="POST"
onclick="window.submitYoutubeEditForm('category')">
</div>
</div>
<div class="row g-3">
<div class="input-group mb-3">
<span class="input-group-text">Tag</span>
<select class="form-select" name="tag-select">
</select>
<input type="text" class="form-control" aria-label="Tag ID" name="tag-id" disabled>
<input type="text" class="form-control" aria-label="Tag Name" name="tag-name">
<input type="button" class="btn btn-warning" value="POST"
onclick="window.submitYoutubeEditForm('tag')">
</div>
</div>
<div class="row g-3">
<div class="input-group mb-3">
<span class="input-group-text">Video</span>
<input type="text" class="form-control" aria-label="Video ID" name="video-id">
<input type="text" class="form-control" aria-label="Video Comment" name="video-comment">
<input type="button" class="btn btn-warning" value="POST"
onclick="window.submitYoutubeEditForm('video')">
</div>
</div>
</form>
</details>
<script>
(() => {
window.submitYoutubeEditForm = function (type, method) {
method = method || "POST";
let tagSelectorEl = document.getElementById("entertainment-youtube-filter-form-tags");
const selectedCategory = document.querySelector("input[name='category-id']").value;
const selectedTags = tagSelectorEl.getAttribute("data-selected-tags").split(',');
switch (type) {
case "category":
if (method === "DELETE") {
if (!confirm("Are you sure you want to delete this category?")) {
return;
}
$.ajax({
url: "/api/entertainment/youtube/category/"
+ encodeURI(selectedCategory),
type: "DELETE",
success: function (result) {
window.location.reload();
}
});
} else if (method == "POST") {
let categoryPostData = {
"id": document.querySelector("input[name='category-id']").value,
"display_name": document.querySelector("input[name='category-name']").value
}
$.ajax({
url: "/api/entertainment/youtube/categories",
type: "POST",
data: JSON.stringify(categoryPostData),
contentType: "application/json",
success: function (result) {
window.location.reload();
}
});
}
break;
case "tag":
let tagPostData = {
"id": document.querySelector("input[name='tag-id']").value,
"display_name": document.querySelector("input[name='tag-name']").value
}
$.ajax({
url: "/api/entertainment/youtube/category/" +
encodeURI(selectedCategory) + "/tags",
type: "POST",
data: JSON.stringify(tagPostData),
contentType: "application/json",
success: function (result) {
window.location.reload();
}
});
break;
case "video":
let videoPostData = {
"video_id": document.querySelector("input[name='video-id']").value,
"tags": selectedTags,
"comment": document.querySelector("input[name='video-comment']").value
}
$.ajax({
url: "/api/entertainment/youtube/category/" +
encodeURI(selectedCategory) + "/videos",
type: "POST",
data: JSON.stringify(videoPostData),
contentType: "application/json",
success: function (result) {
window.location.reload();
}
});
}
}
})()
</script>
{{ end }}
{{ end }}
</div>
</div>
<hr>
<div class="table-responsive">
<table class="table" id="entertainment-youtube-videos">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Title</th>
<th scope="col">Tags</th>
<th scope="col">Comment</th>
<th scope="col">Watch</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
<script>
(() => {
"use strict";
let form = document.getElementById("entertainment-youtube-filter-form");
form.onsubmit = (e) => e.preventDefault
form.querySelector("input[name='watch-all']").onclick = function () {
let videos = JSON.parse(form.querySelector("textbox[name='filter-results']").innerText);
const videoId = videos[0].video_id;
let playList = [];
for (let i = 1; i < videos.length; i++) {
playList.push(videos[i].video_id);
}
window.openYtPlayer(videoId, { playlist: playList });
}
let editForm = document.getElementById("entertainment-youtube-edit-form");
let editFormTagSelect;
if (editForm) {
editForm.onsubmit = (e) => e.preventDefault
editFormTagSelect = editForm.querySelector("select[name='tag-select']");
editFormTagSelect.onchange = (e) => {
let selected = editFormTagSelect.options[editFormTagSelect.selectedIndex];
let tagId = editForm.querySelector("input[name='tag-id']")
let tagName = editForm.querySelector("input[name='tag-name']")
tagName.value = selected.innerText;
if (selected.value) {
tagId.value = selected.value;
tagId.disabled = true;
} else {
tagId.disabled = false;
}
}
editForm.querySelector("input[name='category-use-existing']").onchange = (e) => {
let input = editForm.querySelector("input[name='category-id']");
if (e.target.checked) {
input.disabled = true;
} else {
input.disabled = false;
}
}
}
window.getEntertainmentYoutubeCategories = function () {
$.ajax({
url: "/api/entertainment/youtube/categories",
type: "GET",
success: function (data) {
let items = [];
for (let [idx, category] of data.entries()) {
let label = document.createElement('label');
label.classList.add('form-check-label');
label.innerText = category.display_name;
let input = document.createElement('input');
input.classList.add('form-check-input');
input.setAttribute('type', 'radio');
input.name = 'category';
input.checked = idx === 0;
input.onchange = doNow(function () {
if (input.checked) {
window.getEntertainmentYoutubeTags(category.id);
if (editForm) {
if (editForm.querySelector("input[name='category-use-existing']").checked) {
editForm.querySelector('input[name="category-id"]').value = category.id;
editForm.querySelector('input[name="category-id"]').disabled = true;
editForm.querySelector('input[name="category-name"]').value = category.display_name;
}
}
}
});
input.setAttribute('value', category.display_name);
input.setAttribute('id', 'youtubePlaylistCate_' + category.id);
let div = document.createElement('div');
div.classList.add('form-check', 'form-check-inline');
div.appendChild(input);
div.appendChild(label);
items.push(div);
}
document.getElementById('entertainment-youtube-filter-form-categories').innerHTML = '';
document.getElementById('entertainment-youtube-filter-form-categories').append(...items);
window.getEntertainmentYoutubeTags(data[0].id);
},
});
}
let cachedTags = {};
window.getEntertainmentYoutubeTags = function (category) {
let editForm = document.getElementById("entertainment-youtube-edit-form");
$.ajax(
{
url: "/api/entertainment/youtube/category/" +
encodeURIComponent(category) + "/tags",
type: "GET",
success: function (data) {
let items = [];
let tagFilterEl = document.getElementById("entertainment-youtube-filter-form-tags");
tagFilterEl.setAttribute("data-selected-tags", data.reduce((acc, tag) =>
(acc ? (acc + ",") : "") + tag.id, ""));
if (editForm)
editFormTagSelect.innerHTML = "";
for (let [idx, tag] of data.entries()) {
cachedTags[tag.id] = tag;
if (editForm) {
let option = document.createElement('option');
option.value = tag.id;
option.innerText = tag.display_name;
editFormTagSelect.appendChild(option);
}
let label = document.createElement('label');
label.classList.add('form-check-label');
let labelSpan = document.createElement('span');
labelSpan.classList.add('badge', 'text-bg-primary');
labelSpan.innerText = tag.display_name;
label.appendChild(labelSpan);
let input = document.createElement('input');
input.classList.add('form-check-input');
input.setAttribute('type', 'checkbox');
input.name = 'tag';
input.checked = true;
input.onchange = function () {
let selectedTags = [];
tagFilterEl.querySelectorAll('input[data-tag-id]').forEach(function (el) {
if (el.checked)
selectedTags.push(el.getAttribute('data-tag-id'));
});
tagFilterEl.setAttribute('data-selected-tags', selectedTags.join(','));
if (selectedTags.length === 0) {
input.checked = true;
return;
}
window.getEntertainmentYoutubeVideos(category, selectedTags);
};
input.setAttribute('data-tag-id', tag.id);
input.setAttribute('value', tag.display_name);
input.setAttribute('id', 'youtubePlaylistTag_' + tag.id);
let div = document.createElement('div');
div.classList.add('form-check', 'form-check-inline');
div.appendChild(input);
div.appendChild(label);
items.push(div);
}
if (editForm) {
let newOption = document.createElement('option');
newOption.innerText = "New...";
newOption.value = "";
editFormTagSelect.appendChild(newOption);
}
document.getElementById('entertainment-youtube-filter-form-tags').innerHTML = '';
document.getElementById('entertainment-youtube-filter-form-tags').append(...items);
window.getEntertainmentYoutubeVideos(category);
},
}
);
}
window.getEntertainmentYoutubeVideos = function (category, tags) {
$.ajax(
{
url: "/api/entertainment/youtube/category/" +
encodeURIComponent(category) + "/videos" + (
tags ? ("?tags=" + tags.join(",")) : ""),
type: "GET",
success: function (data) {
let items = [];
form.querySelector("span[name='result-count']").innerText = data.length;
form.querySelector("[name='filter-results']").innerText = JSON.stringify(data);
for (let [idx, video] of data.entries()) {
let tr = document.createElement('tr');
let no = document.createElement('th');
no.innerText = idx + 1;
tr.appendChild(no);
let titleEl = document.createElement('td');
let titleA = document.createElement('a');
titleA.href = "https://www.youtube.com/watch?v=" + video.video_id;
titleA.target = "_blank";
titleA.innerText = video.meta.title;
titleEl.appendChild(titleA);
let sep = document.createElement('span');
sep.innerText = " by ";
titleEl.appendChild(sep);
let authorA = document.createElement('a');
authorA.href = video.meta.author_url;
authorA.target = "_blank";
authorA.innerText = video.meta.author_name;
titleEl.appendChild(authorA);
tr.appendChild(titleEl);
let tagsEl = document.createElement('td');
for (let tag of video.tags) {
let span = document.createElement('span');
span.classList.add('badge', 'text-bg-primary');
span.innerText = cachedTags[tag].display_name;
tagsEl.appendChild(span);
tagsEl.appendChild(document.createElement("br"));
}
tr.appendChild(tagsEl);
let comment = document.createElement('td');
comment.innerHTML = video.comment;
tr.appendChild(comment);
let buttons = document.createElement('td');
let watchBtn = document.createElement('button');
watchBtn.classList.add('btn');
watchBtn.classList.add('btn-primary');
watchBtn.innerText = 'Watch';
watchBtn.onclick = function () {
window.openYtPlayer(video.video_id);
};
buttons.appendChild(watchBtn);
if (editForm) {
let editBtn = document.createElement('button');
editBtn.classList.add('btn', 'btn-warning', "mx-3");
editBtn.innerText = "Edit";
editBtn.onclick = function () {
editForm.parentNode.open = true;
editForm.querySelector('input[name="video-id"]').value = video.video_id;
editForm.querySelector('input[name="video-tags"]').value = video.tags.join(",");
editForm.querySelector('input[name="video-comment"]').value = video.comment;
}
buttons.appendChild(editBtn);
}
tr.appendChild(buttons);
items.push(tr);
}
let tbody = document.getElementById('entertainment-youtube-videos').getElementsByTagName('tbody')[0];
tbody.innerHTML = '';
tbody.append(...items);
},
}
);
}
$(document).ready(getEntertainmentYoutubeCategories);
})()
</script>

View file

@ -0,0 +1,6 @@
<div class="ratio ratio-16x9">
<iframe src="https://gotify.yumechi.jp/" id="gotify-ui-iframe" title="Gotify" allowfullscreen>
</iframe>
</div>

View file

@ -0,0 +1,6 @@
<div class="ratio ratio-16x9">
<iframe src="https://vault.yumechi.jp/" id="vault-ui-iframe" title="Vault" allowfullscreen>
</iframe>
</div>

View file

@ -1,5 +1,4 @@
<nav id="sidebar-tpl" class="d-none">
<nav id="sidebar-tpl" class="d-none" aria-hidden="true">
</nav>
<script>
getAuth().then(auth => {
@ -50,8 +49,23 @@
if (state[routedPage].login)
signin()
else
routedPageEl && routedPageEl.classList.add("router-page-active");
else {
const event = new CustomEvent('sidebar-activate', {
detail: {
page: routedPage,
pageId: routedPageId,
pageEl: routedPageEl,
}
})
if (routedPageEl) {
routedPageEl.classList.add("router-page-active");
routedPageEl.setAttribute("aria-hidden", "false");
if (routedPageEl.getAttribute("data-title")) {
document.title = document.title.replace(/.+?(?=\|.+$)/, routedPageEl.getAttribute("data-title") + " ") || routedPageEl.getAttribute("data-title");
}
}
document.dispatchEvent(event);
}
sidebarReal.innerHTML = sidebarTpl.innerHTML;
sidebarReal.querySelectorAll("a[data-state-bind]").forEach(a => {
@ -72,7 +86,6 @@
procEl.classList.remove(cls);
})
let classToUse = stateValue.trimaProcedure;
console.log(stateKey, routedPage)
if ((stateKey == routedPage)) {
if (!routedPageEl)
classToUse = "questionable"
@ -88,7 +101,9 @@
if (stateValue.login)
signin()
} else {
document.getElementById("page-" + stateKey).classList.remove("router-page-active")
let pageEl = document.getElementById("page-" + stateKey);
pageEl.classList.remove("router-page-active")
pageEl.setAttribute("aria-hidden", "true");
}
if (stateValue.page) {
@ -105,7 +120,12 @@
renderSidebar(state);
}
window.addEventListener('hashchange', renderSidebar)
document.addEventListener("sidebar-activate", e => {
console.log("sidebar-activate", e.detail)
})
renderSidebar();
})
</script>

View file

@ -1,4 +1,3 @@
{{ set .Global "Title" "Home" }}
{{ template "/includes/head.tpl.html" . }}
<link href="dashboard.css" rel="stylesheet">
</head>
@ -7,7 +6,6 @@
<body>
{{ template "/includes/navbar.tpl.html" .}}
<div class="container-fluid">
{{ template "/includes/modal-login.tpl.html" . }}
@ -26,10 +24,11 @@
</li>
</ul>
<!--
<h6
class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted text-uppercase">
<span>Twilio</span>
<a class="link-secondary" href="#" aria-label="Add a new report">
<a class="link-secondary" href="#" aria-label="Twilio">
<span data-feather="plus-circle" class="align-text-bottom"></span>
</a>
</h6>
@ -50,23 +49,116 @@
</a>
</li>
</ul>
-->
<h6
class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted text-uppercase">
<span>Entertainment</span>
<a class="link-secondary" href="#" aria-label="Entertainment">
<span data-feather="plus-circle" class="align-text-bottom"></span>
</a>
</h6>
<ul class="nav flex-column mb-2">
<li class="nav-item">
<a class="nav-link" href="#entertainment-youtube" data-state-bind="entertainment-youtube">
<span class="sidebar-trima-procedure">
{{ template "/partials/sidebar-trima-procedure-logos.tpl.html" }}
</span>Youtube Playlist
</a>
</li>
</ul>
<h6
class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted text-uppercase">
<span>Canvas LMS</span>
<a class="link-secondary" href="#" aria-label="Canvas LMS">
<span data-feather="plus-circle" class="align-text-bottom"></span>
</a>
</h6>
<ul class="nav flex-column mb-2">
<li class="nav-item">
<a class="nav-link" href="#canvas-grades" data-state-bind="canvas-grades"
data-auth-bind="admin">
<span class="sidebar-trima-procedure">
{{ template "/partials/sidebar-trima-procedure-logos.tpl.html" }}
</span>Grades
</a>
</li>
</ul>
<h6
class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted text-uppercase">
<span>Gotify</span>
<a class="link-secondary" href="#" aria-label="Gotify">
<span data-feather="plus-circle" class="align-text-bottom"></span>
</a>
</h6>
<ul class="nav flex-column mb-2">
<li class="nav-item">
<a class="nav-link" href="#gotify-ui" data-state-bind="gotify-ui">
<span class="sidebar-trima-procedure">
{{ template "/partials/sidebar-trima-procedure-logos.tpl.html" }}
</span>Gotify UI
</a>
</li>
</ul>
<h6
class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted text-uppercase">
<span>Vault</span>
<a class="link-secondary" href="#" aria-label="Vault">
<span data-feather="plus-circle" class="align-text-bottom"></span>
</a>
</h6>
<ul class="nav flex-column mb-2">
<li class="nav-item">
<a class="nav-link" href="#vault-ui" data-state-bind="vault-ui">
<span class="sidebar-trima-procedure">
{{ template "/partials/sidebar-trima-procedure-logos.tpl.html" }}
</span>Vault UI
</a>
</li>
</ul>
</div>
</nav>
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 router-page router-page-active" id="page-dashboard">
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 router-page router-page-active" id="page-dashboard"
data-title="Dashboard">
{{ template "/includes/page-dashboard.tpl.html" . }}
</main>
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 router-page" id="page-twilio">
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 router-page" id="page-twilio" data-title="Twilio">
{{ template "/includes/page-twilio.tpl.html" . }}
</main>
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 router-page" id="page-twilio-recordings">
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 router-page" id="page-twilio-recordings"
data-title="Twilio Recordings">
{{ template "/includes/page-twilio-recordings.tpl.html" . }}
</main>
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 router-page" id="page-not-found">
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 router-page" id="page-entertainment-youtube"
data-title="Youtube Playlist">
{{ template "/includes/page-entertainment-youtube.tpl.html" . }}
</main>
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 router-page" id="page-canvas-grades"
data-title="Canvas Grades">
{{ template "/includes/page-canvas-grades.tpl.html" . }}
</main>
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 router-page" id="page-gotify-ui" data-title="Gotify">
{{ template "/includes/page-gotify-ui.tpl.html" . }}
</main>
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 router-page" id="page-vault-ui" data-title="Vault">
{{ template "/includes/page-vault-ui.tpl.html" . }}
</main>
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 router-page" id="page-not-found" data-title="Not Found">
Not Found
</main>

View file

@ -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();
},

42
webroot/lib/error.js Normal file
View file

@ -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 = `
<div class="toast" role="alert" id="error-toast" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<img src="https://yumechi.jp/img/trima/en/btn_stop.gif" width="20" height="20" class="rounded me-2" aria-hidden="true">
<strong class="me-auto" id="error-toast-title">Error</strong>
</div>
<div class="toast-body">
An unknown error has occurred.
</div>
`
document.body.prepend(toastDiv);
}
let toastDiv = document.getElementById(errorToastId);
toastDiv.querySelector("#error-toast-title").innerText = title || "Error";
toastDiv.querySelector(".toast-body").innerText = message || "An unknown error has occurred.";
let toast = new bootstrap.Toast(toastDiv);
toast.show();
}
$(document).ajaxError(function (event, jqxhr, settings, thrownError) {
if (jqxhr.status == 401) {
signin()
} else if (jqxhr.status) {
if (jqxhr.responseJSON && jqxhr.responseJSON.message) {
displayErrorToast(`${jqxhr.status} ${jqxhr.statusText}`, jqxhr.responseJSON.message);
} else {
displayErrorToast(`${jqxhr.status} ${jqxhr.statusText}`, jqxhr.responseText);
}
} else {
displayErrorToast("Error", event.message);
}
})
window.onerror = function (message, source, lineno, colno, error) {
displayErrorToast("Error", `${message} (${source}:${lineno}:${colno})`);
}

4
webroot/lib/lib.js Normal file
View file

@ -0,0 +1,4 @@
function doNow(fn) {
fn();
return fn;
}

View file

@ -0,0 +1,22 @@
<div class="ratio ratio-16x9">
<iframe id="ytplayer" title="YouTube video" allowfullscreen></iframe>
</div>
<script>
window.openYtPlayer = function (videoId, options) {
const defaultOptions = {
rel: 0,
autoplay: 1,
}
options = Object.assign(defaultOptions, options);
let player = document.getElementById('ytplayer');
let src = "https://www.youtube.com/embed/" + encodeURI(videoId) + `?rel=${options.rel}&autoplay=${options.autoplay}`;
if (options.playlist) {
if (options.playlist instanceof Array) {
options.playlist = options.playlist.join(',');
}
if (options.playlistet)
src += `&playlist=${encodeURIComponent(options.playlist)}`;
}
player.src = src;
};
</script>

2
webroot/vault-ui/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore