This commit is contained in:
ゆめ 2022-11-07 05:45:02 -05:00
commit 5d6c8b8aa6
62 changed files with 2563 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
dist
install.sh
*.pid
config-*.yml
!config-test.yml

6
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,6 @@
{
"files.associations": {
"*.tpl.*": "gotmpl",
"*.tpl.html": "html",
}
}

43
Makefile Normal file
View file

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

16
cmd/compile/compile.go Normal file
View file

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

40
cmd/server/server.go Normal file
View file

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

25
config-test.yml Normal file
View file

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

47
config.yml Normal file
View file

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

76
config/config.go Normal file
View file

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

34
etc/service-install.fish Executable file
View file

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

14
etc/yoake-server.service Normal file
View file

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

36
go.mod Normal file
View file

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

102
go.sum Normal file
View file

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

120
internal/auth/auth.go Normal file
View file

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

8
internal/auth/role.go Normal file
View file

@ -0,0 +1,8 @@
package auth
type Role string
const (
RoleAdmin Role = "admin"
RoleUser Role = "user"
)

24
internal/auth/yubikey.go Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
package funcmap
import (
"github.com/eternal-flame-AD/yoake/internal/twilio"
)
var TwilioValidate = twilio.TwilioValidate

View file

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

View file

@ -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("<!-- ERROR: %v -->", err)))
return err
}
return nil
}
}
}

View file

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

View file

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

View file

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

66
internal/twilio/verify.go Normal file
View file

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

20
scripts/webroot-build.fish Executable file
View file

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

38
server/server.go Normal file
View file

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

40
server/vault/server.go Normal file
View file

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

134
server/webroot/log.go Normal file
View file

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

76
server/webroot/server.go Normal file
View file

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

10
webroot/auth.tpl.json Normal file
View file

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

92
webroot/dashboard.css Normal file
View file

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

1
webroot/favicon.build.sh Executable file
View file

@ -0,0 +1 @@
convert -resize x16 -gravity center -crop 16x16+0+0 kobushi_wo_nigiru.gif -flatten -colors 256 -background transparent favicon.ico

View file

@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- The above 3 meta tags *must* come first in the head; any other head content must come *after* these tags -->
<meta name="description" content="">
<meta name="author" content="">
<link rel="icon" href="../../favicon.ico">
<title>{{ with get .Global "Title" }}{{ . }} | {{ end }}{{ (call .Config).WebRoot.SiteName }}</title>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.6.1/dist/jquery.min.js"
integrity="sha256-o88AwQnZB+VDvE9tvIXrMQaPlFFSUTR+nldQm1LuPXQ=" crossorigin="anonymous"></script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-Zenh87qX5JnK2Jl0vWa8Ck2rdkQ2Bzep5IDxbcnCeuOxjzrPF/et3URy9Bv1WTRi" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/js/bootstrap.bundle.min.js"
integrity="sha384-OERcA2EqjJCMA+/3y+gxIOqMEjwtxJY7qPCqsdltbNJuaOe923+mo//f6V8Qbsw3"
crossorigin="anonymous"></script>
<link rel="stylesheet" href="/style.css">
<script src="/lib/auth.js"></script>
<!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js"></script>
<script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
<![endif]-->

View file

@ -0,0 +1,81 @@
<div class="modal" id="login-modal" tabindex="-1" aria-labelledby="loginModalLabel" aria-hidden="true"
data-bs-backdrop="static">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="loginModalLabel">Login</h5>
</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>
<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>
<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>
</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>
</div>
</div>
</div>
</div>
<script>
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(tabEl =>
tabEl.addEventListener('shown.bs.tab', function (event) {
let newTab = event.target
let oldTab = event.relatedTarget
console.log(newTab, oldTab)
newTab.classList.add('btn-primary')
newTab.classList.remove('btn-link')
oldTab.classList.remove('btn-primary')
oldTab.classList.add('btn-link')
}))
</script>

View file

@ -0,0 +1,11 @@
<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>
<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>
</button>
<!--
<input class="form-control form-control-dark w-100 rounded-0 border-0" type="text" placeholder="Search"
aria-label="Search">
-->
</header>

View file

@ -0,0 +1,104 @@
{{ $auth := (get_auth .C) }}
<h1 class="page-header">Dashboard</h1>
<hr>
<main>
<div class="container">
<div class="row p-2">
<div class="col">
<div class="card border">
<div class="card-header">Quote</div>
<div class="card-body">
<blockquote class="blockquote text-center">
<p>
夜明け前が一番暗い
</p>
<p>
The night is darkest just before the dawn.
</p>
</blockquote>
</div>
</div>
</div>
</div>
<div class="row p-2">
<div class="col">
<div class="card border">
<div class="card-header">Time</div>
<div class="card-body text-center">
<div id="clock" class="h5"></div>
<script>
(function () {
"use struct";
const dateInZone = (zone) => {
if (zone)
return new Date(new Date().toLocaleString("en-US", { timeZone: zone }));
return new Date()
}
const timeInZone = (zone) => {
const date = dateInZone(zone);
var h = date.getHours(); // 0 - 23
var m = date.getMinutes(); // 0 - 59
var s = date.getSeconds(); // 0 - 59
h = (h < 10) ? "0" + h : h;
m = (m < 10) ? "0" + m : m;
s = (s < 10) ? "0" + s : s;
return `
<p class="${zone ? "fs-3" : "h2"}">
${zone ? '<small class="fw-lighter">' + zone + '</small><br>' : ""}
<span class="fw-bolder" > ${h}:${m}:${s} <span>
</p>${zone ? "" : "<hr>"}
`
}
const zones = ["", "America/Los_Angeles", "America/New_York",
"Asia/Tokyo", "Asia/Shanghai", "UTC"]
let update = () => {
var date = new Date();
var time = ""
zones.forEach((zone) => {
time += timeInZone(zone) + "\n";
});
document.getElementById("clock").innerHTML = time;
}
update();
setInterval(update, 1000);
})()
</script>
<style>
</style>
</div>
</div>
</div>
<div class="col">
<div class="card border">
<div class="card-header">Operator Info </div>
<div class="card-body text-center">
{{ if $auth.Valid }}
{{ if eq (index $auth.Roles 0) "admin" }}
<img class="d-block mx-auto trima-btn-card" src={{ trima_img "ja/btn_vista_operator.gif" "url"
}}>
<p>
You have super powers!
</p>
{{ end }}
<p>Your session expires at {{ $auth.Expire }}</p>
<button class="btn btn-primary" onclick="signout()">Sign Out</button>
{{ else }}
<a onclick="signin()" href="#" aria-hidden="true">
<img class=" mx-auto trima-btn-card" src={{ trima_img "icon_squeeze.gif" "url" }}>
</a>
<p>
You do not have any priviledges. Keep squeezing!
</p>
<button class="btn btn-primary" onclick="signin()">Sign In</button>
{{ end }}
</div>
</div>
</div>
</div>
</div>
</main>

View file

@ -0,0 +1,157 @@
<h1 class="page-header">Dashboard</h1>
<div class="row placeholders">
<div class="col-xs-6 col-sm-3 placeholder">
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAHd3dwAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==" width="200"
height="200" class="img-responsive" alt="Generic placeholder thumbnail">
<h4>Label</h4>
<span class="text-muted">Something else</span>
</div>
<div class="col-xs-6 col-sm-3 placeholder">
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAHd3dwAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==" width="200"
height="200" class="img-responsive" alt="Generic placeholder thumbnail">
<h4>Label</h4>
<span class="text-muted">Something else</span>
</div>
<div class="col-xs-6 col-sm-3 placeholder">
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAHd3dwAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==" width="200"
height="200" class="img-responsive" alt="Generic placeholder thumbnail">
<h4>Label</h4>
<span class="text-muted">Something else</span>
</div>
<div class="col-xs-6 col-sm-3 placeholder">
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAHd3dwAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==" width="200"
height="200" class="img-responsive" alt="Generic placeholder thumbnail">
<h4>Label</h4>
<span class="text-muted">Something else</span>
</div>
</div>
<h2 class="sub-header">Section title</h2>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>#</th>
<th>Header</th>
<th>Header</th>
<th>Header</th>
<th>Header</th>
</tr>
</thead>
<tbody>
<tr>
<td>1,001</td>
<td>Lorem</td>
<td>ipsum</td>
<td>dolor</td>
<td>sit</td>
</tr>
<tr>
<td>1,002</td>
<td>amet</td>
<td>consectetur</td>
<td>adipiscing</td>
<td>elit</td>
</tr>
<tr>
<td>1,003</td>
<td>Integer</td>
<td>nec</td>
<td>odio</td>
<td>Praesent</td>
</tr>
<tr>
<td>1,003</td>
<td>libero</td>
<td>Sed</td>
<td>cursus</td>
<td>ante</td>
</tr>
<tr>
<td>1,004</td>
<td>dapibus</td>
<td>diam</td>
<td>Sed</td>
<td>nisi</td>
</tr>
<tr>
<td>1,005</td>
<td>Nulla</td>
<td>quis</td>
<td>sem</td>
<td>at</td>
</tr>
<tr>
<td>1,006</td>
<td>nibh</td>
<td>elementum</td>
<td>imperdiet</td>
<td>Duis</td>
</tr>
<tr>
<td>1,007</td>
<td>sagittis</td>
<td>ipsum</td>
<td>Praesent</td>
<td>mauris</td>
</tr>
<tr>
<td>1,008</td>
<td>Fusce</td>
<td>nec</td>
<td>tellus</td>
<td>sed</td>
</tr>
<tr>
<td>1,009</td>
<td>augue</td>
<td>semper</td>
<td>porta</td>
<td>Mauris</td>
</tr>
<tr>
<td>1,010</td>
<td>massa</td>
<td>Vestibulum</td>
<td>lacinia</td>
<td>arcu</td>
</tr>
<tr>
<td>1,011</td>
<td>eget</td>
<td>nulla</td>
<td>Class</td>
<td>aptent</td>
</tr>
<tr>
<td>1,012</td>
<td>taciti</td>
<td>sociosqu</td>
<td>ad</td>
<td>litora</td>
</tr>
<tr>
<td>1,013</td>
<td>torquent</td>
<td>per</td>
<td>conubia</td>
<td>nostra</td>
</tr>
<tr>
<td>1,014</td>
<td>per</td>
<td>inceptos</td>
<td>himenaeos</td>
<td>Curabitur</td>
</tr>
<tr>
<td>1,015</td>
<td>sodales</td>
<td>ligula</td>
<td>in</td>
<td>libero</td>
</tr>
</tbody>
</table>
</div>

View file

@ -0,0 +1,19 @@
<h1 class="page-header">Twilio Recordings</h1>
<table>
<thead>
<tr>
<th>Recording</th>
<th>Duration</th>
<th>Call SID</th>
<th>Call Date</th>
<th>Call Status</th>
<th>Call Duration</th>
<th>Call From</th>
<th>Call To</th>
</tr>
</thead>
<tbody>
</tbody>
</table>

View file

@ -0,0 +1 @@
<h1 class="h2">Twilio Service</h1>

View file

@ -0,0 +1,120 @@
<nav id="sidebar-tpl" class="d-none">
</nav>
<script>
getAuth().then(auth => {
sidebarReal = document.getElementById("sidebar");
sidebarTpl = document.getElementById("sidebar-tpl");
sidebarTpl.innerHTML = sidebarReal.innerHTML;
let state = {};
sidebarTpl.querySelectorAll("a[data-state-bind]").forEach(a => {
const stateKey = a.getAttribute("data-state-bind");
s = {
trimaProcedure: "valid",
page: a.getAttribute("href").replace("#", "")
};
const authProfile = a.getAttribute("data-auth-bind");
if (authProfile) {
if (auth.Roles && auth.Roles.indexOf(authProfile) >= 0) {
// we have that role
} else {
s.login = authProfile
s.trimaProcedure = "ineligible";
}
}
state[stateKey] = s;
})
window.renderSidebar = function () {
if (!window.location.hash || window.location.hash === "#") {
window.location.hash = "#dashboard";
}
function normalizeHash() {
let hash = window.location.hash || ""
if (hash.startsWith("#")) {
hash = hash.slice(1)
}
return hash
}
let routedPage = normalizeHash();
let routedPageId = "page-" + routedPage;
let routedPageEl = document.getElementById(routedPageId);
if (state[routedPage].login)
signin()
else
routedPageEl && routedPageEl.classList.add("router-page-active");
sidebarReal.innerHTML = sidebarTpl.innerHTML;
sidebarReal.querySelectorAll("a[data-state-bind]").forEach(a => {
const stateKey = a.getAttribute("data-state-bind");
const stateValue = state[stateKey];
console.log(stateKey, stateValue);
if (stateKey == routedPage)
a.classList.add("active");
else
a.classList.remove("active");
if (stateValue.trimaProcedure) {
let procEl = a.querySelector(".sidebar-trima-procedure")
procEl.classList.forEach(cls => {
console.log(cls)
if (cls.startsWith("trima-procedure-"))
procEl.classList.remove(cls);
})
let classToUse = stateValue.trimaProcedure;
console.log(stateKey, routedPage)
if ((stateKey == routedPage)) {
if (!routedPageEl)
classToUse = "questionable"
else
classToUse = stateValue.login ? "invalid" : "optimal"
}
procEl.classList.add("trima-procedure-" + classToUse);
procEl.parentNode.classList.add("trima-procedure-hidden")
}
if (stateKey == routedPage) {
if (stateValue.login)
signin()
} else {
document.getElementById("page-" + stateKey).classList.remove("router-page-active")
}
if (stateValue.page) {
a.setAttribute("href", "#" + stateValue.page);
} else {
a.setAttribute("href", "#not-found")
}
})
}
window.updateSidebarState = function (updateFn) {
state = updateFn(state);
renderSidebar(state);
}
window.addEventListener('hashchange', renderSidebar)
renderSidebar();
})
</script>
<style>
.router-page {
display: none;
}
.router-page-active {
display: block;
}
</style>

View file

@ -0,0 +1,3 @@
</body>
</html>

75
webroot/index.tpl.html Normal file
View file

@ -0,0 +1,75 @@
{{ set .Global "Title" "Home" }}
{{ template "/includes/head.tpl.html" . }}
<link href="dashboard.css" rel="stylesheet">
</head>
<body>
{{ template "/includes/navbar.tpl.html" .}}
<div class="container-fluid">
{{ template "/includes/modal-login.tpl.html" . }}
<div class="row">
<nav id="sidebar" class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse">
<div class="position-sticky pt-3 sidebar-sticky">
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link" href="#dashboard" data-state-bind="dashboard">
<span class="sidebar-trima-procedure" class="align-text-bottom">
{{ template "/partials/sidebar-trima-procedure-logos.tpl.html" }}
</span>Dashboard
</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>Twilio</span>
<a class="link-secondary" href="#" aria-label="Add a new report">
<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="#twilio" data-state-bind="twilio">
<span class="sidebar-trima-procedure">
{{ template "/partials/sidebar-trima-procedure-logos.tpl.html" }}
</span>Features
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#twilio-recordings" class="" data-state-bind="twilio-recordings"
data-auth-bind="admin">
<span class="sidebar-trima-procedure">
{{ template "/partials/sidebar-trima-procedure-logos.tpl.html" }}
</span>Recordings
</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">
{{ 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">
{{ 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">
{{ 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">
Not Found
</main>
{{ template "/includes/sidebar-router.tpl.html" . }}
</div>
{{ template "/includes/tail.tpl.html" . }}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

47
webroot/lib/auth.js Normal file
View file

@ -0,0 +1,47 @@
async function getAuth() {
let body = await fetch("/auth.json?" + new Date(), { method: "GET" })
let bodyJSON = await body.json()
return bodyJSON
}
function submitLoginForm(target, e) {
e.preventDefault()
console.log("submitLoginForm", target)
let form = $(target);
var actionUrl = form.attr('action');
$.ajax({
type: 'POST',
url: actionUrl,
data: form.serialize(),
success: function (data) {
window.location.reload();
},
error: function (data) {
try {
let msg = data.responseJSON.message || data.responseJSON;
$('#login-form-error').removeClass('d-none').find('span').text(msg);
} catch (e) {
$('#login-form-error').removeClass('d-none').find('span').text(e.message);
}
}
});
}
function signin() {
$('#login-modal').modal('show');
}
function signout() {
$.ajax({
type: 'DELETE',
url: '/auth.json',
success: function (data) {
window.location.reload();
},
error: function (data) {
console.warn(data)
}
});
}

View file

@ -0,0 +1,17 @@
{{range $key, $name:= (parse_json `
{
"ineligible": "icon_t_vista_procedure_ineligible.gif",
"invalid":
"icon_t_vista_procedure_invalid.gif",
"optimal":
"icon_t_vista_procedure_optimal.gif",
"questionable":
"icon_t_vista_procedure_questionable.gif",
"valid":
"icon_t_vista_procedure_valid.gif"
}
`)}}
<img aria-hidden="true" class="trima-procedure trima-procedure-{{$key}}" src={{ trima_img $name "url" }}>
{{ end }}

View file

@ -0,0 +1,2 @@
</div>
</div>

View file

@ -0,0 +1,12 @@
<div class="card">
<svg class="card-img-top" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 100 100" xml:space="preserve">
<style>
text { font-family: Georgia; font-size: 50px; }
</style>
<image width="100" height="100" xlink:href={{ trima_img . "url" }}/>
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" >1</text>
</svg>
<div class="card-body">

78
webroot/style.css Normal file
View file

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

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
{{ $session := (invoke "Session" . "fs" "twilio-session" "/twilio") }}
{{ $session | set .Global "twilio_session" }}
{{ set .Global "debugMenu" false }}
<!--
Loading from:
{{ if $from := (invoke "FormValue" .C "From")}}
{{ $from | xml }}
{{ if eq $from "+15122993080" }}
{{ set .Global "debugMenu" true }}
{{ end }}
{{ set .Global "from" $from }}
{{ else }}
unknown
{{ set .Global "from" "unknown" }}
{{ end }}
{{ if get .Global "debugMenu" }}
Debug Info:
Visited:
{{ $num_visited := 0 }}
{{ if get $session "num_visited" }}
{{ $num_visited = (get $session "num_visited") }}
{{ end }}
{{ $num_visited }} times
{{ else }}Debug is off{{ end }}
-->

View file

@ -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) }}
<Response>
<Say voice="alice" language="en-US">This is Anne!</Say>
<Redirect method="POST">/twilio/voice/menu.xml</Redirect>
</Response>

View file

@ -0,0 +1,3 @@
<Response>
<Hangup/>
</Response>

View file

@ -0,0 +1,24 @@
{{ template "/twilio/head.tpl.xml" . }}
{{ $session := (get .Global "twilio_session") }}
<Response>
{{ if $digit := (invoke "FormValue" .C "Digits") }}
{{ if eq $digit "1" }}
<Redirect method="POST">/twilio/voice/submenu/connect.xml?connect_to={{urlquery "+5122993080"}}</Redirect>
{{ end }}
{{ if get .Global "debugMenu" }}
{{ if eq $digit "9" }}
<Redirect method="POST">/twilio/voice/submenu/debug.xml</Redirect>
{{ end }}
{{ end }}
{{ else }}
<Gather numDigits="1" timeout="10" >
<Say voice="alice" language="en-US">Press 1 to be connected.</Say>
{{ if get .Global "debugMenu" }}
<Say voice="alice" language="en-US">Press 9 for Debug menu.</Say>
{{ end }}
</Gather>
{{ end }}
<Say voice="alice" language="en-US">Sorry, I didn't get your response. Have a nice day!</Say>
</Response>

View file

@ -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") }}
<h1>Thank you</h1>
{{ end }}

View file

@ -0,0 +1,5 @@
{{ template "/twilio/head.tpl.xml" . }}
{{ $session := (get .Global "twilio_session") }}
<Response>
</Response>

View file

@ -0,0 +1,20 @@
{{ $status := (invoke "FormValue" .C "DialCallStatus")}}
<Response>
{{ if eq $status "completed" }}
<Hangup/>
{{ else if eq $status "failed"}}
<Say voice="alice" language="en-US">Sorry, there was an issue routing your call.</Say>
<Hangup/>
{{ else }}
<Say voice="alice" language="en-US">Please leave a message after the beep.</Say>
<Record
action="/twilio/voice/hangup.xml"
method="POST"
maxLength="60"
recordingStatusCallback="/twilio/voice/recording.html?type=voicemail"
recordingStatusCallbackMethod="POST"
playBeep="true"
/>
{{ end }}
</Response>

View file

@ -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") }}
<Response>
{{ if $record_reason }}
<Say voice="alice" language="en-US">This call may be recorded for {{ $record_reason | xml }}.</Say>
{{ end }}
<Dial
action="/twilio/submenu/connect-finish.tpl.xml"
callerId="{{ $callerId | xml }}"
{{ if $record_reason }}
record="record-from-answer-dual"
recordingStatusCallback="/twilio/voice/recording.html?type=call"
recordingStatusCallbackMethod="POST"
{{ end }}
>
<Number
>{{ $toNumber | xml }}</Number>
</Dial>
</Response>

View file

@ -0,0 +1,8 @@
{{ template "/twilio/head.tpl.xml" . }}
{{ $session := (get .Global "twilio_session") }}
{{ $num_visited := (get $session "num_visited") }}
<Response>
<Say voice="alice" language="en-US">From number is <say-as interpret-as="telephone">{{get .Global "from" | xml}}</say-as></Say>
<Say voice="alice" language="en-US">You have called {{ $num_visited }} times.</Say>
</Response>