From 5d6c8b8aa68c53a6b83a1ed633784674ef6e898d Mon Sep 17 00:00:00 2001 From: eternal-flame-AD Date: Mon, 7 Nov 2022 05:45:02 -0500 Subject: [PATCH] init --- .gitignore | 5 + .vscode/settings.json | 6 + Makefile | 43 +++++ cmd/compile/compile.go | 16 ++ cmd/server/server.go | 40 ++++ config-test.yml | 25 +++ config.yml | 47 +++++ config/config.go | 76 ++++++++ etc/service-install.fish | 34 ++++ etc/yoake-server.service | 14 ++ go.mod | 36 ++++ go.sum | 102 ++++++++++ internal/auth/auth.go | 120 ++++++++++++ internal/auth/role.go | 8 + internal/auth/yubikey.go | 24 +++ internal/servetpl/funcmap/auth.go | 23 +++ internal/servetpl/funcmap/config_test.go | 13 ++ internal/servetpl/funcmap/funcmap.go | 108 +++++++++++ internal/servetpl/funcmap/getset.go | 42 +++++ internal/servetpl/funcmap/json.go | 20 ++ internal/servetpl/funcmap/math.go | 43 +++++ internal/servetpl/funcmap/trimaimg.go | 46 +++++ internal/servetpl/funcmap/twilio.go | 7 + internal/servetpl/funcmap/xml.go | 14 ++ internal/servetpl/middleware.go | 178 ++++++++++++++++++ internal/servetpl/parse.go | 25 +++ internal/session/manager.go | 35 ++++ internal/twilio/twilio_test.go | 36 ++++ internal/twilio/verify.go | 66 +++++++ scripts/webroot-build.fish | 20 ++ server/server.go | 38 ++++ server/vault/server.go | 40 ++++ server/webroot/log.go | 134 +++++++++++++ server/webroot/server.go | 76 ++++++++ webroot/auth.tpl.json | 10 + webroot/dashboard.css | 92 +++++++++ webroot/favicon.build.sh | 1 + webroot/includes/head.tpl.html | 32 ++++ webroot/includes/modal-login.tpl.html | 81 ++++++++ webroot/includes/navbar.tpl.html | 11 ++ webroot/includes/page-dashboard.tpl.html | 104 ++++++++++ webroot/includes/page-demo.tpl.html | 157 +++++++++++++++ .../includes/page-twilio-recordings.tpl.html | 19 ++ webroot/includes/page-twilio.tpl.html | 1 + webroot/includes/sidebar-router.tpl.html | 120 ++++++++++++ webroot/includes/tail.tpl.html | 3 + webroot/index.tpl.html | 75 ++++++++ webroot/kobushi_wo_nigiru.gif | Bin 0 -> 10686 bytes webroot/lib/auth.js | 47 +++++ .../sidebar-trima-procedure-logos.tpl.html | 17 ++ webroot/partials/trima-img-card-end.tpl.html | 2 + webroot/partials/trima-img-card.tpl.html | 12 ++ webroot/style.css | 78 ++++++++ webroot/twilio/head.tpl.xml | 33 ++++ webroot/twilio/voice/entrypoint.tpl.xml | 9 + webroot/twilio/voice/hangup.xml | 3 + webroot/twilio/voice/menu.tpl.xml | 24 +++ webroot/twilio/voice/recordings.tpl.html | 10 + webroot/twilio/voice/submenu/admin.tpl.xml | 5 + .../voice/submenu/connect-finish.tpl.xml | 20 ++ webroot/twilio/voice/submenu/connect.tpl.xml | 29 +++ webroot/twilio/voice/submenu/debug.tpl.xml | 8 + 62 files changed, 2563 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 Makefile create mode 100644 cmd/compile/compile.go create mode 100644 cmd/server/server.go create mode 100644 config-test.yml create mode 100644 config.yml create mode 100644 config/config.go create mode 100755 etc/service-install.fish create mode 100644 etc/yoake-server.service create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/role.go create mode 100644 internal/auth/yubikey.go create mode 100644 internal/servetpl/funcmap/auth.go create mode 100644 internal/servetpl/funcmap/config_test.go create mode 100644 internal/servetpl/funcmap/funcmap.go create mode 100644 internal/servetpl/funcmap/getset.go create mode 100644 internal/servetpl/funcmap/json.go create mode 100644 internal/servetpl/funcmap/math.go create mode 100644 internal/servetpl/funcmap/trimaimg.go create mode 100644 internal/servetpl/funcmap/twilio.go create mode 100644 internal/servetpl/funcmap/xml.go create mode 100644 internal/servetpl/middleware.go create mode 100644 internal/servetpl/parse.go create mode 100644 internal/session/manager.go create mode 100644 internal/twilio/twilio_test.go create mode 100644 internal/twilio/verify.go create mode 100755 scripts/webroot-build.fish create mode 100644 server/server.go create mode 100644 server/vault/server.go create mode 100644 server/webroot/log.go create mode 100644 server/webroot/server.go create mode 100644 webroot/auth.tpl.json create mode 100644 webroot/dashboard.css create mode 100755 webroot/favicon.build.sh create mode 100644 webroot/includes/head.tpl.html create mode 100644 webroot/includes/modal-login.tpl.html create mode 100644 webroot/includes/navbar.tpl.html create mode 100644 webroot/includes/page-dashboard.tpl.html create mode 100644 webroot/includes/page-demo.tpl.html create mode 100644 webroot/includes/page-twilio-recordings.tpl.html create mode 100644 webroot/includes/page-twilio.tpl.html create mode 100644 webroot/includes/sidebar-router.tpl.html create mode 100644 webroot/includes/tail.tpl.html create mode 100644 webroot/index.tpl.html create mode 100644 webroot/kobushi_wo_nigiru.gif create mode 100644 webroot/lib/auth.js create mode 100644 webroot/partials/sidebar-trima-procedure-logos.tpl.html create mode 100644 webroot/partials/trima-img-card-end.tpl.html create mode 100644 webroot/partials/trima-img-card.tpl.html create mode 100644 webroot/style.css create mode 100644 webroot/twilio/head.tpl.xml create mode 100644 webroot/twilio/voice/entrypoint.tpl.xml create mode 100644 webroot/twilio/voice/hangup.xml create mode 100644 webroot/twilio/voice/menu.tpl.xml create mode 100644 webroot/twilio/voice/recordings.tpl.html create mode 100644 webroot/twilio/voice/submenu/admin.tpl.xml create mode 100644 webroot/twilio/voice/submenu/connect-finish.tpl.xml create mode 100644 webroot/twilio/voice/submenu/connect.tpl.xml create mode 100644 webroot/twilio/voice/submenu/debug.tpl.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3962b44 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +dist +install.sh +*.pid +config-*.yml +!config-test.yml diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a27d210 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "files.associations": { + "*.tpl.*": "gotmpl", + "*.tpl.html": "html", + } +} \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4815468 --- /dev/null +++ b/Makefile @@ -0,0 +1,43 @@ +PROJECT_NAME := yoake +MODULE_PATH := github.com/eternal-flame-AD/${PROJECT_NAME} + +CMD_DIR := cmd + +COMMANDS := $(patsubst ${CMD_DIR}/%,%,$(shell find ${CMD_DIR}/ -mindepth 1 -maxdepth 1 -type d)) +COMMANDSDIST = $(addprefix dist/,${COMMANDS}) +ifeq ($(INSTALLDEST),) +INSTALLDEST := /opt/${PROJECT_NAME} +endif + +install: + mkdir -p $(INSTALLDEST) + cp -r dist/* $(INSTALLDEST) + +build: webroot $(COMMANDSDIST) + chmod -R 755 $(COMMANDSDIST) + +dev: + while true; do \ + kill $$(cat .server.pid); \ + make build && \ + (dist/server -c config-dev.yml & echo $$! > .server.pid); \ + inotifywait -e modify -r webroot internal server config && kill $(cat .server.pid) ; \ + done + +webroot: $(wildcard webroot/**) FORCE + cp -r webroot dist + (cd dist/webroot; ../../scripts/webroot-build.fish) + +verify: + go vet ./... + go mod verify + +clean: + rm -rf dist/webroot + rm -rf dist + +dist/%: ${CMD_DIR}/% FORCE + go build -o $@ ${MODULE_PATH}/$< + +.PHONY: build clean +FORCE: \ No newline at end of file diff --git a/cmd/compile/compile.go b/cmd/compile/compile.go new file mode 100644 index 0000000..9f64989 --- /dev/null +++ b/cmd/compile/compile.go @@ -0,0 +1,16 @@ +package main + +import ( + htmlTemplate "html/template" + "log" + "os" +) + +func main() { + + htmlTpl := htmlTemplate.Must(htmlTemplate.ParseGlob("*.tpl.html")) + for _, tpl := range htmlTpl.Templates() { + log.Printf("template: %s", tpl.Name()) + tpl.Execute(os.Stdout, nil) + } +} diff --git a/cmd/server/server.go b/cmd/server/server.go new file mode 100644 index 0000000..56aa1d0 --- /dev/null +++ b/cmd/server/server.go @@ -0,0 +1,40 @@ +package main + +import ( + "flag" + "log" + + "github.com/eternal-flame-AD/yoake/config" + "github.com/eternal-flame-AD/yoake/server" + "github.com/eternal-flame-AD/yoake/server/vault" + "github.com/eternal-flame-AD/yoake/server/webroot" +) + +var ( + flagConfig = flag.String("c", "config.yml", "config file") +) + +func init() { + flag.Parse() + config.ParseConfig(*flagConfig) + + conf := config.Config() + for host, handler := range conf.Hosts { + switch handler { + case "vault": + vault.Init(host) + case "webroot": + webroot.Init(host) + default: + log.Panicf("unknown handler for %s: %s", host, handler) + } + } +} +func main() { + listen := config.Config().Listen + if listen.Ssl.Use { + log.Fatalln(server.Server.StartTLS(listen.Addr, listen.Ssl.Cert, listen.Ssl.Key)) + } else { + log.Fatalln(server.Server.Start(listen.Addr)) + } +} diff --git a/config-test.yml b/config-test.yml new file mode 100644 index 0000000..448c4b4 --- /dev/null +++ b/config-test.yml @@ -0,0 +1,25 @@ +listen: + addr: :10098 + ssl: + use: off + +hosts: + "vault": vault + "": webroot + +webroot: + sitename: "SITE NAME" + root: dist/webroot + sessionkey: test + log: + allrequests: on + +upstream: + vault: + api: "http://vault" + ui: "file://dist/webroot/vault-ui" + +twilio: + accountsid: ACxxxxxxxxxxxxxxxxxx + authtoken: "12345" + skipverify: off \ No newline at end of file diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..85ee865 --- /dev/null +++ b/config.yml @@ -0,0 +1,47 @@ +listen: + addr: :80 + ssl: + use: off + cert: "fullchain.pem" + key: "privkey.pem" + +hosts: + "vault.invalid": vault + "root.invalid": webroot + +auth: + validminutes: 1440 + method: + userpass: + + yubikey: + clientid: "12345" + clientkey: "xxxxx" + keys: + - + name: keyname + publicid: vvxxxxxxxxxxxxx + role: admin + +webroot: + sitename: "夜明け" + root: dist + sessionkey: should_be_random + secure: + hsts_max_age: 0 + log: + indent: off + filter: + - "!(status_404)" + - "(status_4xx)" + - "(status_5xx)" + - "!(static)" + +upstream: + vault: + api: "http://vault.invalid" + ui: "file:///dist/vault-ui" + +twilio: + accountsid: ACxxxxx + authtoken: xxxxxx \ No newline at end of file diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..98bafa7 --- /dev/null +++ b/config/config.go @@ -0,0 +1,76 @@ +package config + +import ( + "github.com/jinzhu/configor" + "github.com/labstack/echo/v4/middleware" +) + +type C struct { + Hosts map[string]string + Listen struct { + Addr string + Ssl struct { + Use bool + Cert string + Key string + } + } + WebRoot struct { + SiteName string + Root string + SessionKey string + SessionDir string + Secure *middleware.SecureConfig + Log *struct { + Filter []string + Indent bool + } + } + Upstream struct { + Vault struct { + API string + UI string + } + } + Twilio struct { + AccountSid string + AuthToken string + SkipVerify bool + } + Auth struct { + ValidMinutes int + Method struct { + UserPass struct { + } + Yubikey struct { + ClientId string + ClientKey string + Keys []struct { + Name string + PublicId string + Role string + } + } + } + } +} + +var parsedC C + +var c C + +func Config() C { + return c +} + +func MockConfig(freshEnv bool, wrapper func(deployedC *C)) { + if freshEnv { + c = parsedC + } + wrapper(&c) +} + +func ParseConfig(files ...string) { + configor.Load(&parsedC, files...) + c = parsedC +} diff --git a/etc/service-install.fish b/etc/service-install.fish new file mode 100755 index 0000000..c72ce6f --- /dev/null +++ b/etc/service-install.fish @@ -0,0 +1,34 @@ +#!/bin/env fish + +make verify + or exit 2 + +make build + or exit 2 + + +sudo cp etc/yoake-server.service /etc/systemd/system/yoake-server.service + or exit 2 + +sudo systemctl daemon-reload + or exit 2 + +sudo systemctl stop yoake-server.service + +if [ -f ~caddy/yoake ] + sudo rm -rf ~caddy/yoake/* + or exit 2 +end + +sudo mkdir -p ~caddy/yoake + or exit 2 + +sudo -ucaddy make INSTALLDEST=(echo ~caddy/yoake) install + or exit 2 + +cat config-prod.yml | sudo -ucaddy tee ~caddy/yoake/config.yml > /dev/null + or exit 2 + +sudo systemctl start yoake-server.service + +systemctl status yoake-server.service \ No newline at end of file diff --git a/etc/yoake-server.service b/etc/yoake-server.service new file mode 100644 index 0000000..c9be2d5 --- /dev/null +++ b/etc/yoake-server.service @@ -0,0 +1,14 @@ +[Unit] +Description=Yoake Server + +[Service] +User=caddy +SupplementaryGroups=ssl +WorkingDirectory=/var/lib/caddy/yoake +ExecStart=/var/lib/caddy/yoake/server +Restart=always +AmbientCapabilities=CAP_NET_BIND_SERVICE + +[Install] +WantedBy=multi-user.target + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e8a71be --- /dev/null +++ b/go.mod @@ -0,0 +1,36 @@ +module github.com/eternal-flame-AD/yoake + +go 1.19 + +require ( + github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible + github.com/eternal-flame-AD/yubigo v0.0.0-20221005082707-ce0c8989e8b1 + 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 +) + +require ( + github.com/BurntSushi/toml v0.3.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/golang/mock v1.6.0 // indirect + github.com/gorilla/securecookie v1.1.1 // 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/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.1 // 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/text v0.3.7 // indirect + golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect + gopkg.in/yaml.v2 v2.2.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3509087 --- /dev/null +++ b/go.sum @@ -0,0 +1,102 @@ +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/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= +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/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/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/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +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/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/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko= +github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc= +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= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v4 v4.9.1 h1:GliPYSpzGKlyOhqIbG8nmHBo3i1saKWFOgh41AN3b+Y= +github.com/labstack/echo/v4 v4.9.1/go.mod h1:Pop5HLc+xoc4qhTZ1ip6C0RtP7Z+4VzRLWZZFKqbbjo= +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/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/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/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/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.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= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +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/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/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +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-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +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-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +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/sync v0.0.0-20190423024810-112230192c58/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-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-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-20210510120138-977fb7262007/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/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.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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +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-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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/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= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..b270489 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,120 @@ +package auth + +import ( + "log" + "net/http" + "time" + + "github.com/eternal-flame-AD/yoake/config" + "github.com/gorilla/sessions" + "github.com/labstack/echo/v4" +) + +const AuthSessionName = "auth_session" + +type RequestAuth struct { + Valid bool + Roles []string + Expire time.Time +} + +func Middleware(store sessions.Store) echo.MiddlewareFunc { + yubiAuthLazyInit() + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + + sess, _ := store.Get(c.Request(), AuthSessionName) + sess.Options = &sessions.Options{ + Path: "/", + HttpOnly: true, + } + + var auth RequestAuth + if expireTs, ok := sess.Values["expire"].(string); ok { + if expireTime, err := time.Parse(time.RFC3339, expireTs); err != nil { + log.Printf("invalid expireTime: %v", expireTs) + } else { + auth.Expire = expireTime + if time.Now().Before(expireTime) { + auth.Valid = true + } + } + } + + if existingRoles, ok := sess.Values["roles"].([]string); !ok { + sess.Values["roles"] = []string{} + sess.Save(c.Request(), c.Response()) + } else if auth.Valid { + auth.Roles = existingRoles + } + + c.Set("auth_"+AuthSessionName, auth) + c.Set("auth_store", store) + + return next(c) + } + } +} + +func issueSession(c echo.Context, period time.Duration, baseRole string) error { + sess, _ := c.Get("auth_store").(sessions.Store).Get(c.Request(), AuthSessionName) + sess.Options = &sessions.Options{ + Path: "/", + HttpOnly: true, + } + if period == 0 { + period = time.Duration(config.Config().Auth.ValidMinutes) * time.Minute + } + if period < 0 { + 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) + } + 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") + } + +} +func GetRequestAuth(c echo.Context) RequestAuth { + return c.Get("auth_" + AuthSessionName).(RequestAuth) +} diff --git a/internal/auth/role.go b/internal/auth/role.go new file mode 100644 index 0000000..edef395 --- /dev/null +++ b/internal/auth/role.go @@ -0,0 +1,8 @@ +package auth + +type Role string + +const ( + RoleAdmin Role = "admin" + RoleUser Role = "user" +) diff --git a/internal/auth/yubikey.go b/internal/auth/yubikey.go new file mode 100644 index 0000000..971d2f1 --- /dev/null +++ b/internal/auth/yubikey.go @@ -0,0 +1,24 @@ +package auth + +import ( + "log" + + "github.com/eternal-flame-AD/yoake/config" + "github.com/eternal-flame-AD/yubigo" +) + +var yubiAuth *yubigo.YubiAuth + +func yubiAuthLazyInit() { + if yubiAuth != nil { + 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 { + log.Panicf("failed to load yubigo: %v", err) + } else { + yubiAuth = a + } + } +} diff --git a/internal/servetpl/funcmap/auth.go b/internal/servetpl/funcmap/auth.go new file mode 100644 index 0000000..ca5eed3 --- /dev/null +++ b/internal/servetpl/funcmap/auth.go @@ -0,0 +1,23 @@ +package funcmap + +import ( + "github.com/eternal-flame-AD/yoake/internal/auth" + "github.com/labstack/echo/v4" +) + +func AuthGet(c echo.Context) auth.RequestAuth { + a := auth.GetRequestAuth(c) + if !a.Valid { + return auth.RequestAuth{} + } else { + return a + } +} + +func AuthLogin(c echo.Context) error { + if e := auth.Login(c); e != nil { + c.Error(e) + return e + } + return nil +} diff --git a/internal/servetpl/funcmap/config_test.go b/internal/servetpl/funcmap/config_test.go new file mode 100644 index 0000000..8308471 --- /dev/null +++ b/internal/servetpl/funcmap/config_test.go @@ -0,0 +1,13 @@ +package funcmap + +import ( + "github.com/eternal-flame-AD/yoake/config" +) + +func init() { + config.ParseConfig("../../../config-test.yml") + config.MockConfig(true, func(config *config.C) { + config.Twilio.AuthToken = "12345" + config.Twilio.SkipVerify = false + }) +} diff --git a/internal/servetpl/funcmap/funcmap.go b/internal/servetpl/funcmap/funcmap.go new file mode 100644 index 0000000..9a0c689 --- /dev/null +++ b/internal/servetpl/funcmap/funcmap.go @@ -0,0 +1,108 @@ +package funcmap + +import ( + "fmt" + "log" + "reflect" +) + +var ErrEarlyTermination = fmt.Errorf("template was early terminated by calling {{ stop }}") + +func Stop() (string, error) { + return "", ErrEarlyTermination +} + +func Lookup(name string, args ...reflect.Value) (interface{}, error) { + if len(args) == 0 { + return nil, fmt.Errorf("lookup expects at least 1 argument, got 0") + } + zero := reflect.ValueOf("") + + target := args[0] + log.Printf("lookup %s %v", name, target) + if len(args) > 1 { + zero = args[1] + } + switch target.Kind() { + case reflect.Map: + if target.IsNil() || target.Type().Elem().Kind() != zero.Kind() { + target = reflect.MakeMap(reflect.MapOf(target.Type().Key(), zero.Type())) + target.SetMapIndex(reflect.ValueOf(name), zero) + } + + return target.MapIndex(reflect.ValueOf(name)).Interface(), nil + case reflect.Pointer: + field := target.MethodByName(name) + if field.IsValid() { + return field.Interface(), nil + } + case reflect.Struct: + field := target.FieldByName(name) + if field.IsValid() { + return field.Interface(), nil + } + case reflect.Interface: + method := target.MethodByName(name) + if method.IsValid() { + return method.Interface(), nil + } + default: + return nil, fmt.Errorf("cannot lookup %s from type %v", name, target.Type()) + } + return nil, fmt.Errorf("no such method or field %s", name) +} + +func Invoke(name string, target reflect.Value, args ...reflect.Value) (any, error) { + if name != "" { + t, err := Lookup(name, target) + if err != nil { + return nil, err + } + target = reflect.ValueOf(t) + } + ret := target.Call(args) + if len(ret) == 0 { + return nil, nil + } + + if err, ok := ret[len(ret)-1].Interface().(error); ok && err != nil { + return nil, err + } + + switch len(ret) { + case 0: + return nil, nil + case 1: + return ret[0].Interface(), nil + default: + var rets []any + for _, r := range ret { + rets = append(rets, r.Interface()) + } + return rets, nil + } + +} + +func Void(args ...reflect.Value) string { + return "" +} + +func GetFuncMap() map[string]interface{} { + return map[string]interface{}{ + "lookup": Lookup, + "invoke": Invoke, + "void": Void, + "get": FuncGet, + "set": FuncSet, + "math": Math, + "xml": EscapeXML, + "twilio_validate": TwilioValidate, + "stop": Stop, + "trima_img": TrimaImg, + "parse_json": ParseJSON, + "json": MarshalJSON, + "get_auth": AuthGet, + "auth_login": AuthLogin, + } +} diff --git a/internal/servetpl/funcmap/getset.go b/internal/servetpl/funcmap/getset.go new file mode 100644 index 0000000..5f69b57 --- /dev/null +++ b/internal/servetpl/funcmap/getset.go @@ -0,0 +1,42 @@ +package funcmap + +import ( + "fmt" + "reflect" +) + +func FuncGet(target reflect.Value, name string) (interface{}, error) { + switch target.Kind() { + case reflect.Map: + if target.IsNil() { + target = reflect.MakeMap(reflect.MapOf(target.Type().Key(), reflect.TypeOf(""))) + } + v := target.MapIndex(reflect.ValueOf(name)) + if !v.IsValid() { + return nil, nil + } + return v.Interface(), nil + case reflect.Struct: + case reflect.Interface: + return Lookup(name, target) + } + + return nil, fmt.Errorf("cannot get %s from type %v", name, target.Type()) +} + +func FuncSet(target reflect.Value, name string, value interface{}) (interface{}, error) { + switch target.Kind() { + case reflect.Map: + target.SetMapIndex(reflect.ValueOf(name), reflect.ValueOf(value)) + return "", nil + case reflect.Struct: + case reflect.Interface: + field := target.FieldByName(name) + if field.IsValid() { + field.Set(reflect.ValueOf(value)) + return "", nil + } + } + + return "", fmt.Errorf("cannot set %s to type %v", name, target.Type()) +} diff --git a/internal/servetpl/funcmap/json.go b/internal/servetpl/funcmap/json.go new file mode 100644 index 0000000..1adeddd --- /dev/null +++ b/internal/servetpl/funcmap/json.go @@ -0,0 +1,20 @@ +package funcmap + +import "encoding/json" + +func ParseJSON(s string) (interface{}, error) { + var v interface{} + + if err := json.Unmarshal([]byte(s), &v); err != nil { + return nil, err + } + return v, nil +} + +func MarshalJSON(v interface{}) (string, error) { + b, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/internal/servetpl/funcmap/math.go b/internal/servetpl/funcmap/math.go new file mode 100644 index 0000000..dd848ca --- /dev/null +++ b/internal/servetpl/funcmap/math.go @@ -0,0 +1,43 @@ +package funcmap + +import ( + "encoding/json" + "fmt" + + "github.com/Knetic/govaluate" +) + +func Math(expS string, args ...interface{}) (interface{}, error) { + exp, err := govaluate.NewEvaluableExpressionWithFunctions(expS, + map[string]govaluate.ExpressionFunction{ + "argv": func(arguments ...interface{}) (interface{}, error) { + + if len(arguments) != 1 { + return nil, fmt.Errorf("argv expects 1 argument, got %d", len(arguments)) + } + idx := int(arguments[0].(float64)) + + if idx < 0 || idx > len(args) { + return nil, fmt.Errorf("argv index out of range: %d", idx) + } + if idx == 0 { + return expS, nil + } + + vJ, err := json.Marshal(args[idx-1]) + if err != nil { + return nil, err + } + var v interface{} + if err := json.Unmarshal(vJ, &v); err != nil { + return nil, err + } + + return v, nil + }, + }) + if err != nil { + return nil, err + } + return exp.Evaluate(nil) +} diff --git a/internal/servetpl/funcmap/trimaimg.go b/internal/servetpl/funcmap/trimaimg.go new file mode 100644 index 0000000..845e298 --- /dev/null +++ b/internal/servetpl/funcmap/trimaimg.go @@ -0,0 +1,46 @@ +package funcmap + +import ( + "encoding/base64" + "fmt" + "io/ioutil" + "net/http" + "time" +) + +var httpClient = &http.Client{ + Timeout: 10 * time.Second, +} + +func TrimaImg(path string, retType string) (string, error) { + url := "https://yumechi.jp/img/trima/" + path + download := func() ([]byte, error) { + resp, err := httpClient.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, fmt.Errorf("bad status code: %d", resp.StatusCode) + } + return ioutil.ReadAll(resp.Body) + } + switch retType { + case "url": + return url, nil + case "data": + data, err := download() + if err != nil { + return "", err + } + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(data), nil + case "raw": + data, err := download() + if err != nil { + return "", err + } + return string(data), nil + } + + return "", fmt.Errorf("unknown return type: %s", retType) +} diff --git a/internal/servetpl/funcmap/twilio.go b/internal/servetpl/funcmap/twilio.go new file mode 100644 index 0000000..edcdf74 --- /dev/null +++ b/internal/servetpl/funcmap/twilio.go @@ -0,0 +1,7 @@ +package funcmap + +import ( + "github.com/eternal-flame-AD/yoake/internal/twilio" +) + +var TwilioValidate = twilio.TwilioValidate diff --git a/internal/servetpl/funcmap/xml.go b/internal/servetpl/funcmap/xml.go new file mode 100644 index 0000000..06ab05f --- /dev/null +++ b/internal/servetpl/funcmap/xml.go @@ -0,0 +1,14 @@ +package funcmap + +import ( + "bytes" + "encoding/xml" +) + +func EscapeXML(s string) (string, error) { + var b bytes.Buffer + if err := xml.EscapeText(&b, []byte(s)); err != nil { + return "", err + } + return b.String(), nil +} diff --git a/internal/servetpl/middleware.go b/internal/servetpl/middleware.go new file mode 100644 index 0000000..fffc2d4 --- /dev/null +++ b/internal/servetpl/middleware.go @@ -0,0 +1,178 @@ +package servetpl + +import ( + "bytes" + "errors" + "fmt" + "html/template" + "io" + "log" + "net/http" + "os" + "path" + "path/filepath" + + "strings" + textTemplate "text/template" + + "github.com/eternal-flame-AD/yoake/config" + "github.com/eternal-flame-AD/yoake/internal/servetpl/funcmap" + "github.com/eternal-flame-AD/yoake/internal/session" + "github.com/labstack/echo/v4" +) + +type TemplatePath struct { + File string + Name string +} + +type Context struct { + CleanPath string + Config func() config.C + C echo.Context + Request *http.Request + Response *echo.Response + WriteHeaders func(headers ...string) error + Session session.Provider + Global map[string]interface{} +} + +type bodyBuffer struct { + resp *echo.Response + bodyBuf bytes.Buffer + + committed bool +} + +func (b *bodyBuffer) Write(p []byte) (int, error) { + if !b.committed { + return b.bodyBuf.Write(p) + } + return b.resp.Write(p) +} + +func (b *bodyBuffer) WriteHeader(headers ...string) error { + if b.committed { + return nil + } + for _, header := range headers { + h := strings.SplitN(header, ":", 2) + if len(h) != 2 { + return fmt.Errorf("invalid header %s", header) + } + h[0] = strings.TrimSpace(h[0]) + h[1] = strings.TrimSpace(h[1]) + b.resp.Header().Set(h[0], h[1]) + } + b.committed = true + if _, err := io.Copy(b.resp, &b.bodyBuf); err != nil { + return err + } + return nil +} + +var ( + errUndefinedTemplate = errors.New("undefined template") + errTplExtNotStripped = errors.New("this is a template file and should be requested without the template extension inflix") +) + +func ServeTemplateDir(dir string) echo.MiddlewareFunc { + dir = filepath.Clean(dir) + var tplFiles []TemplatePath + + if err := filepath.Walk(dir, func(file string, stat os.FileInfo, err error) error { + if err != nil { + return err + } + if stat.IsDir() { + return nil + } + ext := path.Ext(file) + secondExt := path.Ext(strings.TrimSuffix(file, ext)) + if secondExt == ".tpl" { + relPath, err := filepath.Rel(dir, file) + if err != nil { + return err + } + tplFiles = append(tplFiles, TemplatePath{File: file, Name: "/" + relPath}) + } + return nil + }); err != nil { + log.Panicf("templates failed to parse: %s", err) + } + + templates := template.New("").Funcs(funcmap.GetFuncMap()) + textTemplates := textTemplate.New("").Funcs(funcmap.GetFuncMap()) + for _, file := range tplFiles { + 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 { + log.Panicf("templates failed to parse: %s", err) + } + } else { + if _, err := parseTemplateFileAs[textTemplate.FuncMap](textTemplates, file.Name, file.File); err != nil { + log.Panicf("templates failed to parse: %s", err) + } + } + + } + dispatchTemplate := func(file string) func(wr io.Writer, data any) error { + ext := path.Ext(file) + tplName := file[:len(file)-len(ext)] + ".tpl" + ext + if path.Ext(file[:len(file)-len(ext)]) == ".tpl" { + /* reject requests for the template source file */ + log.Printf("rejecting request for template source file: %s", file) + if _, err := os.Stat(filepath.Join(dir, file)); err == nil { + return func(wr io.Writer, data any) error { + return errTplExtNotStripped + } + } + } + + tplPath := filepath.Join(dir, tplName) + if _, err := os.Stat(tplPath); err == nil { + log.Printf("dispatch template: %s(%s) ext=%s", tplName, tplPath, ext) + // template file is still there, execute + if ext == ".html" { + return func(wr io.Writer, data any) error { return templates.ExecuteTemplate(wr, tplName, data) } + } else { + return func(wr io.Writer, data any) error { return textTemplates.ExecuteTemplate(wr, tplName, data) } + } + } + return func(wr io.Writer, data any) error { return errUndefinedTemplate } + } + + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + req, resp := c.Request(), c.Response() + p := path.Clean("/" + req.URL.Path) + + body := &bodyBuffer{resp: resp} + defer body.WriteHeader() + + sess, sessClose := session.ManagedSession(c) + defer sessClose() + if err := dispatchTemplate(p)(body, Context{ + Config: config.Config, + C: c, + CleanPath: p, + Request: req, + Response: resp, + WriteHeaders: body.WriteHeader, + Session: sess, + Global: map[string]interface{}{}, + }); err == errUndefinedTemplate { + return next(c) + } else if errors.Is(err, funcmap.ErrEarlyTermination) { + return nil + } else if errors.Is(err, errTplExtNotStripped) { + c.String(http.StatusBadRequest, err.Error()) + } else if err != nil { + c.Response().Write([]byte(fmt.Sprintf("", err))) + return err + } + return nil + } + } +} diff --git a/internal/servetpl/parse.go b/internal/servetpl/parse.go new file mode 100644 index 0000000..5dd9d26 --- /dev/null +++ b/internal/servetpl/parse.go @@ -0,0 +1,25 @@ +package servetpl + +import ( + "fmt" + "html/template" + "os" + textTemplate "text/template" + + "github.com/eternal-flame-AD/yoake/internal/servetpl/funcmap" +) + +func parseTemplateFileAs[M interface{ ~map[string]any }, T interface { + *template.Template | *textTemplate.Template + Parse(string) (T, error) + New(name string) T + Funcs(funcs M) T +}](tpl T, name string, path string) (T, error) { + slurpedFile, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("error reading template file %s: %w", path, err) + } + + res, err := tpl.New(name).Funcs(funcmap.GetFuncMap()).Parse(string(slurpedFile)) + return res, err +} diff --git a/internal/session/manager.go b/internal/session/manager.go new file mode 100644 index 0000000..dca84ce --- /dev/null +++ b/internal/session/manager.go @@ -0,0 +1,35 @@ +package session + +import ( + "github.com/gorilla/sessions" + "github.com/labstack/echo/v4" +) + +const SessionStoreKeyPrefix = "_session_store_" + +const maxAge = 86400 * 90 + +type Provider func(driver string, name string, path string) map[any]any + +func ManagedSession(c echo.Context) (p Provider, close func()) { + checkedOutSessions := make(map[string]*sessions.Session) + return func(driver string, name string, path string) map[any]any { + store := c.Get(SessionStoreKeyPrefix + driver).(sessions.Store) + + if s, ok := checkedOutSessions[name]; ok { + return s.Values + } + s, _ := store.Get(c.Request(), name) + + s.Options = &sessions.Options{ + Path: path, + MaxAge: maxAge, + } + checkedOutSessions[name] = s + return s.Values + }, func() { + for _, s := range checkedOutSessions { + s.Save(c.Request(), c.Response()) + } + } +} diff --git a/internal/twilio/twilio_test.go b/internal/twilio/twilio_test.go new file mode 100644 index 0000000..73f9cc6 --- /dev/null +++ b/internal/twilio/twilio_test.go @@ -0,0 +1,36 @@ +package twilio + +import ( + "bytes" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTwilioValidate(t *testing.T) { + params := map[string]string{ + "CallSid": "CA1234567890ABCDE", + "Caller": "+12349013030", + "Digits": "1234", + "From": "+12349013030", + "To": "+18005551212", + } + bodyForm := make(url.Values) + for k, v := range params { + bodyForm.Set(k, v) + } + + req := httptest.NewRequest("POST", "https://mycompany.com/myapp.php?foo=1&bar=2", + bytes.NewBufferString(bodyForm.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + require.NoError(t, req.ParseForm()) + + assert.ErrorContains(t, TwilioValidate(req), "no twilio signature") + req.Header.Set("X-Twilio-Signature", "garbage") + assert.ErrorContains(t, TwilioValidate(req), "twilio signature verification failed") + req.Header.Set("X-Twilio-Signature", "0/KCTR6DLpKmkAf8muzZqo1nDgQ=") + assert.NoError(t, TwilioValidate(req)) +} diff --git a/internal/twilio/verify.go b/internal/twilio/verify.go new file mode 100644 index 0000000..674af13 --- /dev/null +++ b/internal/twilio/verify.go @@ -0,0 +1,66 @@ +package twilio + +import ( + "fmt" + "log" + "net/http" + "net/url" + "path" + "strings" + + "github.com/labstack/echo/v4" + + "github.com/eternal-flame-AD/yoake/config" + "github.com/twilio/twilio-go/client" +) + +func firstUrlValues(val url.Values) map[string]string { + res := make(map[string]string) + for k, v := range val { + res[k] = v[0] + } + return res +} + +func VerifyMiddleware(prefix string) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + + return func(c echo.Context) error { + cleanPath := path.Clean(c.Request().URL.Path) + //log.Printf("cleanPath: %s", cleanPath) + if cleanPath == prefix || strings.HasPrefix(cleanPath, prefix+"/") { + if err := TwilioValidate(c.Request()); err != nil { + c.String(http.StatusOK, "We are sorry. Request Validation Failed. This is not your fault.") + log.Printf("twilio verify failed: %v", err) + return err + } + } + return next(c) + } + } +} + +func TwilioValidate(req *http.Request) error { + conf := config.Config().Twilio + signature := req.Header.Get("X-Twilio-Signature") + if signature == "" { + if conf.SkipVerify { + return nil + } + return fmt.Errorf("no twilio signature present") + } + requestValidator := client.NewRequestValidator(conf.AuthToken) + if req.Method == "POST" { + if !requestValidator.Validate(req.URL.String(), firstUrlValues(req.PostForm), signature) { + return fmt.Errorf("twilio signature verification failed") + } + } else if req.Method == "GET" { + if !requestValidator.Validate(req.URL.String(), nil, signature) { + return fmt.Errorf("twilio signature verification failed") + } + } else { + return fmt.Errorf("twilio signature verification failed: unsupported method %s", req.Method) + } + + return nil +} diff --git a/scripts/webroot-build.fish b/scripts/webroot-build.fish new file mode 100755 index 0000000..c826371 --- /dev/null +++ b/scripts/webroot-build.fish @@ -0,0 +1,20 @@ +#!/usr/bin/env fish + +find . -executable -regextype awk -iregex '.+\.build\.(sh|bash|fish)' +for script in \ + (find . -executable -regextype awk -iregex '.+\.build.(sh|bash|fish)') + + set -l script_path (realpath $script) + set -l script_dir (dirname $script_path) + cd $script_dir + echo "--> Build \$WEBROOT/$script_path" + $script_path + or begin + set -l exit_code $status + echo "---> : Command $script_path returned $exit_code" + exit $exit_code + end + cd - +end + +find . -executable -regextype awk -iregex '.+\.build.(sh|bash|fish)' -delete \ No newline at end of file diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..07122e5 --- /dev/null +++ b/server/server.go @@ -0,0 +1,38 @@ +package server + +import ( + "strings" + + "github.com/labstack/echo/v4" +) + +type ( + Host struct { + Echo *echo.Echo + } +) + +var Server = echo.New() +var hosts = map[string]*Host{} + +func init() { + Server.Any("/*", func(c echo.Context) (err error) { + req := c.Request() + res := c.Response() + host := hosts[strings.ToLower(req.Host)] + + if host == nil { + host = hosts[""] + if host == nil { + return echo.ErrNotFound + } + } + + host.Echo.ServeHTTP(res, req) + return + }) +} + +func RegisterHostname(hostname string, h *Host) { + hosts[hostname] = h +} diff --git a/server/vault/server.go b/server/vault/server.go new file mode 100644 index 0000000..4fd3430 --- /dev/null +++ b/server/vault/server.go @@ -0,0 +1,40 @@ +package vault + +import ( + "log" + "net/url" + + "github.com/eternal-flame-AD/yoake/config" + "github.com/eternal-flame-AD/yoake/server" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +func Init(hostname string) { + e := echo.New() + e.Pre(middleware.Logger()) + + vaultApiURL, err := url.Parse(config.Config().Upstream.Vault.API) + if err != nil { + log.Panicf("vault URL not valid: %s", err) + } + e.Group("/v1").Use(middleware.Proxy(middleware.NewRoundRobinBalancer([]*middleware.ProxyTarget{ + {URL: vaultApiURL}, + }))) + if vaultUiURLStr := config.Config().Upstream.Vault.UI; vaultUiURLStr != "" { + vaultUiURL, err := url.Parse(vaultUiURLStr) + if err != nil { + log.Panicf("vault URL not valid: %s", err) + } + if vaultUiURL.Scheme == "file" { + log.Println(vaultUiURL.Path) + e.Use(middleware.Rewrite(map[string]string{"^/ui/*": "/$1"}), middleware.StaticWithConfig(middleware.StaticConfig{HTML5: true, Root: vaultUiURL.Path})) + } else { + e.Use(middleware.Proxy(middleware.NewRoundRobinBalancer([]*middleware.ProxyTarget{ + {URL: vaultUiURL}, + }))) + } + } + + server.RegisterHostname(hostname, &server.Host{Echo: e}) +} diff --git a/server/webroot/log.go b/server/webroot/log.go new file mode 100644 index 0000000..801b2bf --- /dev/null +++ b/server/webroot/log.go @@ -0,0 +1,134 @@ +package webroot + +import ( + "fmt" + "log" + "path" + "regexp" + "strconv" + + "github.com/eternal-flame-AD/yoake/internal/auth" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +type logEntry struct { + middleware.RequestLoggerValues + Categories []string + CleanPath string + Auth auth.RequestAuth +} + +func processLoggerValues(c echo.Context, values middleware.RequestLoggerValues) logEntry { + status := values.Status + statusString := []byte(strconv.Itoa(status)) + for i := len(statusString) - 1; i >= 0; i-- { + logSetRequestCategory(c, fmt.Sprintf("status_%s", statusString)) + statusString[i] = 'x' + } + return logEntry{ + RequestLoggerValues: values, + Categories: logGetCategories(c), + CleanPath: path.Clean(c.Request().URL.Path), + Auth: auth.GetRequestAuth(c), + } +} + +type logCompiledFilter struct { + Negate bool + Pattern *regexp.Regexp +} + +func logGetCategories(c echo.Context) []string { + if existingCates, err := c.Get("log_request_categories").([]string); err { + return existingCates + } else { + return []string{} + } +} + +func logCompileFilters(filters []string) []logCompiledFilter { + var compiledFilters []logCompiledFilter + for _, filter := range filters { + negate := false + if filter[0] == '!' { + negate = true + filter = filter[1:] + } + log.Printf("Compiling filter: %s negate=%v", filter, negate) + compiledFilters = append(compiledFilters, logCompiledFilter{negate, regexp.MustCompile(filter)}) + } + return compiledFilters +} + +func logFilterCategories(c echo.Context, filters []logCompiledFilter) bool { + if filters == nil { + return true + } + for _, category := range logGetCategories(c) { + for _, filter := range filters { + matches := filter.Pattern.MatchString(category) + negate := filter.Negate + // log.Printf("Checking category %s against filter %s negate=%v matches=%v", category, filter.Pattern, negate, matches) + if matches { + return !negate + } + } + } + return true +} + +func logSetRequestCategory(c echo.Context, category string) { + if existingCates, ok := c.Get("log_request_categories").([]string); !ok { + c.Set("log_request_categories", []string{category}) + } else { + c.Set("log_request_categories", append(existingCates, category)) + } +} + +func logRemoveRequestCategory(c echo.Context, category string) { + if existingCates, ok := c.Get("log_request_categories").([]string); ok { + for i, existingCate := range existingCates { + if existingCate == category { + c.Set("log_request_categories", append(existingCates[:i], existingCates[i+1:]...)) + return + } + } + } +} + +func logMiddleware(category string, backend echo.MiddlewareFunc) echo.MiddlewareFunc { + 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) + } + return backend(wrappedNext)(c) + } + } +} + +var ( + loggerConfig = middleware.RequestLoggerConfig{ + LogLatency: true, + LogProtocol: true, + LogRemoteIP: true, + LogHost: true, + LogMethod: true, + LogURI: true, + LogURIPath: true, + LogRoutePath: true, + LogRequestID: true, + LogReferer: true, + LogUserAgent: true, + LogStatus: true, + LogError: true, + LogContentLength: true, + LogResponseSize: true, + LogHeaders: []string{}, + LogQueryParams: []string{}, + LogFormValues: []string{}, + } +) diff --git a/server/webroot/server.go b/server/webroot/server.go new file mode 100644 index 0000000..cd0e56c --- /dev/null +++ b/server/webroot/server.go @@ -0,0 +1,76 @@ +package webroot + +import ( + "encoding/json" + "log" + "os" + + "github.com/eternal-flame-AD/yoake/config" + "github.com/eternal-flame-AD/yoake/internal/auth" + "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/server" + "github.com/gorilla/context" + "github.com/gorilla/sessions" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +func Init(hostname string) { + e := echo.New() + + webroot := config.Config().WebRoot + if webroot.Root == "" { + log.Panicf("webroot not set, use . to override") + } + if webroot.SessionKey == "" { + log.Panicf("webroot session key not set") + } + + sessionCookie := sessions.NewCookieStore([]byte(webroot.SessionKey)) + fsCookie := sessions.NewFilesystemStore(webroot.SessionDir, []byte(webroot.SessionKey)) + + if webroot.Secure != nil { + e.Use(middleware.SecureWithConfig(*webroot.Secure)) + } + if webroot.Log != nil { + filters := logCompileFilters(webroot.Log.Filter) + logOut := log.New(os.Stdout, "webroot: ", log.Ldate|log.Ltime) + lc := loggerConfig + lc.LogValuesFunc = func(c echo.Context, values middleware.RequestLoggerValues) (err error) { + entry := processLoggerValues(c, values) + if logFilterCategories(c, filters) { + var j []byte + if webroot.Log.Indent { + j, err = json.MarshalIndent(entry, "", " ") + } else { + j, err = json.Marshal(entry) + } + if err != nil { + return err + } + logOut.Println(string(j)) + } + return nil + } + 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) + } + }, + middleware.Gzip(), + logMiddleware("twilio", twilio.VerifyMiddleware("/twilio")), + auth.Middleware(sessionCookie), + middleware.Rewrite(map[string]string{"*/": "$1/index.html"}), + logMiddleware("template", servetpl.ServeTemplateDir(webroot.Root)), + logMiddleware("static", middleware.Static(webroot.Root))) + + server.RegisterHostname(hostname, &server.Host{Echo: e}) +} diff --git a/webroot/auth.tpl.json b/webroot/auth.tpl.json new file mode 100644 index 0000000..2b7d036 --- /dev/null +++ b/webroot/auth.tpl.json @@ -0,0 +1,10 @@ +{{ $method := .Request.Method }} +{{ if eq $method "POST" }} + {{ if not ($err := (auth_login .C)) }} + { "status": "success", "message": "Login successful", "success": true } + {{ end }} +{{ else if eq $method "DELETE" }} + {{ void (auth_login .C) }} +{{ else if eq $method "GET" }} +{{ (get_auth .C) | json }} +{{ end }} \ No newline at end of file diff --git a/webroot/dashboard.css b/webroot/dashboard.css new file mode 100644 index 0000000..ad12048 --- /dev/null +++ b/webroot/dashboard.css @@ -0,0 +1,92 @@ +body { + font-size: .875rem; +} + +.feather { + width: 16px; + height: 16px; +} + +/* + * Sidebar + */ + +.sidebar { + position: fixed; + top: 0; + /* rtl:raw: + right: 0; + */ + bottom: 0; + /* rtl:remove */ + left: 0; + z-index: 100; /* Behind the navbar */ + padding: 48px 0 0; /* Height of navbar */ + box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1); +} + +@media (max-width: 767.98px) { + .sidebar { + top: 5rem; + } +} + +.sidebar-sticky { + height: calc(100vh - 48px); + overflow-x: hidden; + overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */ +} + +.sidebar .nav-link { + font-weight: 500; + color: #333; +} + +.sidebar .nav-link .feather { + margin-right: 4px; + color: #727272; +} + +.sidebar .nav-link.active { + color: #2470dc; +} + +.sidebar .nav-link:hover .feather, +.sidebar .nav-link.active .feather { + color: inherit; +} + +.sidebar-heading { + font-size: .75rem; +} + +/* + * Navbar + */ + +.navbar-brand { + padding-top: .75rem; + padding-bottom: .75rem; + background-color: rgba(0, 0, 0, .25); + box-shadow: inset -1px 0 0 rgba(0, 0, 0, .25); +} + +.navbar .navbar-toggler { + top: .25rem; + right: 1rem; +} + +.navbar .form-control { + padding: .75rem 1rem; +} + +.form-control-dark { + color: #fff; + background-color: rgba(255, 255, 255, .1); + border-color: rgba(255, 255, 255, .1); +} + +.form-control-dark:focus { + border-color: transparent; + box-shadow: 0 0 0 3px rgba(255, 255, 255, .25); +} diff --git a/webroot/favicon.build.sh b/webroot/favicon.build.sh new file mode 100755 index 0000000..95626cf --- /dev/null +++ b/webroot/favicon.build.sh @@ -0,0 +1 @@ +convert -resize x16 -gravity center -crop 16x16+0+0 kobushi_wo_nigiru.gif -flatten -colors 256 -background transparent favicon.ico \ No newline at end of file diff --git a/webroot/includes/head.tpl.html b/webroot/includes/head.tpl.html new file mode 100644 index 0000000..bb03ca8 --- /dev/null +++ b/webroot/includes/head.tpl.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + + {{ with get .Global "Title" }}{{ . }} | {{ end }}{{ (call .Config).WebRoot.SiteName }} + + + + + + + + + + + + \ No newline at end of file diff --git a/webroot/includes/modal-login.tpl.html b/webroot/includes/modal-login.tpl.html new file mode 100644 index 0000000..2c67d2a --- /dev/null +++ b/webroot/includes/modal-login.tpl.html @@ -0,0 +1,81 @@ + + \ No newline at end of file diff --git a/webroot/includes/navbar.tpl.html b/webroot/includes/navbar.tpl.html new file mode 100644 index 0000000..7965a06 --- /dev/null +++ b/webroot/includes/navbar.tpl.html @@ -0,0 +1,11 @@ + \ No newline at end of file diff --git a/webroot/includes/page-dashboard.tpl.html b/webroot/includes/page-dashboard.tpl.html new file mode 100644 index 0000000..b9eef47 --- /dev/null +++ b/webroot/includes/page-dashboard.tpl.html @@ -0,0 +1,104 @@ +{{ $auth := (get_auth .C) }} +

Dashboard

+
+
+
+
+
+
+
Quote
+
+
+

+ 夜明け前が一番暗い +

+

+ The night is darkest just before the dawn. +

+ +
+
+
+
+
+
+
+
+
Time
+
+
+ + +
+
+
+
+
+
Operator Info
+
+ + {{ if $auth.Valid }} + {{ if eq (index $auth.Roles 0) "admin" }} + +

+ You have super powers! +

+ {{ end }} +

Your session expires at {{ $auth.Expire }}

+ + {{ else }} + +

+ You do not have any priviledges. Keep squeezing! +

+ + {{ end }} +
+
+
+
+
+
\ No newline at end of file diff --git a/webroot/includes/page-demo.tpl.html b/webroot/includes/page-demo.tpl.html new file mode 100644 index 0000000..3f59203 --- /dev/null +++ b/webroot/includes/page-demo.tpl.html @@ -0,0 +1,157 @@ +

Dashboard

+ +
+
+ Generic placeholder thumbnail +

Label

+ Something else +
+
+ Generic placeholder thumbnail +

Label

+ Something else +
+
+ Generic placeholder thumbnail +

Label

+ Something else +
+
+ Generic placeholder thumbnail +

Label

+ Something else +
+
+ +

Section title

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#HeaderHeaderHeaderHeader
1,001Loremipsumdolorsit
1,002ametconsecteturadipiscingelit
1,003IntegernecodioPraesent
1,003liberoSedcursusante
1,004dapibusdiamSednisi
1,005Nullaquissemat
1,006nibhelementumimperdietDuis
1,007sagittisipsumPraesentmauris
1,008Fuscenectellussed
1,009auguesemperportaMauris
1,010massaVestibulumlaciniaarcu
1,011egetnullaClassaptent
1,012tacitisociosquadlitora
1,013torquentperconubianostra
1,014perinceptoshimenaeosCurabitur
1,015sodalesligulainlibero
+
\ No newline at end of file diff --git a/webroot/includes/page-twilio-recordings.tpl.html b/webroot/includes/page-twilio-recordings.tpl.html new file mode 100644 index 0000000..d951116 --- /dev/null +++ b/webroot/includes/page-twilio-recordings.tpl.html @@ -0,0 +1,19 @@ +

Twilio Recordings

+ + + + + + + + + + + + + + + + + +
RecordingDurationCall SIDCall DateCall StatusCall DurationCall FromCall To
\ No newline at end of file diff --git a/webroot/includes/page-twilio.tpl.html b/webroot/includes/page-twilio.tpl.html new file mode 100644 index 0000000..9ccd695 --- /dev/null +++ b/webroot/includes/page-twilio.tpl.html @@ -0,0 +1 @@ +

Twilio Service

\ No newline at end of file diff --git a/webroot/includes/sidebar-router.tpl.html b/webroot/includes/sidebar-router.tpl.html new file mode 100644 index 0000000..dca4464 --- /dev/null +++ b/webroot/includes/sidebar-router.tpl.html @@ -0,0 +1,120 @@ + + + \ No newline at end of file diff --git a/webroot/includes/tail.tpl.html b/webroot/includes/tail.tpl.html new file mode 100644 index 0000000..9870926 --- /dev/null +++ b/webroot/includes/tail.tpl.html @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/webroot/index.tpl.html b/webroot/index.tpl.html new file mode 100644 index 0000000..e217517 --- /dev/null +++ b/webroot/index.tpl.html @@ -0,0 +1,75 @@ +{{ set .Global "Title" "Home" }} +{{ template "/includes/head.tpl.html" . }} + + + + + + + {{ template "/includes/navbar.tpl.html" .}} + +
+ + {{ template "/includes/modal-login.tpl.html" . }} + +
+ + + +
+ + {{ template "/includes/page-dashboard.tpl.html" . }} + +
+ +
+ {{ template "/includes/page-twilio.tpl.html" . }} +
+
+ {{ template "/includes/page-twilio-recordings.tpl.html" . }} +
+ +
+ Not Found +
+ + {{ template "/includes/sidebar-router.tpl.html" . }} +
+ {{ template "/includes/tail.tpl.html" . }} \ No newline at end of file diff --git a/webroot/kobushi_wo_nigiru.gif b/webroot/kobushi_wo_nigiru.gif new file mode 100644 index 0000000000000000000000000000000000000000..a7750a8e9017746a088853fd4963eb5ad13185b1 GIT binary patch literal 10686 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
+ \ No newline at end of file diff --git a/webroot/partials/trima-img-card.tpl.html b/webroot/partials/trima-img-card.tpl.html new file mode 100644 index 0000000..5005100 --- /dev/null +++ b/webroot/partials/trima-img-card.tpl.html @@ -0,0 +1,12 @@ +
+ + + + + 1 + + +
\ No newline at end of file diff --git a/webroot/style.css b/webroot/style.css new file mode 100644 index 0000000..a009d90 --- /dev/null +++ b/webroot/style.css @@ -0,0 +1,78 @@ +.bd-placeholder-img { + font-size: 1.125rem; + text-anchor: middle; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} + +@media (min-width: 768px) { + .bd-placeholder-img-lg { + font-size: 3.5rem; + } +} + +.b-example-divider { + height: 3rem; + background-color: rgba(0, 0, 0, .1); + border: solid rgba(0, 0, 0, .15); + border-width: 1px 0; + box-shadow: inset 0 .5em 1.5em rgba(0, 0, 0, .1), inset 0 .125em .5em rgba(0, 0, 0, .15); +} + +.b-example-vr { + flex-shrink: 0; + width: 1.5rem; + height: 100vh; +} + +.bi { + vertical-align: -.125em; + fill: currentColor; +} + +.nav-scroller { + position: relative; + z-index: 2; + height: 2.75rem; + overflow-y: hidden; +} + +.nav-scroller .nav { + display: flex; + flex-wrap: nowrap; + padding-bottom: 1rem; + margin-top: -1px; + overflow-x: auto; + text-align: center; + white-space: nowrap; + -webkit-overflow-scrolling: touch; +} + +.trima-procedure-hidden img.trima-procedure { + display: none; +} + +.trima-procedure-valid img.trima-procedure-valid { + display: inline; +} + +.trima-procedure-ineligible img.trima-procedure-ineligible { + display: inline; +} + +.trima-procedure-invalid img.trima-procedure-invalid { + display: inline; +} + +.trima-procedure-optimal img.trima-procedure-optimal { + display: inline; +} + +.trima-procedure-questionable img.trima-procedure-questionable { + display: inline; +} + +.trima-btn-card { + height: 8rem; +} \ No newline at end of file diff --git a/webroot/twilio/head.tpl.xml b/webroot/twilio/head.tpl.xml new file mode 100644 index 0000000..30d14b3 --- /dev/null +++ b/webroot/twilio/head.tpl.xml @@ -0,0 +1,33 @@ + + +{{ $session := (invoke "Session" . "fs" "twilio-session" "/twilio") }} +{{ $session | set .Global "twilio_session" }} + +{{ set .Global "debugMenu" false }} + \ No newline at end of file diff --git a/webroot/twilio/voice/entrypoint.tpl.xml b/webroot/twilio/voice/entrypoint.tpl.xml new file mode 100644 index 0000000..1705c15 --- /dev/null +++ b/webroot/twilio/voice/entrypoint.tpl.xml @@ -0,0 +1,9 @@ +{{ template "/twilio/head.tpl.xml" . }} +{{ $session := (get .Global "twilio_session") }} + +{{ $num_visited := (get $session "num_visited") }} +{{ set $session "num_visited" (math "argv(1) + 1" $num_visited) }} + + This is Anne! + /twilio/voice/menu.xml + \ No newline at end of file diff --git a/webroot/twilio/voice/hangup.xml b/webroot/twilio/voice/hangup.xml new file mode 100644 index 0000000..3a86d2b --- /dev/null +++ b/webroot/twilio/voice/hangup.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/webroot/twilio/voice/menu.tpl.xml b/webroot/twilio/voice/menu.tpl.xml new file mode 100644 index 0000000..47c7672 --- /dev/null +++ b/webroot/twilio/voice/menu.tpl.xml @@ -0,0 +1,24 @@ +{{ template "/twilio/head.tpl.xml" . }} +{{ $session := (get .Global "twilio_session") }} + + + + {{ if $digit := (invoke "FormValue" .C "Digits") }} + {{ if eq $digit "1" }} + /twilio/voice/submenu/connect.xml?connect_to={{urlquery "+5122993080"}} + {{ end }} + {{ if get .Global "debugMenu" }} + {{ if eq $digit "9" }} + /twilio/voice/submenu/debug.xml + {{ end }} + {{ end }} + {{ else }} + + Press 1 to be connected. + {{ if get .Global "debugMenu" }} + Press 9 for Debug menu. + {{ end }} + + {{ end }} + Sorry, I didn't get your response. Have a nice day! + diff --git a/webroot/twilio/voice/recordings.tpl.html b/webroot/twilio/voice/recordings.tpl.html new file mode 100644 index 0000000..4fe7da2 --- /dev/null +++ b/webroot/twilio/voice/recordings.tpl.html @@ -0,0 +1,10 @@ +{{ $type := "recording" }} +{{ if $formType := (invoke "FormValue" .C "type") }} +{{ $type = $formType }} +{{ end }} +{{ $status := (invoke "FormValue" .C "RecordingStatus") }} + +{{ if eq $status "completed" }} +{{ $recUrl := (invoke "FormValue" .C "RecordingUrl") }} +

Thank you

+{{ end }} \ No newline at end of file diff --git a/webroot/twilio/voice/submenu/admin.tpl.xml b/webroot/twilio/voice/submenu/admin.tpl.xml new file mode 100644 index 0000000..a314132 --- /dev/null +++ b/webroot/twilio/voice/submenu/admin.tpl.xml @@ -0,0 +1,5 @@ +{{ template "/twilio/head.tpl.xml" . }} +{{ $session := (get .Global "twilio_session") }} + + + \ No newline at end of file diff --git a/webroot/twilio/voice/submenu/connect-finish.tpl.xml b/webroot/twilio/voice/submenu/connect-finish.tpl.xml new file mode 100644 index 0000000..d308eef --- /dev/null +++ b/webroot/twilio/voice/submenu/connect-finish.tpl.xml @@ -0,0 +1,20 @@ +{{ $status := (invoke "FormValue" .C "DialCallStatus")}} + + {{ if eq $status "completed" }} + + {{ else if eq $status "failed"}} + Sorry, there was an issue routing your call. + + {{ else }} + Please leave a message after the beep. + + {{ end }} + + \ No newline at end of file diff --git a/webroot/twilio/voice/submenu/connect.tpl.xml b/webroot/twilio/voice/submenu/connect.tpl.xml new file mode 100644 index 0000000..9e4e09d --- /dev/null +++ b/webroot/twilio/voice/submenu/connect.tpl.xml @@ -0,0 +1,29 @@ +{{ template "/twilio/head.tpl.xml" . }} +{{ $session := (get .Global "twilio_session") }} +{{ $toNumber := "+15122993080" }} +{{ $fromNumber := get .Global "from" }} + +{{ $callerId := $fromNumber }} +{{ if $v := (invoke "FormValue" .C "connect_to") }} + {{ $toNumber = $v }} +{{ end }} + +{{ $record_reason := (invoke "FormValue" .C "record_reason") }} + + +{{ if $record_reason }} + This call may be recorded for {{ $record_reason | xml }}. +{{ end }} + + {{ $toNumber | xml }} + + \ No newline at end of file diff --git a/webroot/twilio/voice/submenu/debug.tpl.xml b/webroot/twilio/voice/submenu/debug.tpl.xml new file mode 100644 index 0000000..f2c8a0c --- /dev/null +++ b/webroot/twilio/voice/submenu/debug.tpl.xml @@ -0,0 +1,8 @@ +{{ template "/twilio/head.tpl.xml" . }} +{{ $session := (get .Global "twilio_session") }} + +{{ $num_visited := (get $session "num_visited") }} + + From number is {{get .Global "from" | xml}} + You have called {{ $num_visited }} times. + \ No newline at end of file