v0.1.0
This commit is contained in:
parent
808d25e901
commit
d8ec9bb883
61 changed files with 3134 additions and 163 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -3,3 +3,4 @@ install.sh
|
|||
*.pid
|
||||
config-*.yml
|
||||
!config-test.yml
|
||||
data
|
||||
|
|
10
Makefile
10
Makefile
|
@ -9,6 +9,9 @@ ifeq ($(INSTALLDEST),)
|
|||
INSTALLDEST := /opt/${PROJECT_NAME}
|
||||
endif
|
||||
|
||||
VERSION := $(shell git describe --tags --exact HEAD || printf "%s" $(shell git rev-parse --short HEAD))
|
||||
BUILDDATE := $(shell date -Iminutes)
|
||||
|
||||
install:
|
||||
mkdir -p $(INSTALLDEST)
|
||||
cp -r dist/* $(INSTALLDEST)
|
||||
|
@ -25,6 +28,8 @@ dev:
|
|||
done
|
||||
|
||||
webroot: $(wildcard webroot/**) FORCE
|
||||
mkdir -p dist
|
||||
cp -r assets dist
|
||||
cp -r webroot dist
|
||||
(cd dist/webroot; ../../scripts/webroot-build.fish)
|
||||
|
||||
|
@ -37,7 +42,10 @@ clean:
|
|||
rm -rf dist
|
||||
|
||||
dist/%: ${CMD_DIR}/% FORCE
|
||||
go build -o $@ ${MODULE_PATH}/$<
|
||||
go build \
|
||||
-ldflags "-X ${MODULE_PATH}/internal/version.Version=$(VERSION) \
|
||||
-X ${MODULE_PATH}/internal/version.BuildDate=$(BUILDDATE)" \
|
||||
-o $@ ${MODULE_PATH}/$<
|
||||
|
||||
.PHONY: build clean
|
||||
FORCE:
|
29
assets/msg-canvas-grades.tpl.html
Normal file
29
assets/msg-canvas-grades.tpl.html
Normal 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>
|
|
@ -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)
|
||||
}
|
||||
|
|
15
config.yml
15
config.yml
|
@ -23,6 +23,21 @@ auth:
|
|||
publicid: vvxxxxxxxxxxxxx
|
||||
role: admin
|
||||
|
||||
comm:
|
||||
gotify:
|
||||
baseurl: https://gotify.example.com/
|
||||
apptoken: Axxxxx
|
||||
email:
|
||||
testmode: off
|
||||
smtp:
|
||||
from: "me@example.com"
|
||||
to: "me@example.com"
|
||||
host: "smtp.example.com"
|
||||
port: "587"
|
||||
username: "me@example.com"
|
||||
password: "xxxx"
|
||||
defaultsubject: "Hi!"
|
||||
|
||||
webroot:
|
||||
sitename: "夜明け"
|
||||
root: dist
|
||||
|
|
20
config/canvas.go
Normal file
20
config/canvas.go
Normal 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
26
config/comm.go
Normal 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
|
||||
}
|
|
@ -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
5
config/db.go
Normal file
|
@ -0,0 +1,5 @@
|
|||
package config
|
||||
|
||||
type DBBadger struct {
|
||||
Dir string
|
||||
}
|
|
@ -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
29
go.mod
|
@ -4,33 +4,52 @@ go 1.19
|
|||
|
||||
require (
|
||||
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible
|
||||
github.com/PuerkitoBio/goquery v1.8.0
|
||||
github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387
|
||||
github.com/eternal-flame-AD/yubigo v0.0.0-20221005082707-ce0c8989e8b1
|
||||
github.com/gomarkdown/markdown v0.0.0-20221013030248-663e2500819c
|
||||
github.com/gorilla/context v1.1.1
|
||||
github.com/gorilla/sessions v1.2.1
|
||||
github.com/jinzhu/configor v1.2.1
|
||||
github.com/labstack/echo/v4 v4.9.1
|
||||
github.com/stretchr/testify v1.8.1
|
||||
github.com/twilio/twilio-go v1.1.1
|
||||
github.com/vanng822/go-premailer v1.20.1
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v0.3.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||
github.com/cespare/xxhash v1.1.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.1 // indirect
|
||||
github.com/dgraph-io/badger/v3 v3.2103.4 // indirect
|
||||
github.com/dgraph-io/ristretto v0.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b // indirect
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6 // indirect
|
||||
github.com/golang/mock v1.6.0 // indirect
|
||||
github.com/golang/protobuf v1.3.1 // indirect
|
||||
github.com/golang/snappy v0.0.3 // indirect
|
||||
github.com/google/flatbuffers v1.12.1 // indirect
|
||||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/gorilla/securecookie v1.1.1 // indirect
|
||||
github.com/klauspost/compress v1.12.3 // indirect
|
||||
github.com/labstack/gommon v0.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.12 // indirect
|
||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/stretchr/testify v1.8.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.1 // indirect
|
||||
github.com/vanng822/css v1.0.1 // indirect
|
||||
go.opencensus.io v0.22.5 // indirect
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect
|
||||
golang.org/x/net v0.0.0-20220728030405-41545e8bf201 // indirect
|
||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956 // indirect
|
||||
golang.org/x/sys v0.0.0-20221010170243-090e33056c14 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/yaml.v2 v2.2.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
140
go.sum
140
go.sum
|
@ -1,26 +1,82 @@
|
|||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible h1:1G1pk05UrOh0NlF1oeaaix1x8XzrfjIDK47TY0Zehcw=
|
||||
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
|
||||
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
|
||||
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
|
||||
github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387 h1:loy0fjI90vF44BPW4ZYOkE3tDkGTy7yHURusOJimt+I=
|
||||
github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387/go.mod h1:GuR5j/NW7AU7tDAQUDGCtpiPxWIOy/c3kiRDnlwiCHc=
|
||||
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
|
||||
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
|
||||
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
|
||||
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgraph-io/badger/v3 v3.2103.4 h1:WE1B07YNTTJTtG9xjBcSW2wn0RJLyiV99h959RKZqM4=
|
||||
github.com/dgraph-io/badger/v3 v3.2103.4/go.mod h1:4MPiseMeDQ3FNCYwRbbcBOGJLf5jsE0PPFzRiKjtcdw=
|
||||
github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
|
||||
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
|
||||
github.com/eternal-flame-AD/yubigo v0.0.0-20221005082707-ce0c8989e8b1 h1:B+ad4UMWwNAUsZhLLQCCrEx+cfLsbf0+AbbcfG7RIv0=
|
||||
github.com/eternal-flame-AD/yubigo v0.0.0-20221005082707-ce0c8989e8b1/go.mod h1:kRnqsWaIjqWNPoCV14+cxs/B9eClc0hKL/I2a3LKOQ4=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6 h1:ZgQEtGgCBiWRM39fZuwSd1LwSqqSW0hOdXCYYDX0R3I=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/snappy v0.0.3 h1:fHPg5GQYlCeLIPB9BZqMVR5nR9A+IM5zcgeTdjMYmLA=
|
||||
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gomarkdown/markdown v0.0.0-20221013030248-663e2500819c h1:iyaGYbCmcYK0Ja9a3OUa2Fo+EaN0cbLu0eKpBwPFzc8=
|
||||
github.com/gomarkdown/markdown v0.0.0-20221013030248-663e2500819c/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw=
|
||||
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
|
||||
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko=
|
||||
github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.12.3 h1:G5AfA94pHPysR56qqrkO2pxEexdDzrpFJ6yt/VqWxVU=
|
||||
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
|
@ -30,20 +86,36 @@ github.com/labstack/echo/v4 v4.9.1/go.mod h1:Pop5HLc+xoc4qhTZ1ip6C0RtP7Z+4VzRLWZ
|
|||
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
|
||||
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
|
||||
github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275/go.mod h1:zt6UU74K6Z6oMOYJbJzYpYucqdcQwSMPBEdSvGiaUMw=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
|
||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
|
@ -51,52 +123,116 @@ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKs
|
|||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/twilio/twilio-go v1.1.1 h1:SBTIqN6wPWd7sykijHyQ2yWZBY9KgT/wUcqNpFupSwA=
|
||||
github.com/twilio/twilio-go v1.1.1/go.mod h1:tdnfQ5TjbewoAu4lf9bMsGvfuJ/QU9gYuv9yx3TSIXU=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/unrolled/render v1.0.3/go.mod h1:gN9T0NhL4Bfbwu8ann7Ry/TGHYfosul+J0obPf6NBdM=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
|
||||
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/vanng822/css v1.0.1 h1:10yiXc4e8NI8ldU6mSrWmSWMuyWgPr9DZ63RSlsgDw8=
|
||||
github.com/vanng822/css v1.0.1/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w=
|
||||
github.com/vanng822/go-premailer v1.20.1 h1:2LTSIULXxNV5IOB5BSD3dlfOG95cq8qqExtRZMImTGA=
|
||||
github.com/vanng822/go-premailer v1.20.1/go.mod h1:RAxbRFp6M/B171gsKu8dsyq+Y5NGsUUvYfg+WQWusbE=
|
||||
github.com/vanng822/r2router v0.0.0-20150523112421-1023140a4f30/go.mod h1:1BVq8p2jVr55Ost2PkZWDrG86PiJ/0lxqcXoAcGxvWU=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
go.opencensus.io v0.22.5 h1:dntmOdLpSpHlVqbW5Eay97DelsZHe+55D+xC6i0dDS0=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220728030405-41545e8bf201 h1:bvOltf3SADAfG05iRml8lAB3qjoEX5RCyN4K6G5v3N0=
|
||||
golang.org/x/net v0.0.0-20220728030405-41545e8bf201/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956 h1:XeJjHH1KiLpKGb6lvMiksZ9l0fVUh+AmGcm0nOMEBOY=
|
||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20221010170243-090e33056c14 h1:k5II8e6QD8mITdi+okbbmR/cIyEbeXLBhy5Ha4nevyc=
|
||||
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 h1:ftMN5LMiBFjbzleLqtoBZk7KdJwhuybIU+FckUHgoyQ=
|
||||
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
|
|
@ -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
11
internal/auth/kdf.go
Normal 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,
|
||||
}
|
|
@ -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
146
internal/canvaslms/graph.go
Normal 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))
|
||||
}
|
318
internal/canvaslms/handler.go
Normal file
318
internal/canvaslms/handler.go
Normal 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
|
||||
}
|
38
internal/canvaslms/message.go
Normal file
38
internal/canvaslms/message.go
Normal 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
68
internal/comm/api.go
Normal 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
116
internal/comm/convert.go
Normal 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
|
||||
}
|
80
internal/comm/email/handler.go
Normal file
80
internal/comm/email/handler.go
Normal 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)
|
||||
}
|
29
internal/comm/gotify/model.go
Normal file
29
internal/comm/gotify/model.go
Normal 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"`
|
||||
}
|
103
internal/comm/gotify/send.go
Normal file
103
internal/comm/gotify/send.go
Normal 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
|
||||
}
|
17
internal/comm/gotify/url.go
Normal file
17
internal/comm/gotify/url.go
Normal 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
|
||||
}
|
15
internal/comm/interface.go
Normal file
15
internal/comm/interface.go
Normal 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)
|
||||
}
|
9
internal/comm/model/generic.go
Normal file
9
internal/comm/model/generic.go
Normal 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
72
internal/comm/provider.go
Normal 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
41
internal/db/badger.go
Normal 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
49
internal/db/db.go
Normal 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
14
internal/db/error.go
Normal 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
|
||||
}
|
23
internal/echoerror/httperror.go
Normal file
23
internal/echoerror/httperror.go
Normal 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}
|
||||
}
|
95
internal/echoerror/middleware.go
Normal file
95
internal/echoerror/middleware.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
11
internal/entertainment/api.go
Normal file
11
internal/entertainment/api.go
Normal 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)
|
||||
}
|
10
internal/entertainment/util.go
Normal file
10
internal/entertainment/util.go
Normal 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
|
||||
}
|
421
internal/entertainment/youtube.go
Normal file
421
internal/entertainment/youtube.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
52
internal/servetpl/funcmap/http.go
Normal file
52
internal/servetpl/funcmap/http.go
Normal 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
|
||||
}
|
15
internal/servetpl/funcmap/version.go
Normal file
15
internal/servetpl/funcmap/version.go
Normal 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
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
41
internal/utilapi/handler.go
Normal file
41
internal/utilapi/handler.go
Normal 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
|
||||
}
|
6
internal/version/version.go
Normal file
6
internal/version/version.go
Normal file
|
@ -0,0 +1,6 @@
|
|||
package version
|
||||
|
||||
var (
|
||||
Version = "unknown"
|
||||
BuildDate = "unknown"
|
||||
)
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)),
|
||||
|
|
|
@ -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 }}
|
|
@ -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]>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
|
|
191
webroot/includes/page-canvas-grades.tpl.html
Normal file
191
webroot/includes/page-canvas-grades.tpl.html
Normal 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 }}
|
430
webroot/includes/page-entertainment-youtube.tpl.html
Normal file
430
webroot/includes/page-entertainment-youtube.tpl.html
Normal 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>
|
0
webroot/includes/page-gotify-send-message.tpl.html
Normal file
0
webroot/includes/page-gotify-send-message.tpl.html
Normal file
6
webroot/includes/page-gotify-ui.tpl.html
Normal file
6
webroot/includes/page-gotify-ui.tpl.html
Normal 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>
|
6
webroot/includes/page-vault-ui.tpl.html
Normal file
6
webroot/includes/page-vault-ui.tpl.html
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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
42
webroot/lib/error.js
Normal 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
4
webroot/lib/lib.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
function doNow(fn) {
|
||||
fn();
|
||||
return fn;
|
||||
}
|
22
webroot/partials/ytplayer.tpl.html
Normal file
22
webroot/partials/ytplayer.tpl.html
Normal 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
2
webroot/vault-ui/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
Loading…
Reference in a new issue