init
This commit is contained in:
commit
5d6c8b8aa6
62 changed files with 2563 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
dist
|
||||
install.sh
|
||||
*.pid
|
||||
config-*.yml
|
||||
!config-test.yml
|
6
.vscode/settings.json
vendored
Normal file
6
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"files.associations": {
|
||||
"*.tpl.*": "gotmpl",
|
||||
"*.tpl.html": "html",
|
||||
}
|
||||
}
|
43
Makefile
Normal file
43
Makefile
Normal 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
16
cmd/compile/compile.go
Normal 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
40
cmd/server/server.go
Normal 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
25
config-test.yml
Normal 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
47
config.yml
Normal 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
76
config/config.go
Normal 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
34
etc/service-install.fish
Executable 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
14
etc/yoake-server.service
Normal 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
36
go.mod
Normal 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
102
go.sum
Normal 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
120
internal/auth/auth.go
Normal 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
8
internal/auth/role.go
Normal file
|
@ -0,0 +1,8 @@
|
|||
package auth
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleAdmin Role = "admin"
|
||||
RoleUser Role = "user"
|
||||
)
|
24
internal/auth/yubikey.go
Normal file
24
internal/auth/yubikey.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
23
internal/servetpl/funcmap/auth.go
Normal file
23
internal/servetpl/funcmap/auth.go
Normal 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
|
||||
}
|
13
internal/servetpl/funcmap/config_test.go
Normal file
13
internal/servetpl/funcmap/config_test.go
Normal 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
|
||||
})
|
||||
}
|
108
internal/servetpl/funcmap/funcmap.go
Normal file
108
internal/servetpl/funcmap/funcmap.go
Normal 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,
|
||||
}
|
||||
}
|
42
internal/servetpl/funcmap/getset.go
Normal file
42
internal/servetpl/funcmap/getset.go
Normal 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())
|
||||
}
|
20
internal/servetpl/funcmap/json.go
Normal file
20
internal/servetpl/funcmap/json.go
Normal 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
|
||||
}
|
43
internal/servetpl/funcmap/math.go
Normal file
43
internal/servetpl/funcmap/math.go
Normal 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)
|
||||
}
|
46
internal/servetpl/funcmap/trimaimg.go
Normal file
46
internal/servetpl/funcmap/trimaimg.go
Normal 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)
|
||||
}
|
7
internal/servetpl/funcmap/twilio.go
Normal file
7
internal/servetpl/funcmap/twilio.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package funcmap
|
||||
|
||||
import (
|
||||
"github.com/eternal-flame-AD/yoake/internal/twilio"
|
||||
)
|
||||
|
||||
var TwilioValidate = twilio.TwilioValidate
|
14
internal/servetpl/funcmap/xml.go
Normal file
14
internal/servetpl/funcmap/xml.go
Normal 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
|
||||
}
|
178
internal/servetpl/middleware.go
Normal file
178
internal/servetpl/middleware.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
25
internal/servetpl/parse.go
Normal file
25
internal/servetpl/parse.go
Normal 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
|
||||
}
|
35
internal/session/manager.go
Normal file
35
internal/session/manager.go
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
36
internal/twilio/twilio_test.go
Normal file
36
internal/twilio/twilio_test.go
Normal 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
66
internal/twilio/verify.go
Normal 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
20
scripts/webroot-build.fish
Executable 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
38
server/server.go
Normal 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
40
server/vault/server.go
Normal 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
134
server/webroot/log.go
Normal 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
76
server/webroot/server.go
Normal 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
10
webroot/auth.tpl.json
Normal 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
92
webroot/dashboard.css
Normal 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
1
webroot/favicon.build.sh
Executable file
|
@ -0,0 +1 @@
|
|||
convert -resize x16 -gravity center -crop 16x16+0+0 kobushi_wo_nigiru.gif -flatten -colors 256 -background transparent favicon.ico
|
32
webroot/includes/head.tpl.html
Normal file
32
webroot/includes/head.tpl.html
Normal 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]-->
|
81
webroot/includes/modal-login.tpl.html
Normal file
81
webroot/includes/modal-login.tpl.html
Normal 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>
|
11
webroot/includes/navbar.tpl.html
Normal file
11
webroot/includes/navbar.tpl.html
Normal 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>
|
104
webroot/includes/page-dashboard.tpl.html
Normal file
104
webroot/includes/page-dashboard.tpl.html
Normal 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>
|
157
webroot/includes/page-demo.tpl.html
Normal file
157
webroot/includes/page-demo.tpl.html
Normal 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>
|
19
webroot/includes/page-twilio-recordings.tpl.html
Normal file
19
webroot/includes/page-twilio-recordings.tpl.html
Normal 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>
|
1
webroot/includes/page-twilio.tpl.html
Normal file
1
webroot/includes/page-twilio.tpl.html
Normal file
|
@ -0,0 +1 @@
|
|||
<h1 class="h2">Twilio Service</h1>
|
120
webroot/includes/sidebar-router.tpl.html
Normal file
120
webroot/includes/sidebar-router.tpl.html
Normal 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>
|
3
webroot/includes/tail.tpl.html
Normal file
3
webroot/includes/tail.tpl.html
Normal file
|
@ -0,0 +1,3 @@
|
|||
</body>
|
||||
|
||||
</html>
|
75
webroot/index.tpl.html
Normal file
75
webroot/index.tpl.html
Normal 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" . }}
|
BIN
webroot/kobushi_wo_nigiru.gif
Normal file
BIN
webroot/kobushi_wo_nigiru.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 10 KiB |
47
webroot/lib/auth.js
Normal file
47
webroot/lib/auth.js
Normal 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)
|
||||
}
|
||||
});
|
||||
}
|
17
webroot/partials/sidebar-trima-procedure-logos.tpl.html
Normal file
17
webroot/partials/sidebar-trima-procedure-logos.tpl.html
Normal 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 }}
|
2
webroot/partials/trima-img-card-end.tpl.html
Normal file
2
webroot/partials/trima-img-card-end.tpl.html
Normal file
|
@ -0,0 +1,2 @@
|
|||
</div>
|
||||
</div>
|
12
webroot/partials/trima-img-card.tpl.html
Normal file
12
webroot/partials/trima-img-card.tpl.html
Normal 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
78
webroot/style.css
Normal 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;
|
||||
}
|
33
webroot/twilio/head.tpl.xml
Normal file
33
webroot/twilio/head.tpl.xml
Normal 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 }}
|
||||
|
||||
-->
|
9
webroot/twilio/voice/entrypoint.tpl.xml
Normal file
9
webroot/twilio/voice/entrypoint.tpl.xml
Normal 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>
|
3
webroot/twilio/voice/hangup.xml
Normal file
3
webroot/twilio/voice/hangup.xml
Normal file
|
@ -0,0 +1,3 @@
|
|||
<Response>
|
||||
<Hangup/>
|
||||
</Response>
|
24
webroot/twilio/voice/menu.tpl.xml
Normal file
24
webroot/twilio/voice/menu.tpl.xml
Normal 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>
|
10
webroot/twilio/voice/recordings.tpl.html
Normal file
10
webroot/twilio/voice/recordings.tpl.html
Normal 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 }}
|
5
webroot/twilio/voice/submenu/admin.tpl.xml
Normal file
5
webroot/twilio/voice/submenu/admin.tpl.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
{{ template "/twilio/head.tpl.xml" . }}
|
||||
{{ $session := (get .Global "twilio_session") }}
|
||||
<Response>
|
||||
|
||||
</Response>
|
20
webroot/twilio/voice/submenu/connect-finish.tpl.xml
Normal file
20
webroot/twilio/voice/submenu/connect-finish.tpl.xml
Normal 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>
|
29
webroot/twilio/voice/submenu/connect.tpl.xml
Normal file
29
webroot/twilio/voice/submenu/connect.tpl.xml
Normal 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>
|
8
webroot/twilio/voice/submenu/debug.tpl.xml
Normal file
8
webroot/twilio/voice/submenu/debug.tpl.xml
Normal 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>
|
Loading…
Reference in a new issue