Compare commits

...

No commits in common. "main" and "legacy" have entirely different histories.
main ... legacy

239 changed files with 10660 additions and 12180 deletions

1
.env
View file

@ -1 +0,0 @@
DATABASE_URL=sqlite://data/development.db

9
.gitignore vendored
View file

@ -1,4 +1,7 @@
dist
install.sh
*.pid
config-*.yml
!config-test.yml
data
target
config-dev.yaml
config-prod.yaml
.vscode/launch.json

16
.vscode/c_cpp_properties.json vendored Normal file
View file

@ -0,0 +1,16 @@
{
"configurations": [
{
"name": "Linux",
"includePath": [
"${workspaceFolder}/**"
],
"defines": [],
"compilerPath": "/usr/bin/gcc",
"cStandard": "gnu17",
"cppStandard": "gnu++17",
"intelliSenseMode": "linux-gcc-x64"
}
],
"version": 4
}

12
.vscode/launch-tpl.json vendored Normal file
View file

@ -0,0 +1,12 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Attach to Process",
"type": "go",
"request": "attach",
"mode": "local",
"processId": 0
}
]
}

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

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

3448
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,61 +0,0 @@
[package]
name = "yoake"
version = "0.1.0"
edition = "2021"
authors = ["Yumechi <yume@yumechi.jp>"]
description = "Yoake is Yumechi's Personal Information Management System."
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
aes-gcm = "0.10.2"
anyhow = "1.0.71"
argon2 = "0.5.0"
askama = { version = "0.12.0" }
async-trait = "0.1.68"
axum = { version = "0.6.18", features = ["http2"] }
axum-server = { version = "0.5.1", features = ["rustls", "rustls-pemfile", "tls-rustls"] }
base64 = "0.21.2"
bzip2 = { version = "0.4.4", features = ["tokio"] }
chrono = { version = "0.4.26", features = ["serde"] }
chrono-humanize = "0.2.2"
clap = { version = "4.3.2", features = ["derive"] }
csv = "1.2.2"
diesel = { version = "2.1.0", features = ["sqlite", "chrono"] }
diesel_migrations = { version = "2.1.0", features = ["sqlite"] }
flate2 = "1.0.26"
futures = "0.3.28"
hyper = "0.14.26"
lazy_static = "1.4.0"
lettre = "0.10.4"
log = "0.4.18"
rand_core = { version = "0.6.4", features = ["getrandom"] }
regex = "1.8.4"
reqwest = { version = "0.11.18", features = ["json", "cookies"] }
rust-embed = "6.7.0"
rustls-pemfile = "1.0.2"
scraper = "0.17.1"
serde = { version = "1.0.163", features = ["derive"] }
serde_json = "1.0.96"
serde_yaml = "0.9.21"
serenity = { version = "0.11.5", default-features = false, features = ["builder", "cache", "client", "chrono","gateway", "model", "utils", "rustls_backend"] }
simple_logger = "4.1.0"
tempfile = "3.6.0"
thirtyfour = "0.31.0"
tokio = { version = "1.28.2", features = ["macros", "rt-multi-thread", "time"] }
tokio-rustls = "0.24.0"
tokio-stream = "0.1.14"
tower = { version = "0.4.13", features = ["limit", "timeout", "buffer"] }
tower-http = { version = "0.4.0", features = ["timeout", "limit", "cors"] }
url = "2.4.0"
urlencoding = "2.1.3"
uuid = { version = "1.3.3", features = ["v4"] }
[profile.release]
lto = "thin"
[[bin]]
name = "yoake_server"
path = "src/bin/server.rs"

66
Makefile Normal file
View file

@ -0,0 +1,66 @@
PROJECT_NAME := yoake
MODULE_PATH := github.com/eternal-flame-AD/${PROJECT_NAME}
CMD_DIR := cmd
WASM_DIR := wasm
COMMANDS := $(patsubst ${CMD_DIR}/%,%,$(shell find ${CMD_DIR}/ -mindepth 1 -maxdepth 1 -type d))
WASM_APPS := $(patsubst ${WASM_DIR}/%,%.wasm,$(shell find ${WASM_DIR}/ -mindepth 1 -maxdepth 1 -type d))
COMMANDSDIST = $(addprefix dist/,${COMMANDS})
WASM_APPSDIST = $(addprefix dist/web/,${WASM_APPS})
ifeq ($(INSTALLDEST),)
INSTALLDEST := /opt/${PROJECT_NAME}
endif
VERSION := $(shell git describe --tags --exact HEAD || printf "%s" $(shell git rev-parse --short HEAD))
BUILDDATE := $(shell date -Iminutes)
install:
mkdir -p $(INSTALLDEST)
cp -r dist/* $(INSTALLDEST)
build: webroot $(COMMANDSDIST) $(WASM_APPSDIST)
chmod -R 755 $(COMMANDSDIST) $(WASM_APPSDIST)
dev:
while true; do \
kill $$(cat .server.pid); \
make GOGCFLAGS='all=-N -l' build && \
(dist/server -c config-dev.yml & echo $$! > .server.pid); \
jq " .configurations[0].processId = $$(cat .server.pid) " .vscode/launch-tpl.json > .vscode/launch.json; \
inotifywait -e modify -r webroot internal server config && kill $(cat .server.pid) ; \
done
webroot: $(wildcard webroot/**) FORCE
mkdir -p dist
mkdir -p dist/web
cp -r assets dist
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/web/%.wasm: ${WASM_DIR}/% FORCE
GOOS=js GOARCH=wasm CGO_ENABLED=0 go build -buildvcs\
-ldflags "-X ${MODULE_PATH}/internal/version.tagVersion=$(VERSION) \
-X ${MODULE_PATH}/internal/version.buildDate=$(BUILDDATE) \
-s -w" \
-o $@ ${MODULE_PATH}/$<
dist/%: ${CMD_DIR}/% FORCE
go build -buildvcs\
-ldflags "-X ${MODULE_PATH}/internal/version.tagVersion=$(VERSION) \
-X ${MODULE_PATH}/internal/version.buildDate=$(BUILDDATE)" \
-gcflags "$(GOGCFLAGS)" \
-o $@ ${MODULE_PATH}/$<
.PHONY: build clean
FORCE:

View file

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

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

@ -0,0 +1,99 @@
package main
import (
"flag"
"log"
"os"
"strconv"
"github.com/eternal-flame-AD/go-apparmor/apparmor"
"github.com/eternal-flame-AD/go-apparmor/apparmor/magic"
"github.com/eternal-flame-AD/yoake/config"
"github.com/eternal-flame-AD/yoake/internal/comm"
"github.com/eternal-flame-AD/yoake/internal/db"
"github.com/eternal-flame-AD/yoake/internal/filestore"
"github.com/eternal-flame-AD/yoake/server"
"github.com/eternal-flame-AD/yoake/server/vault"
"github.com/eternal-flame-AD/yoake/server/webroot"
"github.com/spf13/afero"
)
var (
flagConfig = flag.String("c", "config.yml", "config file")
)
func writePid(fs filestore.FS) error {
pid := []byte(strconv.Itoa(os.Getpid()))
return afero.WriteFile(fs, "yoake.pid", pid, 0644)
}
func init() {
flag.Parse()
config.ParseConfig(*flagConfig)
fs := filestore.NewFS(config.Config().FS.BasePath)
if err := writePid(fs); err != nil {
log.Panicf("failed to write pid: %v", err)
}
db, err := db.New(config.Config())
if err != nil {
log.Panicf("failed to initialize database: %v", err)
}
comm := comm.InitCommunicator(db)
conf := config.Config()
for host, handler := range conf.Hosts {
switch handler {
case "vault":
vault.Init(host)
case "webroot":
webroot.Init(host, comm, db, fs)
default:
log.Panicf("unknown handler for %s: %s", host, handler)
}
}
}
func main() {
listen := config.Config().Listen
Server := server.New()
if listen.Ssl.Use {
var sslCertBytes, sslKeyBytes []byte
readCerts := func() {
var err error
sslCertBytes, err = os.ReadFile(listen.Ssl.Cert)
if err != nil {
log.Panicf("failed to read ssl cert: %v", err)
}
sslKeyBytes, err = os.ReadFile(listen.Ssl.Key)
if err != nil {
log.Panicf("failed to read ssl key: %v", err)
}
}
magic, err := magic.Generate(nil)
if err != nil {
log.Panicf("failed to generate apparmor magic token: %v", err)
}
if listen.AppArmor.SSL != "" {
if err := apparmor.WithHat(listen.AppArmor.SSL, func() uint64 { return magic }, readCerts); err != nil {
log.Panicf("failed to read ssl cert/key with apparmor hat: %v", err)
}
// defensive programming, try read ssl key
if _, err := os.ReadFile(listen.Ssl.Key); err == nil {
log.Panicf("AppArmor profile set for SSL but I could still read %v!", listen.Ssl.Key)
}
} else {
readCerts()
}
log.Fatalln(Server.StartTLS(listen.Addr, sslCertBytes, sslKeyBytes))
} else {
log.Fatalln(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

View file

@ -1,48 +0,0 @@
listen:
addr: 127.0.0.1:3000
db:
data_dir: data
url: sqlite://data/development.db
canvas_lms:
token: "1017~xxxxxxxxxxxx"
endpoint: "https://xxx.instructure.com/api/graphql"
refresh_interval: 300
comm:
discord:
token: "xxx"
gotify:
url: https://gotify.yumechi.jp
token: Axxxxxx
email:
from: "Yoake <yoake@yumechi.jp>"
to: "yoake@yumechi.jp"
host: "smtp.example.com"
port: 587
username: "yume@yumechi.jp"
password: "xxxx"
default_subject: "[ohime]お姫様のご注意"
auth:
users:
yume:
roles:
- Admin
- User
password: $argon2id$v=19$m=19456,t=2,p=1$U7zg/pa1Wf9Hi9NM+ns9aA$tivXyIMw+wo9ZZoz0I+6yLm7+1SfkW9fF5hONy/qq1Y
test:
roles:
- User
password: $argon2id$v=19$m=19456,t=2,p=1$U7zg/pa1Wf9Hi9NM+ns9aA$tivXyIMw+wo9ZZoz0I+6yLm7+1SfkW9fF5hONy/qq1Y
session:
secret: I3fEE8L65Ldk+BrjwPoQQu+skB7k3PlDg5uVx8EB
webcheck:
utd_app:
username: user
password: xxxx
interval: 600

64
config.yml Normal file
View file

@ -0,0 +1,64 @@
listen:
addr: :80
apparmor:
ssl: ssl
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
comm:
gotify:
baseurl: https://gotify.example.com/
apptoken: Axxxxx
email:
testmode: off
smtp:
from: "me@example.com"
to: "me@example.com"
host: "smtp.example.com"
port: "587"
username: "me@example.com"
password: "xxxx"
defaultsubject: "Hi!"
webroot:
sitename: "夜明け"
root: dist
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

20
config/canvas.go Normal file
View file

@ -0,0 +1,20 @@
package config
type CanvasLMS struct {
Token string
Frequency string
MaxN string
Endpoint string
SubmissionName string
Message struct {
OnUpdate CanvasLMSMessage
OnStartup CanvasLMSMessage
}
}
type CanvasLMSMessage struct {
Comm string
Subject string
Template string
}

32
config/comm.go Normal file
View file

@ -0,0 +1,32 @@
package config
type Communication struct {
Gotify CommGotify
Email CommEmail
Telegram CommTelegram
}
type CommTelegram struct {
Token string
Owner string
}
type CommEmail struct {
SMTP struct {
Host string
Port int
From string
To string
UserName string
Password string
DefaultSubject string
}
}
type CommGotify struct {
BaseURL string
AppToken string
}

94
config/config.go Normal file
View file

@ -0,0 +1,94 @@
package config
import (
"log"
"github.com/jinzhu/configor"
"github.com/labstack/echo/v4/middleware"
)
type C struct {
parsed bool
Hosts map[string]string
Listen struct {
Addr string
AppArmor struct {
Serve string
SSL string
}
Ssl struct {
Use bool
Cert string
Key string
}
}
DB struct {
Badger DBBadger
}
FS FileStore
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
BaseURL string
}
Comm Communication
CanvasLMS CanvasLMS
Auth struct {
ValidMinutes int
DevMode struct {
GrantAll bool
}
Users map[string]struct {
Password string
PublicKeyId []string
Roles []string
Telegram string
}
Yubikey struct {
ClientId string
ClientKey string
}
}
}
var parsedC C
var c C
func Config() C {
if !c.parsed {
log.Panicln("Config() called without calling ParseConfig() first")
}
return c
}
func MockConfig(freshEnv bool, wrapper func(deployedC *C)) {
if freshEnv {
c = parsedC
}
wrapper(&c)
}
func ParseConfig(files ...string) {
configor.Load(&parsedC, files...)
parsedC.parsed = true
c = parsedC
}

5
config/db.go Normal file
View file

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

6
config/fs.go Normal file
View file

@ -0,0 +1,6 @@
package config
type FileStore struct {
BasePath string
Serve bool
}

View file

@ -1,9 +0,0 @@
# For documentation on how to configure this file,
# see https://diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/schema.rs"
custom_type_derives = ["diesel::query_builder::QueryId"]
[migrations_directory]
dir = "migrations"

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

@ -0,0 +1,33 @@
#!/bin/env fish
make verify
or exit 2
make build
or exit 2
if ! diff etc/yoake-server.service /etc/systemd/system/yoake-server.service
sudo cp etc/yoake-server.service /etc/systemd/system/yoake-server.service
or exit 2
sudo systemctl daemon-reload
or exit 2
end
sudo systemctl stop yoake-server.service
sudo find ~caddy/yoake -mindepth 1 -delete
or exit 2
sudo -ucaddy 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

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

@ -0,0 +1,19 @@
[Unit]
Description=Yoake Server
After=network-online.target
RequiresMountsFor=/mnt/nas
Before=umount.target
[Service]
User=caddy
SupplementaryGroups=ssl
WorkingDirectory=/var/lib/caddy/yoake
ExecStart=/var/lib/caddy/yoake/server
Restart=always
AmbientCapabilities=CAP_NET_BIND_SERVICE
AppArmorProfile=yoake
SupplementaryGroups=data
[Install]
WantedBy=multi-user.target

42
etc/yoake.apparmor Normal file
View file

@ -0,0 +1,42 @@
# Last Modified: Fri Nov 11 17:59:18 2022
abi <abi/3.0>,
include <tunables/global>
@{YOAKE}="/var/lib/caddy/yoake"
@{YOAKE_DATA}="/var/lib/caddy/yoake.badger"
profile yoake @{YOAKE}/server {
include <abstractions/base>
include <abstractions/nameservice>
include <abstractions/apparmor_api/introspect>
^ssl {
include <abstractions/base>
include <abstractions/ssl_certs>
include <abstractions/ssl_keys>
include <abstractions/apparmor_api/introspect>
}
/sys/kernel/mm/transparent_hugepage/* r,
/proc/sys/net/core/somaxconn r,
@{YOAKE}/server mr,
@{YOAKE}/** r,
@{YOAKE_DATA}/ rwk,
@{YOAKE_DATA}/** rwlk,
capability net_bind_service,
signal send peer=yoake//serve,
^serve flags=(kill) {
include <abstractions/base>
include <abstractions/nameservice>
include <abstractions/apparmor_api/introspect>
/sys/kernel/mm/transparent_hugepage/ r,
@{YOAKE}/server mr,
@{YOAKE}/ r,
@{YOAKE}/{assets,webroot}/** r,
@{YOAKE_DATA}/ rwk,
@{YOAKE_DATA}/** rwlk,
signal receive peer=yoake,
}
}

66
go.mod Normal file
View file

@ -0,0 +1,66 @@
module github.com/eternal-flame-AD/yoake
go 1.19
require (
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible
github.com/PuerkitoBio/goquery v1.8.0
github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387
github.com/dgraph-io/badger/v3 v3.2103.4
github.com/eternal-flame-AD/go-apparmor v0.0.4
github.com/eternal-flame-AD/yubigo v0.0.0-20221005082707-ce0c8989e8b1
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/gomarkdown/markdown v0.0.0-20221013030248-663e2500819c
github.com/google/uuid v1.3.0
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/maxence-charriere/go-app/v9 v9.6.7
github.com/spf13/afero v1.9.3
github.com/stretchr/testify v1.8.1
github.com/twilio/twilio-go v1.1.1
github.com/vanng822/go-premailer v1.20.1
golang.org/x/mod v0.7.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
)
require (
github.com/BurntSushi/toml v0.3.1 // indirect
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.1.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgraph-io/ristretto v0.1.1 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/mock v1.6.0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/snappy v0.0.3 // indirect
github.com/google/flatbuffers v1.12.1 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/jsipprell/keyctl v1.0.3 // indirect
github.com/klauspost/compress v1.12.3 // indirect
github.com/labstack/gommon v0.4.0 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.1 // indirect
github.com/vanng822/css v1.0.1 // indirect
go.opencensus.io v0.22.5 // indirect
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect
golang.org/x/net v0.0.0-20220728030405-41545e8bf201 // indirect
golang.org/x/sys v0.0.0-20221010170243-090e33056c14 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect
google.golang.org/protobuf v1.26.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

618
go.sum Normal file
View file

@ -0,0 +1,618 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible h1:1G1pk05UrOh0NlF1oeaaix1x8XzrfjIDK47TY0Zehcw=
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387 h1:loy0fjI90vF44BPW4ZYOkE3tDkGTy7yHURusOJimt+I=
github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387/go.mod h1:GuR5j/NW7AU7tDAQUDGCtpiPxWIOy/c3kiRDnlwiCHc=
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v3 v3.2103.4 h1:WE1B07YNTTJTtG9xjBcSW2wn0RJLyiV99h959RKZqM4=
github.com/dgraph-io/badger/v3 v3.2103.4/go.mod h1:4MPiseMeDQ3FNCYwRbbcBOGJLf5jsE0PPFzRiKjtcdw=
github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/eternal-flame-AD/go-apparmor v0.0.4 h1:MSHdwn+lCby8HWm3q4NZRWUejCNlJF86RbX+YWim6/A=
github.com/eternal-flame-AD/go-apparmor v0.0.4/go.mod h1:OpqESxf/LXsssooWBPzAoIAC2PtloCT1CmA+glQKYV8=
github.com/eternal-flame-AD/yubigo v0.0.0-20221005082707-ce0c8989e8b1 h1:B+ad4UMWwNAUsZhLLQCCrEx+cfLsbf0+AbbcfG7RIv0=
github.com/eternal-flame-AD/yubigo v0.0.0-20221005082707-ce0c8989e8b1/go.mod h1:kRnqsWaIjqWNPoCV14+cxs/B9eClc0hKL/I2a3LKOQ4=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.3 h1:fHPg5GQYlCeLIPB9BZqMVR5nR9A+IM5zcgeTdjMYmLA=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomarkdown/markdown v0.0.0-20221013030248-663e2500819c h1:iyaGYbCmcYK0Ja9a3OUa2Fo+EaN0cbLu0eKpBwPFzc8=
github.com/gomarkdown/markdown v0.0.0-20221013030248-663e2500819c/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw=
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko=
github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
github.com/jsipprell/keyctl v1.0.3 h1:o72tppb3ZhP5B/v9FGUtMqJWx+S1Gs0elQ7AZmiNhsM=
github.com/jsipprell/keyctl v1.0.3/go.mod h1:64s6WpBtruURX3w8W/vhWj1/uh+nOm7vUXSJlK5+KMs=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.12.3 h1:G5AfA94pHPysR56qqrkO2pxEexdDzrpFJ6yt/VqWxVU=
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
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 h1:IZycmTpoUtQK3PD60UYBwjaCUHUP7cML494ao9/O8+Q=
github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275/go.mod h1:zt6UU74K6Z6oMOYJbJzYpYucqdcQwSMPBEdSvGiaUMw=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/maxence-charriere/go-app/v9 v9.6.7 h1:t+wofnLjVsptBB7MNevsFymMYaMIX2hGjLdWgsIFgq4=
github.com/maxence-charriere/go-app/v9 v9.6.7/go.mod h1:UlniES44R5JoD4HsjMNrAqWXSzyw0smM0Ox+QwnO/IE=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/afero v1.9.3 h1:41FoI0fD7OR7mGcKE/aOiLkGreyf8ifIOQmJANWogMk=
github.com/spf13/afero v1.9.3/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
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/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/unrolled/render v1.0.3/go.mod h1:gN9T0NhL4Bfbwu8ann7Ry/TGHYfosul+J0obPf6NBdM=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/vanng822/css v1.0.1 h1:10yiXc4e8NI8ldU6mSrWmSWMuyWgPr9DZ63RSlsgDw8=
github.com/vanng822/css v1.0.1/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w=
github.com/vanng822/go-premailer v1.20.1 h1:2LTSIULXxNV5IOB5BSD3dlfOG95cq8qqExtRZMImTGA=
github.com/vanng822/go-premailer v1.20.1/go.mod h1:RAxbRFp6M/B171gsKu8dsyq+Y5NGsUUvYfg+WQWusbE=
github.com/vanng822/r2router v0.0.0-20150523112421-1023140a4f30/go.mod h1:1BVq8p2jVr55Ost2PkZWDrG86PiJ/0lxqcXoAcGxvWU=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5 h1:dntmOdLpSpHlVqbW5Eay97DelsZHe+55D+xC6i0dDS0=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220728030405-41545e8bf201 h1:bvOltf3SADAfG05iRml8lAB3qjoEX5RCyN4K6G5v3N0=
golang.org/x/net v0.0.0-20220728030405-41545e8bf201/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20221010170243-090e33056c14 h1:k5II8e6QD8mITdi+okbbmR/cIyEbeXLBhy5Ha4nevyc=
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 h1:ftMN5LMiBFjbzleLqtoBZk7KdJwhuybIU+FckUHgoyQ=
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

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

@ -0,0 +1,319 @@
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/alexedwards/argon2id"
"github.com/eternal-flame-AD/yoake/config"
"github.com/eternal-flame-AD/yoake/internal/auth/tglogin"
"github.com/eternal-flame-AD/yoake/internal/db"
"github.com/eternal-flame-AD/yoake/internal/echoerror"
"github.com/eternal-flame-AD/yoake/internal/util"
"github.com/gorilla/sessions"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
const AuthSessionName = "auth_session"
var dummyHash string
func authSessionOptions() *sessions.Options {
return &sessions.Options{
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: config.Config().Listen.Ssl.Use,
MaxAge: config.Config().Auth.ValidMinutes * 60 * 5,
}
}
func init() {
var dummyPassword [16]byte
_, err := rand.Read(dummyPassword[:])
if err != nil {
panic(err)
}
dummyHash, err = argon2id.CreateHash(string(dummyPassword[:]), Argon2IdParams)
if err != nil {
panic(err)
}
}
type RequestAuth struct {
Present bool
Valid bool
Roles []string
Expire time.Time
Ident UserIdent
}
type UserIdent struct {
Username string `json:"username"`
PhotoURL string `json:"photo_url"`
DisplayName string `json:"display_name"`
}
func (a RequestAuth) HasRole(role Role) bool {
if !a.Valid {
return false
}
for _, r := range a.Roles {
if r == string(role) {
return true
}
}
return false
}
type RoleInsufficientError struct {
RoleRequired Role
RolesAvailable []string
}
func (e RoleInsufficientError) Error() string {
return fmt.Sprintf("role insufficient: required %v, you have %v", e.RoleRequired, e.RolesAvailable)
}
func (e RoleInsufficientError) Code() int {
if len(e.RolesAvailable) == 0 {
return http.StatusUnauthorized
}
return http.StatusForbidden
}
func (a RequestAuth) RequireRole(role Role) error {
if config := config.Config(); config.Auth.DevMode.GrantAll && !config.Listen.Ssl.Use {
log.Printf("dev mode: role %v granted without checking", role)
return nil
}
if a.HasRole(role) {
return nil
}
return RoleInsufficientError{RoleRequired: role, RolesAvailable: a.Roles}
}
func RequireMiddleware(role Role) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
auth := GetRequestAuth(c)
if err := auth.RequireRole(role); err != nil {
return err
}
return next(c)
}
}
}
func Middleware(store sessions.Store) echo.MiddlewareFunc {
yubiAuthLazyInit()
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
sess, _ := store.Get(c.Request(), AuthSessionName)
sess.Options = authSessionOptions()
var auth RequestAuth
if expireTs, ok := sess.Values["expire"].(string); ok {
auth.Present = true
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
}
if util.Contain(auth.Roles, string(RoleAdmin)) {
c.Set("devel", true)
}
if existingIdentJSON, ok := sess.Values["ident"].([]byte); !ok {
sess.Values["ident"] = []byte("{}")
sess.Save(c.Request(), c.Response())
} else if auth.Valid {
if err := json.Unmarshal(existingIdentJSON, &auth.Ident); err != nil {
log.Printf("invalid ident: %v", existingIdentJSON)
}
}
c.Set("auth_"+AuthSessionName, auth)
c.Set("auth_store", store)
return next(c)
}
}
}
func issueIdent(c echo.Context, ident UserIdent) error {
sess, _ := c.Get("auth_store").(sessions.Store).Get(c.Request(), AuthSessionName)
sess.Options = authSessionOptions()
identJSON, err := json.Marshal(ident)
if err != nil {
return err
}
sess.Values["ident"] = identJSON
return sess.Save(c.Request(), c.Response())
}
func issueSession(c echo.Context, period time.Duration, roles []string) error {
sess, _ := c.Get("auth_store").(sessions.Store).Get(c.Request(), AuthSessionName)
sess.Options = authSessionOptions()
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 {
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())
}
type LoginForm struct {
Username string `json:"username" form:"username"`
Password string `json:"password" form:"password"`
OtpResponse string `json:"otp_response" form:"otp_response"`
}
var errInvalidUserPass = echoerror.NewHttp(http.StatusUnauthorized, errors.New("invalid username or password"))
func Register(g *echo.Group, database db.DB) (err error) {
g.GET("/auth.json", func(c echo.Context) error {
return c.JSON(http.StatusOK, GetRequestAuth(c))
})
loginRateLimiterStore := middleware.NewRateLimiterMemoryStoreWithConfig(middleware.RateLimiterMemoryStoreConfig{
ExpiresIn: 300 * time.Second,
Rate: 2,
Burst: 4,
})
loginRateLimiter := middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
Store: loginRateLimiterStore,
})
g.POST("/login_tg", func(c echo.Context) error {
tgForm, err := tglogin.VerifyLoginCallback(c, database)
if err != nil {
return err
}
if tgForm.ID == 0 {
return echoerror.NewHttp(http.StatusUnauthorized, errors.New("invalid telegram login"))
}
roles := []string{string(RoleTelgram)}
for _, user := range config.Config().Auth.Users {
if user.Telegram == "@"+tgForm.UserName ||
user.Telegram == strconv.FormatUint(tgForm.ID, 10) {
{
roles = append(roles, user.Roles...)
}
}
}
if err := issueSession(c, 0, roles); err != nil {
return fmt.Errorf("failed to issue session: %v", err)
}
if err := issueIdent(c, UserIdent{
Username: "@" + tgForm.UserName,
PhotoURL: tgForm.PhotoURL,
DisplayName: strings.TrimSpace(tgForm.FirstName + " " + tgForm.LastName),
}); err != nil {
return fmt.Errorf("failed to issue ident: %v", err)
}
return c.JSON(http.StatusOK, GetRequestAuth(c))
})
g.POST("/login", func(c echo.Context) error {
var form LoginForm
if err := c.Bind(&form); err != nil {
return err
}
var verifiedOtpPubId string
if form.OtpResponse != "" {
form.OtpResponse = strings.TrimSpace(form.OtpResponse)
if yubiAuth == nil {
return echo.NewHTTPError(http.StatusNotImplemented, "Yubikey authentication not configured")
}
if yr, ok, err := yubiAuth.Verify(form.OtpResponse); err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Yubikey authentication failed: "+err.Error())
} else if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Yubikey authentication failed")
} else {
// sessionUseCounter := yr.GetResultParameter("sessionuse")
// sessionCounter := yr.GetResultParameter("sessioncounter")
keyPublicId := yr.GetResultParameter("otp")[:12]
verifiedOtpPubId = keyPublicId
}
}
if form.Username == "" {
return echo.NewHTTPError(http.StatusBadRequest, "username required")
}
if form.Password == "" {
return echo.NewHTTPError(http.StatusBadRequest, "password required")
}
if user, ok := config.Config().Auth.Users[form.Username]; ok {
if match, _ := argon2id.ComparePasswordAndHash(form.Password, user.Password); match {
if len(user.PublicKeyId) > 0 {
if verifiedOtpPubId == "" {
return echo.NewHTTPError(http.StatusUnauthorized, "otp required")
}
found := 0
for _, pubId := range user.PublicKeyId {
found += subtle.ConstantTimeCompare([]byte(pubId[:12]), []byte(verifiedOtpPubId))
}
if found == 0 {
return echo.NewHTTPError(http.StatusUnauthorized, "incorrect key used")
}
} else if verifiedOtpPubId != "" {
return echo.NewHTTPError(http.StatusBadRequest, "otp not required but you provided one, this may be an configuration error")
}
if err := issueSession(c, 0, user.Roles); err != nil {
return fmt.Errorf("failed to issue session: %w", err)
}
if err := issueIdent(c, UserIdent{
Username: form.Username,
DisplayName: form.Username,
}); err != nil {
return fmt.Errorf("failed to issue identification: %w", err)
}
c.JSON(http.StatusOK, map[string]interface{}{"message": "ok", "ok": true})
return nil
} else {
return errInvalidUserPass
}
}
argon2id.ComparePasswordAndHash(form.Password, dummyHash)
return errInvalidUserPass
}, loginRateLimiter)
g.DELETE("/login", func(c echo.Context) error {
return issueSession(c, -1, nil)
})
return nil
}
func GetRequestAuth(c echo.Context) RequestAuth {
if a, ok := c.Get("auth_" + AuthSessionName).(RequestAuth); ok {
return a
} else {
return RequestAuth{Present: false, Valid: false}
}
}

7
internal/auth/gob.go Normal file
View file

@ -0,0 +1,7 @@
package auth
import "encoding/gob"
func init() {
gob.Register(UserIdent{})
}

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

@ -0,0 +1,11 @@
package auth
import "github.com/alexedwards/argon2id"
var Argon2IdParams = &argon2id.Params{
Memory: 64 * 1024,
Iterations: 4,
Parallelism: 2,
SaltLength: 16,
KeyLength: 32,
}

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

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

View file

@ -0,0 +1,93 @@
package tglogin
import (
"crypto/hmac"
"crypto/sha256"
"encoding/json"
"fmt"
"sort"
"time"
"github.com/eternal-flame-AD/yoake/config"
"github.com/eternal-flame-AD/yoake/internal/db"
"github.com/labstack/echo/v4"
)
type LoginCallbackForm struct {
ID uint64 `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
UserName string `json:"username"`
PhotoURL string `json:"photo_url"`
AuthDate uint64 `json:"auth_date"`
}
type verifyForm map[string]interface{}
func sha256Str(input string) []byte {
h := sha256.New()
h.Write([]byte(input))
return h.Sum(nil)
}
func VerifyLoginCallback(c echo.Context, database db.DB) (*LoginCallbackForm, error) {
txn := database.NewTransaction(true)
defer txn.Discard()
tgToken := config.Config().Comm.Telegram.Token
if tgToken == "" {
return nil, fmt.Errorf("telegram token not set")
}
verifyForm := make(verifyForm)
if err := c.Bind(&verifyForm); err != nil {
return nil, err
}
verifyKeys := make([]string, 0, len(verifyForm))
for k := range verifyForm {
if k != "hash" {
verifyKeys = append(verifyKeys, k)
}
}
sort.Strings(verifyKeys)
hmac := hmac.New(sha256.New, sha256Str(tgToken))
for i, k := range verifyKeys {
if i != 0 {
hmac.Write([]byte("\n"))
}
switch val := verifyForm[k].(type) {
case string:
fmt.Fprintf(hmac, "%s=%s", k, val)
case float64:
fmt.Fprintf(hmac, "%s=%v", k, uint64(val))
default:
return nil, fmt.Errorf("invalid verify form, unexpected type %T", val)
}
}
if fmt.Sprintf("%x", hmac.Sum(nil)) != verifyForm["hash"] {
return nil, echo.NewHTTPError(403, "invalid hash")
}
form := new(LoginCallbackForm)
remarshaled, err := json.Marshal(verifyForm)
if err != nil {
return nil, err
}
if err := json.Unmarshal(remarshaled, form); err != nil {
return nil, err
}
lastAuthDateDBKey := fmt.Sprintf("auth_telegram_last_auth_date_%d", form.ID)
var dbLastAuthDate uint64
if err := db.GetJSON(txn, []byte(lastAuthDateDBKey), &dbLastAuthDate); db.IsNotFound(err) {
dbLastAuthDate = 0
} else if err != nil {
return nil, err
}
if form.AuthDate <= dbLastAuthDate || time.Since(time.Unix(int64(form.AuthDate), 0)) > 10*time.Minute {
return nil, echo.NewHTTPError(403, "authentication payload expired")
}
if err := db.SetJSON(txn, []byte(lastAuthDateDBKey), form.AuthDate); err != nil {
return nil, err
}
return form, nil
}

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.Yubikey.ClientId != "" {
if a, err := yubigo.NewYubiAuth(conf.Auth.Yubikey.ClientId, conf.Auth.Yubikey.ClientKey); err != nil {
log.Panicf("failed to load yubigo: %v", err)
} else {
yubiAuth = a
}
}
}

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

@ -0,0 +1,146 @@
package canvaslms
import (
"time"
)
const GraphQuery = `query gradeQuery {
allCourses {
_id
id
name
state
courseCode
submissionsConnection(first: $maxn$, orderBy: {field: gradedAt, direction: descending}) {
nodes {
_id
id
assignment {
_id
id
name
dueAt
gradingType
pointsPossible
htmlUrl
}
score
enteredScore
grade
enteredGrade
gradingStatus
gradeHidden
gradedAt
posted
postedAt
state
user {
_id
id
name
sisId
email
}
}
}
}
}`
type GraphResponse struct {
Data struct {
AllCourses []struct {
IDLegacy string `json:"_id"`
ID string `json:"id"`
Name string `json:"name"`
State string `json:"state"`
CourseCode string `json:"courseCode"`
SubmissionsConnection struct {
Nodes []GraphSubmissionResponse `json:"nodes"`
} `json:"submissionsConnection"`
} `json:"allCourses"`
} `json:"data"`
}
type GraphSubmissionResponse struct {
IDLegacy string `json:"_id"`
ID string `json:"id"`
Assignment struct {
IDLegacy string `json:"_id"`
ID string `json:"id"`
Name string `json:"name"`
DueAt *string `json:"dueAt"`
GradingType string `json:"gradingType"`
PointsPossible float64 `json:"pointsPossible"`
HTMLUrl string `json:"htmlUrl"`
} `json:"assignment"`
Score *float64 `json:"score"`
EnteredScore *float64 `json:"enteredScore"`
Grade *string `json:"grade"`
EnteredGrade *string `json:"enteredGrade"`
GradingStatus string `json:"gradingStatus"`
GradeHidden bool `json:"gradeHidden"`
GradedAt *string `json:"gradedAt"`
Posted bool `json:"posted"`
PostedAt *string `json:"postedAt"`
State string `json:"state"`
User struct {
IDLegacy string `json:"_id"`
ID string `json:"id"`
SISID *string `json:"sisId"`
Name string `json:"name"`
Email *string `json:"email"`
}
}
type GraphSubmissionCompareFunc func(m1, m2 GraphSubmissionResponse) (m1HasPriority bool)
func parseJSONTime(s string) time.Time {
t, _ := time.Parse(time.RFC3339, s)
return t
}
func GraphSubmissionCompareByDue(m1, m2 GraphSubmissionResponse) (m1HasPriority bool) {
if m1.Assignment.DueAt == nil {
return false
}
if m2.Assignment.DueAt == nil {
return true
}
m1Time, m2Time := parseJSONTime(*m1.Assignment.DueAt), parseJSONTime(*m2.Assignment.DueAt)
now := time.Now()
m1IsPast, m2IsPast := now.After(m1Time), now.After(m2Time)
if m1IsPast && m2IsPast {
return m1Time.After(m2Time)
}
if !m1IsPast && !m2IsPast {
return m1Time.Before(m2Time)
}
return !m1IsPast
}
func laterTime(t1, t2 *string) *string {
if t1 == nil {
return t2
}
if t2 == nil {
return t1
}
t1T, t2T := parseJSONTime(*t1), parseJSONTime(*t2)
if t1T.After(t2T) {
return t1
}
return t2
}
func GraphSubmissionCompareByGradeTime(m1, m2 GraphSubmissionResponse) (m1HasPriority bool) {
m1LastUpdate := laterTime(m1.PostedAt, m1.GradedAt)
m2LastUpdate := laterTime(m2.PostedAt, m2.GradedAt)
if m2LastUpdate == nil {
return true
}
if m1LastUpdate == nil {
return false
}
return parseJSONTime(*m1LastUpdate).After(parseJSONTime(*m2LastUpdate))
}

View file

@ -0,0 +1,318 @@
package canvaslms
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/eternal-flame-AD/yoake/config"
"github.com/eternal-flame-AD/yoake/internal/auth"
"github.com/eternal-flame-AD/yoake/internal/comm/model"
"github.com/eternal-flame-AD/yoake/internal/echoerror"
"github.com/labstack/echo/v4"
)
type Handler struct {
conf config.CanvasLMS
maxn int
respCache *responseCache
respCacheMutex sync.RWMutex
refreshPeriod time.Duration
comm model.Communicator
}
type GetGradesResponse struct {
LastRefresh string `json:"last_refresh"`
Grades []SubmissionScoreResponse `json:"grades"`
}
type SubmissionScoreResponse struct {
Name string
Due string
AssignmentID string
AssignmentLegacyID string
AssignmentURL string
SubmissionID string
SubmissionLegacyID string
CourseID string
CourseLegacyID string
CourseName string
CourseCode string
Graded string
Posted string
State string
Score float64
EnteredScore float64
PossiblePoints float64
Grade string
EnteredGrade string
GradeHidden bool
GradedAt string
PostedAt string
SubmissionUserLegacyID string
SubmissionUserID string
SubmissionUserName string
SubmissionUserSISID string
SubmissionUserEmail string
}
func submissionScoreResponseFromQL(courselid, courseid, coursename, coursecode string, coursesubmission GraphSubmissionResponse) SubmissionScoreResponse {
res := SubmissionScoreResponse{
Name: coursesubmission.Assignment.Name,
Due: "-",
AssignmentID: coursesubmission.Assignment.ID,
AssignmentLegacyID: coursesubmission.Assignment.IDLegacy,
AssignmentURL: coursesubmission.Assignment.HTMLUrl,
SubmissionID: coursesubmission.ID,
SubmissionLegacyID: coursesubmission.IDLegacy,
CourseID: courseid,
CourseLegacyID: courselid,
CourseName: coursename,
CourseCode: coursecode,
Graded: coursesubmission.GradingStatus,
Posted: strconv.FormatBool(coursesubmission.Posted),
State: coursesubmission.State,
Score: -1,
EnteredScore: -1,
PossiblePoints: coursesubmission.Assignment.PointsPossible,
Grade: "-",
EnteredGrade: "-",
GradeHidden: coursesubmission.GradeHidden,
GradedAt: "-",
PostedAt: "-",
SubmissionUserID: coursesubmission.User.ID,
SubmissionUserLegacyID: coursesubmission.User.IDLegacy,
SubmissionUserName: coursesubmission.User.Name,
}
if coursesubmission.Score != nil {
res.Score = *coursesubmission.Score
}
if coursesubmission.EnteredScore != nil {
res.EnteredScore = *coursesubmission.EnteredScore
}
if coursesubmission.Assignment.DueAt != nil {
res.Due = *coursesubmission.Assignment.DueAt
}
if coursesubmission.Grade != nil {
res.Grade = *coursesubmission.Grade
}
if coursesubmission.GradedAt != nil {
res.GradedAt = *coursesubmission.GradedAt
}
if coursesubmission.PostedAt != nil {
res.PostedAt = *coursesubmission.PostedAt
}
if coursesubmission.User.SISID != nil {
res.SubmissionUserSISID = *coursesubmission.User.SISID
}
if coursesubmission.User.Email != nil {
res.SubmissionUserEmail = *coursesubmission.User.Email
}
return res
}
func (h *Handler) sortResponse(compare GraphSubmissionCompareFunc) (resp []SubmissionScoreResponse, err error) {
h.respCacheMutex.RLock()
defer h.respCacheMutex.RUnlock()
res := make([]GraphSubmissionResponse, h.maxn)
resF := make([]SubmissionScoreResponse, h.maxn)
curL := 0
push := func(pos int, resp GraphSubmissionResponse, respF SubmissionScoreResponse) {
for i := curL - 1; i > pos; i-- {
res[i] = res[i-1]
resF[i] = resF[i-1]
}
res[pos] = resp
resF[pos] = respF
}
for _, course := range h.respCache.rawResponse.Data.AllCourses {
for _, submission := range course.SubmissionsConnection.Nodes {
pos := curL
for i := curL - 1; i >= 0; i-- {
if !compare(submission, res[i]) {
break
}
pos = i
}
if pos < curL || curL < h.maxn {
push(pos, submission, submissionScoreResponseFromQL(course.IDLegacy, course.ID, course.Name, course.CourseCode, submission))
if curL < h.maxn {
curL++
}
}
}
}
return resF, nil
}
func (h *Handler) refresh() (hasUpdate bool, err error) {
h.respCacheMutex.Lock()
defer h.respCacheMutex.Unlock()
client := http.Client{
Timeout: h.refreshPeriod / 2,
}
buf := bytes.NewBufferString("")
e := json.NewEncoder(buf)
e.Encode(struct {
Query string `json:"query"`
OperationName string `json:"operationName"`
Variables *struct{} `json:"variables"`
}{strings.ReplaceAll(GraphQuery, "$maxn$", h.conf.MaxN), "gradeQuery", nil})
now := time.Now()
req, err := http.NewRequest("POST", h.conf.Endpoint, buf)
if err != nil {
return false, err
}
req.Header.Set("content-type", "application/json")
req.Header.Set("Authorization", "Bearer "+h.conf.Token)
resp, err := client.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return false, fmt.Errorf("remote returned with %d", resp.StatusCode)
}
dec := json.NewDecoder(resp.Body)
respStruct := new(GraphResponse)
if err := dec.Decode(respStruct); err != nil {
return false, err
}
hasUpdate = false
lastUpdateTime := make(map[string]time.Time)
for _, course := range respStruct.Data.AllCourses {
for _, submission := range course.SubmissionsConnection.Nodes {
lastUpdForSubmission := laterTime(submission.PostedAt, submission.GradedAt)
if lastUpdForSubmission != nil {
newUpdTime := parseJSONTime(*lastUpdForSubmission)
lastUpdateTime[submission.ID] = newUpdTime
if h.respCache != nil && (h.conf.SubmissionName == "" || submission.User.Name == h.conf.SubmissionName) {
if lastUpdateTime, ok := h.respCache.submissionLastUpdate[submission.ID]; !ok || lastUpdateTime.UnixNano() != newUpdTime.UnixNano() {
hasUpdate = true
}
}
}
}
}
h.respCache = &responseCache{
rawResponse: *respStruct,
requestTime: now,
submissionLastUpdate: lastUpdateTime,
}
if hasUpdate {
go h.SendGradeMessage(h.conf.Message.OnUpdate)
}
return hasUpdate, nil
}
func (h *Handler) GetInformation(key string) (data interface{}, err error) {
switch key {
case "recent-graded":
return h.sortResponse(GraphSubmissionCompareByGradeTime)
case "recent-due":
return h.sortResponse(GraphSubmissionCompareByDue)
}
return nil, errors.New("unknown info request type")
}
type responseCache struct {
rawResponse GraphResponse
requestTime time.Time
submissionLastUpdate map[string]time.Time
}
func Register(g *echo.Group, comm model.Communicator) (h *Handler, err error) {
h = &Handler{conf: config.Config().CanvasLMS, comm: comm}
if h.conf.Token == "" {
return nil, errors.New("canvas token not set")
}
maxn, err := strconv.Atoi(h.conf.MaxN)
if err != nil {
return nil, err
}
h.maxn = maxn
refreshperiod, err := time.ParseDuration(h.conf.Frequency)
if err != nil {
return nil, err
}
h.refreshPeriod = refreshperiod
checkForUpdates := make(chan bool)
go func() {
if _, err := h.refresh(); err != nil {
log.Panicf("cannot access graphql endpoint: %v", err)
} else {
go h.SendGradeMessage(h.conf.Message.OnStartup)
}
for forced := range checkForUpdates {
_ = forced
h.refresh()
// TODO: notify if there is an update
}
}()
go func() {
for range time.NewTicker(h.refreshPeriod).C {
checkForUpdates <- false
}
}()
gradesG := g.Group("/grades", auth.RequireMiddleware(auth.RoleAdmin), func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if h.respCache == nil {
return echoerror.NewHttp(http.StatusServiceUnavailable, errors.New("not yet initialized"))
}
return next(c)
}
})
{
gradesG.GET("", func(c echo.Context) error {
if c.QueryParam("refresh") == "1" {
if _, err := h.refresh(); err != nil {
return fmt.Errorf("cannot access graphql endpoint: %v", err)
}
}
sortQuery := c.QueryParam("sort")
if sortQuery == "" {
sortQuery = "recent-graded"
}
var res GetGradesResponse
res.LastRefresh = h.respCache.requestTime.Format(time.RFC3339)
switch sortQuery {
case "recent-graded":
if grades, err := h.sortResponse(GraphSubmissionCompareByGradeTime); err != nil {
return err
} else {
res.Grades = grades
}
return c.JSON(http.StatusOK, res)
case "recent-due":
if grades, err := h.sortResponse(GraphSubmissionCompareByDue); err != nil {
return err
} else {
res.Grades = grades
}
return c.JSON(http.StatusOK, res)
}
return errors.New("unknown info request type")
})
}
return h, nil
}

View file

@ -0,0 +1,38 @@
package canvaslms
import (
"log"
"strings"
"time"
"github.com/eternal-flame-AD/yoake/config"
"github.com/eternal-flame-AD/yoake/internal/comm/model"
)
func (h *Handler) SendGradeMessage(conf config.CanvasLMSMessage) error {
if conf.Template == "" {
return nil
}
mime := "text/plain+text/template"
if strings.HasSuffix(conf.Template, ".html") && strings.HasPrefix(conf.Template, "@") {
mime = "text/html+html/template"
}
grades, err := h.sortResponse(GraphSubmissionCompareByGradeTime)
if err != nil {
return err
}
if err := h.comm.SendGenericMessage(conf.Comm, &model.GenericMessage{
Subject: conf.Subject,
Body: conf.Template,
MIME: mime,
Context: GetGradesResponse{
Grades: grades,
LastRefresh: h.respCache.requestTime.Format(time.RFC3339),
},
}, false); err != nil {
log.Printf("error sending grade message: %v", err)
return err
}
return nil
}

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

@ -0,0 +1,71 @@
package comm
import (
"github.com/eternal-flame-AD/yoake/internal/auth"
"github.com/eternal-flame-AD/yoake/internal/comm/model"
"github.com/eternal-flame-AD/yoake/internal/util"
"github.com/labstack/echo/v4"
)
type CommStatusResponse struct {
Communicators []struct {
Method string
SupportedMIME []string
} `json:"communicators"`
}
func (c *Communicator) RegisterAPIRoute(g *echo.Group) {
send := g.Group("/send", auth.RequireMiddleware(auth.RoleAdmin))
{
send.POST("", func(ctx echo.Context) error {
var msg model.GenericMessage
if err := ctx.Bind(&msg); err != nil {
return err
}
if err := c.SendGenericMessage("", &msg, false); err != nil {
return err
}
ctx.JSON(200, msg)
return nil
})
send.POST("/:method", func(ctx echo.Context) error {
var msg model.GenericMessage
if err := ctx.Bind(&msg); err != nil {
return err
}
if err := c.SendGenericMessage(ctx.Param("method"), &msg, ctx.QueryParam("force") == "1"); err != nil {
return err
}
ctx.JSON(200, msg)
return nil
})
}
g.GET("/status", func(ctx echo.Context) error {
var communicators []struct {
Method string
SupportedMIME []string
}
for _, comm := range c.fallbackCommunicators {
communicators = append(communicators, struct {
Method string
SupportedMIME []string
}{
Method: comm,
SupportedMIME: c.commMethods[comm].SupportedMIME(),
})
}
for key, comm := range c.commMethods {
if !util.Contain(c.fallbackCommunicators, key) {
communicators = append(communicators, struct {
Method string
SupportedMIME []string
}{
Method: key,
SupportedMIME: comm.SupportedMIME(),
})
}
}
return ctx.JSON(200, CommStatusResponse{Communicators: communicators})
})
}

View file

@ -0,0 +1,125 @@
package comm
import (
"errors"
"fmt"
"log"
"github.com/eternal-flame-AD/yoake/internal/comm/email"
"github.com/eternal-flame-AD/yoake/internal/comm/gotify"
"github.com/eternal-flame-AD/yoake/internal/comm/model"
"github.com/eternal-flame-AD/yoake/internal/comm/telegram"
"github.com/eternal-flame-AD/yoake/internal/db"
"github.com/eternal-flame-AD/yoake/internal/util"
)
type Communicator struct {
commMethods map[string]model.CommMethod
fallbackCommunicators []string
}
var (
errMethodNotSupported = errors.New("method not supported")
)
func (c *Communicator) actualSendGenericMessage(tryMethod string, message *model.GenericMessage) error {
if comm, ok := c.commMethods[tryMethod]; ok {
if convertedMsg, err := ConvertGenericMessage(message, comm.SupportedMIME()); err == nil {
err := comm.SendGenericMessage(convertedMsg)
if err != nil {
return err
}
message.ThreadID = convertedMsg.ThreadID
return nil
} else {
return err
}
}
return errMethodNotSupported
}
// GetMethod returns the method with the given name.
func (c *Communicator) GetMethod(method string) model.CommMethod {
return c.commMethods[method]
}
// GetMethodsByMIME returns a list of methods that support the given MIME type as the message type, MIME convertions were considered.
func (c *Communicator) GetMethodsByMIME(mime string) []model.CommMethod {
var result []model.CommMethod
for _, comm := range c.commMethods {
if util.Contain(ConvertOutMIMEToSupportedInMIME(comm.SupportedMIME()), mime) {
result = append(result, comm)
}
}
return result
}
type ErrorSentWithFallback struct {
OriginalError error
OrignalMethod string
FallbackMethod string
}
func (e ErrorSentWithFallback) Error() string {
return fmt.Sprintf("used fallback method %s because original method %s reeported error: %v", e.FallbackMethod, e.OrignalMethod, e.OriginalError)
}
// SendGenericMethods sends a message using the preferred method
// if the preferred method failed to send the message, fallback methods will be tried,
// and an ErrorSentWithFabback will be returned if any fallback method succeeded.
// if fallback methods failed as well the original error will be returned.
func (c *Communicator) SendGenericMessage(preferredMethod string, message *model.GenericMessage, force bool) error {
if preferredMethod == "" {
preferredMethod = c.fallbackCommunicators[0]
}
if origErr := c.actualSendGenericMessage(preferredMethod, message); origErr != nil {
if force {
log.Printf("Failed to send message using preferred method %s: %v", preferredMethod, origErr)
return origErr
}
log.Printf("Failed to send message using preferred method %s: %v. trying fallback methods", preferredMethod, origErr)
for _, fallback := range c.fallbackCommunicators {
if fallback == preferredMethod {
continue
}
if err := c.actualSendGenericMessage(fallback, message); err == nil {
log.Printf("Sent message using fallback method %s", fallback)
return ErrorSentWithFallback{
OriginalError: origErr,
OrignalMethod: preferredMethod,
FallbackMethod: fallback,
}
} else {
log.Printf("Failed to send message using fallback method %s: %v", fallback, err)
}
}
return origErr
}
return nil
}
func InitCommunicator(database db.DB) *Communicator {
comm := &Communicator{
commMethods: make(map[string]model.CommMethod),
}
if emailHandler, err := email.NewHandler(); err == nil {
comm.commMethods["email"] = emailHandler
comm.fallbackCommunicators = append(comm.fallbackCommunicators, "email")
} else {
log.Printf("Failed to initialize email communicator: %v", err)
}
if gotifyHandler, err := gotify.NewClient(); err == nil {
comm.commMethods["gotify"] = gotifyHandler
comm.fallbackCommunicators = append(comm.fallbackCommunicators, "gotify")
} else {
log.Printf("Failed to initialize gotify communicator: %v", err)
}
if telegramHandler, err := telegram.NewClient(database); err == nil {
comm.commMethods["telegram"] = telegramHandler
comm.fallbackCommunicators = append(comm.fallbackCommunicators, "telegram")
} else {
log.Printf("Failed to initialize telegram communicator: %v", err)
}
return comm
}

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

@ -0,0 +1,140 @@
package comm
import (
"bytes"
"fmt"
"html/template"
"path"
"strings"
textTemplate "text/template"
"github.com/PuerkitoBio/goquery"
"github.com/eternal-flame-AD/yoake/internal/comm/model"
"github.com/eternal-flame-AD/yoake/internal/servetpl/funcmap"
"github.com/eternal-flame-AD/yoake/internal/util"
"github.com/gomarkdown/markdown"
)
func unique[T comparable](s []T) []T {
var result []T
for _, e := range s {
if !util.Contain(result, e) {
result = append(result, e)
}
}
return result
}
type ErrorMIMENoOverlap struct {
MessageMIME string
supportedMIME []string
}
func (e *ErrorMIMENoOverlap) Error() string {
return fmt.Sprintf("message MIME type %s is not supported by this communicator. Supported MIME types are: %s", e.MessageMIME, e.supportedMIME)
}
func ConvertOutMIMEToSupportedInMIME(outMIMEs []string) (inMIMEs []string) {
inMIMEs = outMIMEs
for _, out := range outMIMEs {
if out == "text/plain" {
inMIMEs = append(inMIMEs, "text/html", "text/markdown")
}
if out == "text/html" {
inMIMEs = append(inMIMEs, "text/markdown")
}
}
for _, in := range inMIMEs {
inMIMEs = append(inMIMEs, in+"+html/template", in+"+text/template")
}
inMIMEs = unique(inMIMEs)
return
}
func ConvertGenericMessage(msgOrig *model.GenericMessage, supportedMIMES []string) (*model.GenericMessage, error) {
if util.Contain(supportedMIMES, msgOrig.MIME) {
return msgOrig, nil
}
msg := *msgOrig
if strings.HasSuffix(msgOrig.MIME, "+html/template") ||
strings.HasSuffix(msgOrig.MIME, "+text/template") {
var output bytes.Buffer
var err error
var tplName string
if strings.HasSuffix(msgOrig.MIME, "+html/template") {
tpl := template.New("").Funcs(funcmap.GetFuncMap())
if strings.HasPrefix(msgOrig.Body, "@") {
tplFiles := strings.Split(msgOrig.Body[1:], ",")
tplName = path.Base(tplFiles[len(tplFiles)-1])
tpl, err = tpl.ParseFiles(tplFiles...)
if err != nil {
return nil, err
}
} else {
tpl, err = tpl.Parse(msgOrig.Body)
if err != nil {
return nil, err
}
}
if err := tpl.ExecuteTemplate(&output, tplName, msgOrig.Context); err != nil {
return nil, err
}
msg.MIME = strings.TrimSuffix(msgOrig.MIME, "+html/template")
} else {
tpl := textTemplate.New("").Funcs(funcmap.GetFuncMap())
if strings.HasPrefix(msgOrig.Body, "@") {
tplFiles := strings.Split(msgOrig.Body[1:], ",")
tplName = path.Base(tplFiles[len(tplFiles)-1])
tpl, err = tpl.ParseFiles(tplFiles...)
if err != nil {
return nil, err
}
} else {
tpl, err = tpl.Parse(msgOrig.Body)
if err != nil {
return nil, err
}
}
if err := tpl.ExecuteTemplate(&output, tplName, msgOrig.Context); err != nil {
return nil, err
}
msg.MIME = strings.TrimSuffix(msgOrig.MIME, "+text/template")
}
msg.Body = output.String()
}
if util.Contain(supportedMIMES, msg.MIME) {
return &msg, nil
}
// convert markdown to html
if msg.MIME == "text/markdown" && !util.Contain(supportedMIMES, "text/markdown") {
msg.Body = string(markdown.ToHTML([]byte(msg.Body), nil, nil))
msg.MIME = "text/html"
}
// convert html to text
if msg.MIME == "text/html" && !util.Contain(supportedMIMES, "text/html") && util.Contain(supportedMIMES, "text/plain") {
docBuf := strings.NewReader(msg.Body)
doc, err := goquery.NewDocumentFromReader(docBuf)
if err != nil {
return nil, err
}
msg.Body = doc.Text()
msg.MIME = "text/plain"
}
if msg.MIME == "text/plain" && !util.Contain(supportedMIMES, "text/plain") && util.Contain(supportedMIMES, "text/html") {
msg.Body = template.HTMLEscapeString(msg.Body)
if msg.Subject != "" {
msg.Body = fmt.Sprintf("<b>%s</b>%s", template.HTMLEscapeString(msg.Subject), template.HTMLEscapeString("\n---\n")) + msg.Body
}
msg.MIME = "text/html"
}
if !util.Contain(supportedMIMES, msg.MIME) {
return nil, &ErrorMIMENoOverlap{
MessageMIME: msg.MIME,
supportedMIME: supportedMIMES,
}
}
return &msg, nil
}

View file

@ -0,0 +1,80 @@
package email
import (
"fmt"
"strings"
"github.com/eternal-flame-AD/yoake/config"
"github.com/eternal-flame-AD/yoake/internal/comm/model"
"github.com/vanng822/go-premailer/premailer"
"gopkg.in/gomail.v2"
)
type Message struct {
MIME string
Subject string
Message string
To string
}
type Handler struct {
dialer *gomail.Dialer
}
func NewHandler() (*Handler, error) {
conf := config.Config().Comm.Email
if conf.SMTP.Host == "" || conf.SMTP.Port == 0 {
return nil, fmt.Errorf("invalid email configuration")
}
dialer := gomail.NewDialer(conf.SMTP.Host, conf.SMTP.Port, conf.SMTP.UserName, conf.SMTP.Password)
return &Handler{
dialer: dialer,
}, nil
}
func (h *Handler) SendGenericMessage(gmsg *model.GenericMessage) error {
msg := Message{
MIME: gmsg.MIME,
Subject: gmsg.Subject,
Message: gmsg.Body,
}
return h.SendEmail(msg)
}
func (h *Handler) SupportedMIME() []string {
return []string{"text/plain", "text/html"}
}
func (h *Handler) SendEmail(msg Message) error {
conf := config.Config().Comm.Email
if !strings.HasPrefix(msg.MIME, "text/html") &&
!strings.HasPrefix(msg.MIME, "text/plain") {
return fmt.Errorf("does not know how to send MIME type %s", msg.MIME)
}
if msg.MIME == "text/html" {
prem, err := premailer.NewPremailerFromString(msg.Message, premailer.NewOptions())
if err != nil {
return err
}
msg.Message, err = prem.Transform()
if err != nil {
return err
}
}
email := gomail.NewMessage()
email.SetHeader("From", conf.SMTP.From)
if msg.To != "" {
email.SetHeader("To", msg.To)
} else {
email.SetHeader("To", conf.SMTP.To)
}
if msg.Subject != "" {
email.SetHeader("Subject", msg.Subject)
} else {
email.SetHeader("Subject", conf.SMTP.DefaultSubject)
}
email.SetBody(msg.MIME, msg.Message, gomail.SetPartEncoding("base64"))
return h.dialer.DialAndSend(email)
}

View file

@ -0,0 +1,29 @@
package gotify
type Message struct {
Message string `json:"message"`
Title string `json:"title"`
Priority int `json:"priority"`
Extras struct {
ClientDisplay struct {
ContentType string `json:"contentType"`
} `json:"client::display,omitempty"`
ClientNotification struct {
Click struct {
URL string `json:"url"`
} `json:"click,omitempty"`
} `json:"client::notification,omitempty"`
} `json:"extras"`
}
type Health struct {
Database string `json:"database"`
Health string `json:"health"`
}
type Version struct {
BuildDate string `json:"buildDate"`
Commit string `json:"commit"`
Version string `json:"version"`
}

View file

@ -0,0 +1,104 @@
package gotify
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/eternal-flame-AD/yoake/config"
"github.com/eternal-flame-AD/yoake/internal/comm/model"
"github.com/eternal-flame-AD/yoake/internal/echoerror"
)
type Client struct {
conf *config.CommGotify
httpClient *http.Client
baseURL url.URL
serverVersion Version
}
func NewClient() (*Client, error) {
conf := config.Config().Comm.Gotify
baseURL, err := url.Parse(conf.BaseURL)
if err != nil {
return nil, err
}
httpClient := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := httpClient.Get(baseURL.ResolveReference(urlVersion).String())
if err != nil {
return nil, fmt.Errorf("failed to obtain gotify version: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("gotify returned with %d", resp.StatusCode)
}
var version Version
if err := json.NewDecoder(resp.Body).Decode(&version); err != nil {
return nil, fmt.Errorf("failed to decode gotify version: %w", err)
}
resp, err = httpClient.Get(baseURL.ResolveReference(urlHealth).String())
if err != nil {
return nil, fmt.Errorf("failed to obtain gotify health: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("gotify returned with %d", resp.StatusCode)
}
var health Health
if err := json.NewDecoder(resp.Body).Decode(&health); err != nil {
return nil, fmt.Errorf("failed to decode gotify health: %w", err)
}
return &Client{
conf: &conf,
httpClient: httpClient,
baseURL: *baseURL,
serverVersion: version,
}, nil
}
func (c *Client) SendGenericMessage(gmsg *model.GenericMessage) error {
msg := Message{
Message: gmsg.Body,
Title: gmsg.Subject,
Priority: gmsg.Priority + 5,
}
msg.Extras.ClientDisplay.ContentType = gmsg.MIME
return c.SendMessage(msg)
}
func (c *Client) SupportedMIME() []string {
return []string{"text/plain", "text/markdown"}
}
func (c *Client) SendMessage(msg Message) error {
reader, writer := io.Pipe()
e := json.NewEncoder(writer)
req, err := http.NewRequest("POST", c.baseURL.ResolveReference(urlMessage).String(), reader)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Gotify-Key", c.conf.AppToken)
go func() {
defer writer.Close()
e.Encode(msg)
}()
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return echoerror.NewHttp(resp.StatusCode, fmt.Errorf("gotify returned with %d", resp.StatusCode))
}
return nil
}

View file

@ -0,0 +1,17 @@
package gotify
import "net/url"
var (
urlMessage = urlMustParse("/message")
urlHealth = urlMustParse("/health")
urlVersion = urlMustParse("/version")
)
func urlMustParse(s string) *url.URL {
u, err := url.Parse(s)
if err != nil {
panic(err)
}
return u
}

View file

@ -0,0 +1,11 @@
package model
type GenericMessage struct {
Subject string `json:"subject" form:"subject" query:"subject"`
Body string `json:"body" form:"body" query:"body"`
MIME string `json:"mime" form:"mime" query:"mime"`
Priority int `json:"priority" form:"priority" query:"priority"`
ThreadID uint64 `json:"thread_id" form:"thread_id" query:"thread_id"`
Context interface{}
}

View file

@ -0,0 +1,20 @@
package model
import (
"github.com/labstack/echo/v4"
)
type CommMethod interface {
SupportedMIME() []string
SendGenericMessage(message *GenericMessage) error
}
type CommMethodWithRoute interface {
RegisterRoute(g *echo.Group)
}
type Communicator interface {
GetMethod(method string) CommMethod
GetMethodsByMIME(mime string) []CommMethod
SendGenericMessage(preferredMethod string, message *GenericMessage, force bool) error
}

View file

@ -0,0 +1,11 @@
package telegram
const banner = `
Welcome: {name}
夜明け前が一番暗い
The night is darkest just before the dawn.
yoake.yumechi.jp
`

View file

@ -0,0 +1,229 @@
package telegram
import (
"fmt"
"html/template"
"log"
"strconv"
"strings"
"github.com/eternal-flame-AD/yoake/config"
"github.com/eternal-flame-AD/yoake/internal/comm/model"
"github.com/eternal-flame-AD/yoake/internal/db"
"github.com/eternal-flame-AD/yoake/internal/servetpl/funcmap"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type Role int
const (
RoleAnonymous Role = 0
RoleFriend Role = 1
RoleOwner Role = 2
)
type CommandHandler func(bot *Bot, role Role, update tgbotapi.Update) error
type Bot struct {
client *tgbotapi.BotAPI
database db.DB
cmdHandlers map[string]CommandHandler
Name string `json:"name"`
LastUpdateID int `json:"last_update_id"`
OwnerChatID int64 `json:"owner_user_id"`
}
func (b *Bot) SupportedMIME() []string {
return []string{"text/markdown", "text/html"}
}
func (b *Bot) SendGenericMessage(message *model.GenericMessage) error {
if b.OwnerChatID == 0 {
return fmt.Errorf("owner chat id not set")
}
chattable := tgbotapi.NewMessage(b.OwnerChatID, message.Body)
switch message.MIME {
case "text/markdown":
chattable.ParseMode = tgbotapi.ModeMarkdownV2
case "text/html":
chattable.ParseMode = tgbotapi.ModeHTML
default:
return fmt.Errorf("unsupported MIME type %s", message.MIME)
}
if message.ThreadID != 0 {
chattable.ReplyToMessageID = int(message.ThreadID)
}
msg, err := b.client.Send(chattable)
if err != nil {
return err
}
if message.ThreadID == 0 {
message.ThreadID = uint64(msg.MessageID)
}
return nil
}
func (b *Bot) saveConf() error {
txn := b.database.NewTransaction(true)
defer txn.Discard()
if err := db.SetJSON(txn, []byte(fmt.Sprintf("comm_telegram_bot_%d", b.client.Self.ID)), b); err != nil {
return err
}
return txn.Commit()
}
func (b *Bot) Client() *tgbotapi.BotAPI {
return b.client
}
func (b *Bot) SendHTML(chatID int64, fmtStr string, args ...interface{}) error {
for i := range args {
switch v := args[i].(type) {
case string:
args[i] = template.HTMLEscapeString(args[i].(string))
default:
args[i] = v
}
}
msg := tgbotapi.NewMessage(chatID, fmt.Sprintf(fmtStr, args...))
msg.ParseMode = tgbotapi.ModeHTML
_, err := b.client.Send(msg)
return err
}
func (b *Bot) handleUpdate(update tgbotapi.Update) error {
role := RoleAnonymous
if msg := update.Message; msg != nil {
log.Printf("received message from %s: %v", update.Message.From.UserName, update.Message)
msg := *update.Message
conf := config.Config().Comm.Telegram
if strings.HasPrefix(conf.Owner, "@") && msg.From.UserName == conf.Owner[1:] {
if msg.Chat.ID != b.OwnerChatID {
log.Printf("telegram owner chat id set: %d", msg.Chat.ID)
b.OwnerChatID = msg.Chat.ID
}
} else if id, err := strconv.ParseInt(conf.Owner, 10, 64); err == nil && msg.From.ID == id || msg.Chat.ID == id {
if msg.Chat.ID != b.OwnerChatID {
log.Printf("telegram owner chat id set: %d", msg.Chat.ID)
b.OwnerChatID = msg.Chat.ID
}
}
if msg.Chat.ID == b.OwnerChatID {
role = RoleOwner
}
if msg.IsCommand() {
if handler, ok := b.cmdHandlers[msg.Command()]; ok {
return handler(b, role, update)
} else {
if err := b.SendHTML(msg.Chat.ID, "unknown command: %s\n", msg.Command()); err != nil {
return err
}
}
}
}
if callback := update.CallbackQuery; callback != nil {
cb := tgbotapi.NewCallback(callback.ID, "")
defer b.client.Send(cb)
if msg := callback.Message; msg != nil {
if callbackMsg := msg.ReplyToMessage; callbackMsg != nil && callbackMsg.IsCommand() {
if handler, ok := b.cmdHandlers[callbackMsg.Command()]; ok {
log.Printf("callback for command %s", callbackMsg.Command())
return handler(b, role, update)
} else {
if err := b.SendHTML(callback.Message.Chat.ID, "unknown command: %s\n", callback.Message.Command()); err != nil {
return err
}
}
}
}
}
return nil
}
func (bot *Bot) start() error {
u := tgbotapi.NewUpdate(bot.LastUpdateID + 1)
u.Timeout = 60
bot.RegisterCommand("start", "onboarding command", func(bot *Bot, role Role, upd tgbotapi.Update) error {
if msg := upd.Message; msg != nil {
if _, err := bot.client.Send(tgbotapi.NewMessage(msg.Chat.ID, strings.ReplaceAll(banner, "{name}", msg.From.FirstName+" "+msg.From.LastName))); err != nil {
return err
}
}
return nil
})
updates := bot.client.GetUpdatesChan(u)
go func() {
for update := range updates {
if err := bot.handleUpdate(update); err != nil {
fromChat := update.FromChat()
if stopImgURL, err := funcmap.TrimaImg("ja/btn_stop.gif", "url"); err != nil {
log.Printf("failed to get stop image: %v", err)
} else {
stopPhoto := tgbotapi.NewPhoto(fromChat.ID, tgbotapi.FileURL(stopImgURL))
stopPhoto.DisableNotification = true
bot.client.Send(stopPhoto)
}
if fromChat.ID != bot.OwnerChatID {
bot.SendHTML(fromChat.ID, "<b>Runtime Error</b>\n<pre>%s</pre>\nBot owner has been notified.", err)
}
bot.SendHTML(bot.OwnerChatID, "<b>Runtime Error</b>\noriginating chat ID: %d (@%s)\n\n<pre>%s</pre>", update.FromChat().ID, update.FromChat().UserName, err)
log.Printf("telegram runtime error: %v", err)
}
if update.UpdateID > bot.LastUpdateID {
bot.LastUpdateID = update.UpdateID
}
if err := bot.saveConf(); err != nil {
log.Printf("failed to save telegram bot config: %v", err)
}
}
}()
return nil
}
func loadConf(confName string, database db.DB) Bot {
txn := database.NewTransaction(false)
defer txn.Discard()
conf := Bot{LastUpdateID: -1}
if err := db.GetJSON(txn, []byte(confName), &conf); db.IsNotFound(err) {
log.Printf("telegram bot config %s not found, creating new one", confName)
txn.Discard()
txn = database.NewTransaction(true)
defer txn.Discard()
if err := db.SetJSON(txn, []byte(confName), conf); err != nil {
log.Fatalf("failed to create telegram bot config %s: %v", confName, err)
}
} else if err != nil {
log.Fatalf("failed to load telegram bot config %s: %v",
confName, err)
}
return conf
}
func NewClient(database db.DB) (*Bot, error) {
token := config.Config().Comm.Telegram.Token
if token == "" {
return nil, fmt.Errorf("telegram token not set")
}
client, err := tgbotapi.NewBotAPI(token)
if err != nil {
return nil, err
}
bot := loadConf(fmt.Sprintf("comm_telegram_bot_%d", client.Self.ID), database)
bot.client = client
bot.database = database
bot.cmdHandlers = make(map[string]CommandHandler)
return &bot, bot.start()
}

View file

@ -0,0 +1,33 @@
package telegram
import tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
func (b *Bot) RegisterCommand(cmd string, description string, handler CommandHandler) error {
existingCommand, err := b.client.GetMyCommands()
if err != nil {
return err
}
found := false
for i, command := range existingCommand {
if command.Command == cmd {
found = true
existingCommand[i].Description = description
}
}
if !found {
existingCommand = append(existingCommand, tgbotapi.BotCommand{
Command: cmd,
Description: description,
})
}
if _, err := b.client.Request(tgbotapi.NewSetMyCommands(existingCommand...)); err != nil {
return err
}
b.cmdHandlers[cmd] = handler
return nil
}

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

@ -0,0 +1,41 @@
package db
import "github.com/dgraph-io/badger/v3"
type BadgerDB struct {
db *badger.DB
}
func (b *BadgerDB) NewTransaction(readonly bool) DBTxn {
return &BadgerDBTxn{
txn: b.db.NewTransaction(readonly),
}
}
type BadgerDBTxn struct {
txn *badger.Txn
}
func (t *BadgerDBTxn) Set(key []byte, value []byte) error {
return t.txn.Set(key, value)
}
func (t *BadgerDBTxn) Delete(key []byte) error {
return t.txn.Delete(key)
}
func (t *BadgerDBTxn) Get(key []byte) ([]byte, error) {
item, err := t.txn.Get(key)
if err != nil {
return nil, err
}
return item.ValueCopy(nil)
}
func (t *BadgerDBTxn) Commit() error {
return t.txn.Commit()
}
func (t *BadgerDBTxn) Discard() {
t.txn.Discard()
}

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

@ -0,0 +1,49 @@
package db
import (
"encoding/json"
"errors"
badger "github.com/dgraph-io/badger/v3"
"github.com/eternal-flame-AD/yoake/config"
)
type DB interface {
NewTransaction(update bool) DBTxn
}
type DBTxn interface {
Set(key, value []byte) error
Delete(key []byte) error
Get(key []byte) ([]byte, error)
Commit() error
Discard()
}
func GetJSON(t DBTxn, key []byte, v interface{}) error {
if data, err := t.Get(key); err != nil {
return err
} else {
return json.Unmarshal(data, v)
}
}
func SetJSON(t DBTxn, key []byte, v interface{}) error {
if data, err := json.Marshal(v); err != nil {
return err
} else {
return t.Set(key, data)
}
}
func New(conf config.C) (DB, error) {
if conf.DB.Badger.Dir != "" {
opts := badger.DefaultOptions(conf.DB.Badger.Dir)
if db, err := badger.Open(opts); err != nil {
return nil, err
} else {
return &BadgerDB{db}, nil
}
}
return nil, errors.New("no database configured")
}

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

@ -0,0 +1,14 @@
package db
import (
"errors"
"github.com/dgraph-io/badger/v3"
)
func IsNotFound(err error) bool {
if errors.Is(err, badger.ErrKeyNotFound) {
return true
}
return false
}

View file

@ -0,0 +1,23 @@
package echoerror
type HTTPError interface {
error
Code() int
}
type httpError struct {
code int
err error
}
func (e *httpError) Error() string {
return e.err.Error()
}
func (e *httpError) Code() int {
return e.code
}
func NewHttp(code int, err error) HTTPError {
return &httpError{code, err}
}

View file

@ -0,0 +1,105 @@
package echoerror
import (
"encoding/json"
"fmt"
"html/template"
"net/http"
"runtime/debug"
"github.com/labstack/echo/v4"
)
var htmlErrorTemplate = template.Must(template.New("").Parse(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<h1>{{.Code}} {{.CodeText}}</h1>
<hr>
<p>Refer to this message: {{.Message}}</p>
`))
type htmlErrorTemplateCtx struct {
Code int
CodeText string
Message string
}
type ErrorWriter func(c echo.Context, err error) error
var (
JSONWriter = func(c echo.Context, err error) error {
if httpError, ok := err.(HTTPError); ok {
jsonStr, err := json.Marshal(map[string]interface{}{
"code": httpError.Code(),
"ok": false,
"message": httpError.Error(),
"error": err,
})
if err != nil {
jsonStr, err = json.Marshal(map[string]interface{}{
"code": httpError.Code(),
"ok": false,
"message": httpError.Error(),
"error": nil,
})
if err != nil {
return err
}
}
return c.JSONBlob(httpError.Code(), jsonStr)
}
return c.JSON(500, map[string]interface{}{
"code": 500,
"ok": false,
"message": err.Error(),
})
}
HTMLWriter = func(c echo.Context, err error) error {
errContext := htmlErrorTemplateCtx{
Code: 500,
Message: "Internal Server Error",
}
if httpError, ok := err.(HTTPError); ok {
errContext.Code = httpError.Code()
errContext.Message = httpError.Error()
} else {
errContext.Message = err.Error()
}
errContext.CodeText = http.StatusText(errContext.Code)
c.Response().Status = errContext.Code
c.Response().Header().Set(echo.HeaderContentType, echo.MIMETextHTMLCharsetUTF8)
return htmlErrorTemplate.Execute(c.Response().Writer, errContext)
}
)
func Middleware(errorWriter ErrorWriter) func(next echo.HandlerFunc) echo.HandlerFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
defer func() {
if err := recover(); err != nil {
if c.Get("devel") == true {
errorWriter(c, fmt.Errorf("panic: %v; stacktrace: %s", err, string(debug.Stack())))
} else {
errorWriter(c, fmt.Errorf("panic: %v", err))
}
}
}()
err := next(c)
if err != nil {
if errEcho, ok := err.(*echo.HTTPError); ok {
err = NewHttp(errEcho.Code, fmt.Errorf("%s", errEcho.Message))
}
if err := errorWriter(c, err); err != nil {
return err
}
}
return nil
}
}
}

View file

@ -0,0 +1,11 @@
package entertainment
import (
"github.com/eternal-flame-AD/yoake/internal/db"
"github.com/labstack/echo/v4"
)
func Register(g *echo.Group, database db.DB) {
youtube := g.Group("/youtube")
registerYoutube(youtube, database)
}

View file

@ -0,0 +1,10 @@
package entertainment
func contain[V comparable](slice []V, value V) bool {
for _, v := range slice {
if v == value {
return true
}
}
return false
}

View file

@ -0,0 +1,421 @@
package entertainment
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/eternal-flame-AD/yoake/internal/auth"
"github.com/eternal-flame-AD/yoake/internal/db"
"github.com/eternal-flame-AD/yoake/internal/echoerror"
"github.com/labstack/echo/v4"
)
type YoutubeVideoStore struct {
VideoID string `json:"video_id" form:"video_id" query:"video_id"`
Meta *YoutubeVideoEmbedMeta `json:"meta,omitempty"`
Tags []string `json:"tags"`
Category string `param:"category" json:"category" form:"category" query:"category"`
Comment string `json:"comment" form:"comment"`
}
type YoutubeVideoEmbedMeta struct {
Title string `json:"title"`
AuthorName string `json:"author_name"`
AuthorURL string `json:"author_url"`
Type string `json:"type"`
ProviderName string `json:"provider_name"`
ProviderURL string `json:"provider_url"`
ThumbnailURL string `json:"thumbnail_url"`
ThumbnailWidth int `json:"thumbnail_width"`
ThumbnailHeight int `json:"thumbnail_height"`
Html string `json:"html"`
Version string `json:"version"`
Height int `json:"height"`
Width int `json:"width"`
}
func GetYoutubeVideoInfo(videoUrl string) (info *YoutubeVideoEmbedMeta, err error) {
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Get("https://www.youtube.com/oembed?format=json&url=https://www.youtube.com/watch?v=" + url.QueryEscape(videoUrl))
if err != nil {
return nil, err
}
defer resp.Body.Close()
info = new(YoutubeVideoEmbedMeta)
err = json.NewDecoder(resp.Body).Decode(info)
if err != nil {
return nil, err
}
return info, nil
}
type YoutubeCategoryStore struct {
ID string `json:"id" form:"id"`
DisplayName string `json:"display_name" form:"display_name"`
}
type YoutubeTagStore struct {
ID string `json:"id" form:"id"`
DisplayName string `json:"display_name" form:"display_name"`
}
type YoutubeVideoDBTxn struct {
txn db.DBTxn
}
func newYoutubeDBTxn(txn db.DBTxn) *YoutubeVideoDBTxn {
return &YoutubeVideoDBTxn{
txn: txn,
}
}
func (t *YoutubeVideoDBTxn) GetCategories() (categories []YoutubeCategoryStore, err error) {
err = db.GetJSON(t.txn, []byte("youtube_categories"), &categories)
return
}
func (t *YoutubeVideoDBTxn) SetCategories(categories []YoutubeCategoryStore) (err error) {
return db.SetJSON(t.txn, []byte("youtube_categories"), categories)
}
func (t *YoutubeVideoDBTxn) DeleteCategory(category string) (err error) {
categories, err := t.GetCategories()
if err != nil {
return err
}
newCategories := make([]YoutubeCategoryStore, 0, len(categories))
for _, categoryS := range categories {
if categoryS.ID != category {
newCategories = append(newCategories, categoryS)
}
}
if err = t.SetCategories(newCategories); err != nil {
return err
}
if err := t.txn.Delete([]byte("youtube_category:" + category + "_tags")); err != nil && !db.IsNotFound(err) {
return err
}
if err := t.txn.Delete([]byte("youtube_category:" + category + "_videos")); err != nil && !db.IsNotFound(err) {
return err
}
return nil
}
func (t *YoutubeVideoDBTxn) GetTags(category string) (tags []YoutubeTagStore, err error) {
categories, err := t.GetCategories()
if err != nil {
return nil, err
}
for _, categoryS := range categories {
if categoryS.ID == category {
err = db.GetJSON(t.txn, []byte("youtube_category:"+category+"_tags"), &tags)
return
}
}
return nil, echoerror.NewHttp(404, fmt.Errorf("category not found"))
}
func (t *YoutubeVideoDBTxn) SetTags(category string, tags []YoutubeTagStore) (err error) {
categories, err := t.GetCategories()
if err != nil {
return err
}
for _, categoryS := range categories {
if categoryS.ID == category {
return db.SetJSON(t.txn, []byte("youtube_category:"+category+"_tags"), tags)
}
}
return echoerror.NewHttp(404, fmt.Errorf("category not found"))
}
func (t *YoutubeVideoDBTxn) GetVideos(category string, tags []string) (videos []YoutubeVideoStore, err error) {
videos = make([]YoutubeVideoStore, 0, 16)
categories, err := t.GetCategories()
if err != nil {
return nil, err
}
for _, categoryS := range categories {
if categoryS.ID == category {
tagsAvail, err := t.GetTags(category)
if err != nil {
return nil, err
}
var tagSelected []string
for _, tag := range tags {
for _, tagAvail := range tagsAvail {
if tagAvail.ID == tag {
tagSelected = append(tagSelected, tag)
break
}
}
}
var videosS []YoutubeVideoStore
if err = db.GetJSON(t.txn, []byte("youtube_category:"+category+"_videos"), &videosS); err != nil {
return nil, err
}
if len(tagSelected) == 0 {
return videosS, nil
}
for _, video := range videosS {
matchtag:
for _, tagA := range tagSelected {
for _, tag := range video.Tags {
if tagA == tag {
videos = append(videos, video)
break matchtag
}
}
}
}
}
}
return videos, nil
}
func (t *YoutubeVideoDBTxn) SetVideos(category string, videos []YoutubeVideoStore) (err error) {
categories, err := t.GetCategories()
if err != nil {
return err
}
for _, categoryS := range categories {
if categoryS.ID == category {
existingTags, err := t.GetTags(category)
if err != nil {
return err
}
tagsUsed := make(map[string]YoutubeTagStore)
for _, video := range videos {
for _, tag := range video.Tags {
found := false
for _, existingTag := range existingTags {
if existingTag.ID == tag {
tagsUsed[tag] = existingTag
found = true
break
}
}
if !found {
return echoerror.NewHttp(400, fmt.Errorf("tag %s not found", tag))
}
}
}
tagsUsedList := make([]YoutubeTagStore, 0, len(tagsUsed))
for _, tag := range tagsUsed {
tagsUsedList = append(tagsUsedList, tag)
}
if err = t.SetTags(category, tagsUsedList); err != nil {
return err
}
return db.SetJSON(t.txn, []byte("youtube_category:"+category+"_videos"), videos)
}
}
return echoerror.NewHttp(404, fmt.Errorf("category not found"))
}
func registerYoutube(g *echo.Group, database db.DB) {
g.GET("/categories", func(c echo.Context) error {
txn := newYoutubeDBTxn(database.NewTransaction(false))
defer txn.txn.Discard()
categories, err := txn.GetCategories()
if err != nil && !db.IsNotFound(err) {
return err
}
return c.JSON(http.StatusOK, categories)
})
g.GET("/category/:category/tags", func(c echo.Context) error {
txn := newYoutubeDBTxn(database.NewTransaction(false))
defer txn.txn.Discard()
tags, err := txn.GetTags(c.Param("category"))
if err != nil {
return err
}
return c.JSON(http.StatusOK, tags)
})
g.GET("/category/:category/videos", func(c echo.Context) error {
tags := strings.Split(c.QueryParam("tags"), ",")
txn := newYoutubeDBTxn(database.NewTransaction(false))
defer txn.txn.Discard()
videos, err := txn.GetVideos(c.Param("category"), tags)
if err != nil {
return err
}
return c.JSON(http.StatusOK, videos)
})
adminG := g.Group("", auth.RequireMiddleware(auth.RoleAdmin))
{
adminG.POST("/categories", func(c echo.Context) error {
var category YoutubeCategoryStore
if err := c.Bind(&category); err != nil {
return err
}
if category.DisplayName == "" {
return echoerror.NewHttp(400, fmt.Errorf("display name is required"))
}
if category.ID == "" {
category.ID = strings.ToLower(
regexp.MustCompile(`[^0-9a-zA-Z]`).ReplaceAllString(category.DisplayName, "-"))
}
txn := newYoutubeDBTxn(database.NewTransaction(true))
defer txn.txn.Discard()
updatedExisting := false
existingCategories, err := txn.GetCategories()
if err != nil {
if !db.IsNotFound(err) {
return err
}
} else {
for i, existingCategory := range existingCategories {
if existingCategory.ID == category.ID {
existingCategories[i].DisplayName = category.DisplayName
updatedExisting = true
}
}
}
if !updatedExisting {
existingCategories = append(existingCategories, category)
}
if err = txn.SetCategories(existingCategories); err != nil {
return err
}
if err := txn.txn.Commit(); err != nil {
return err
}
return c.JSON(http.StatusOK, category)
})
adminG.DELETE("/category/:category", func(c echo.Context) error {
txn := newYoutubeDBTxn(database.NewTransaction(true))
defer txn.txn.Discard()
if err := txn.DeleteCategory(c.Param("category")); err != nil {
return err
}
if err := txn.txn.Commit(); err != nil {
return err
}
return c.NoContent(http.StatusOK)
})
adminG.POST("/category/:category/tags", func(c echo.Context) error {
var tag YoutubeTagStore
if err := c.Bind(&tag); err != nil {
return err
}
if tag.DisplayName == "" {
return echoerror.NewHttp(400, fmt.Errorf("display name is required"))
}
if tag.ID == "" {
tag.ID = strings.ToLower(
regexp.MustCompile(`[^0-9a-zA-Z]`).ReplaceAllString(tag.DisplayName, "-"))
}
txn := newYoutubeDBTxn(database.NewTransaction(true))
defer txn.txn.Discard()
updatedExisting := false
existingTags, err := txn.GetTags(c.Param("category"))
if err != nil {
if !db.IsNotFound(err) {
return err
}
} else {
for i, existingTag := range existingTags {
if existingTag.ID == tag.ID {
existingTags[i].DisplayName = tag.DisplayName
updatedExisting = true
}
}
}
if !updatedExisting {
existingTags = append(existingTags, tag)
}
if err = txn.SetTags(c.Param("category"), existingTags); err != nil {
return err
}
if err := txn.txn.Commit(); err != nil {
return err
}
return c.JSON(http.StatusOK, tag)
})
adminG.POST("/category/:category/videos", func(c echo.Context) error {
var video YoutubeVideoStore
if err := c.Bind(&video); err != nil {
return err
}
meta, err := GetYoutubeVideoInfo(video.VideoID)
if err != nil {
return err
}
video.Meta = meta
txn := newYoutubeDBTxn(database.NewTransaction(true))
defer txn.txn.Discard()
videos, err := txn.GetVideos(c.Param("category"), nil)
updatedExisting := false
if err != nil {
if !db.IsNotFound(err) {
return err
}
} else {
for i, existingVideo := range videos {
if existingVideo.VideoID == video.VideoID {
videos[i].Tags = video.Tags
videos[i].Category = video.Category
videos[i].Meta = video.Meta
videos[i].Comment = video.Comment
updatedExisting = true
}
}
}
if !updatedExisting {
videos = append(videos, video)
}
if err := txn.SetVideos(c.Param("category"), videos); err != nil {
return err
}
if err := txn.txn.Commit(); err != nil {
return err
}
return c.JSON(http.StatusOK, video)
})
adminG.DELETE("/category/:category/video/:id", func(c echo.Context) error {
txn := newYoutubeDBTxn(database.NewTransaction(true))
defer txn.txn.Discard()
videos, err := txn.GetVideos(c.Param("category"), nil)
if err != nil {
return err
}
for i, video := range videos {
if video.VideoID == c.Param("id") {
videos = append(videos[:i], videos[i+1:]...)
}
}
if err := txn.SetVideos(c.Param("category"), videos); err != nil {
return err
}
if err := txn.txn.Commit(); err != nil {
return err
}
return c.NoContent(http.StatusOK)
})
}
}

16
internal/filestore/fs.go Normal file
View file

@ -0,0 +1,16 @@
package filestore
import "github.com/spf13/afero"
type FS interface {
afero.Fs
}
func NewFS(basepath string) FS {
bp := afero.NewBasePathFs(afero.NewOsFs(), basepath)
return bp
}
func ChrootFS(fs FS, chroot string) FS {
return afero.NewBasePathFs(fs, chroot)
}

View file

@ -0,0 +1 @@
WTFIsThis_0.0.0-20190101000000-000000000000

View file

@ -0,0 +1,19 @@
package gomod
import "text/template"
type goGetHtmlTemplateCtx struct {
ModulePath string
ProxyBase string
}
var goGetHtmlTemplate = template.Must(template.New("tpl").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="go-import" content="{{.ModulePath}} mod {{.ProxyBase}}">
</head>
<body>
go get {{.ModulePath}}
</body>
</html>`))

129
internal/gomod/resolve.go Normal file
View file

@ -0,0 +1,129 @@
package gomod
import (
"archive/zip"
"fmt"
"math/rand"
"net/url"
"path"
"strings"
"time"
"github.com/eternal-flame-AD/yoake/internal/echoerror"
"github.com/labstack/echo/v4"
"golang.org/x/mod/module"
)
type Info struct {
Version string // version string
Time time.Time // commit time
}
const backendProxy = "https://proxy.golang.org/"
func random143String() string {
randItems := []string{
"143",
"143.",
"4.3",
"143",
".143",
"1.43",
"1.4.3",
"14.3",
"omo",
"om.o",
"o.m.o",
"o.mo",
}
var ret strings.Builder
ret.WriteString("v1.4.3-")
for i := 0; i < 10; i++ {
rand.Intn(len(randItems))
ret.WriteString(randItems[rand.Intn(len(randItems))])
}
ret.WriteString("+incompatible")
return ret.String()
}
func resolveModule(c echo.Context, modPathUnesc string, pRequest string) error {
modPath, err := module.UnescapePath(modPathUnesc)
if err != nil {
return echoerror.NewHttp(400, fmt.Errorf("invalid module path: %w", err))
}
if !strings.HasPrefix(modPath[strings.IndexRune(modPath, '/')+1:], "test-") {
return echoerror.NewHttp(400, fmt.Errorf("please prefix your module path with 'test-'"))
}
if err != nil {
return echoerror.NewHttp(400, fmt.Errorf("invalid module path: %v", err))
}
if pRequest == "list" {
return c.String(143, random143String()+"\n")
}
ext := path.Ext(pRequest)
version := strings.TrimSuffix(pRequest, ext)
if err := module.Check(modPath, version); err != nil {
return echoerror.NewHttp(400, fmt.Errorf("invalid version: %v", err))
}
switch ext {
case ".info":
return c.JSON(143, Info{
Version: version,
Time: time.Now().Add(-time.Hour),
})
case ".mod":
return c.String(143, strings.Repeat("\n", 143-1)+"Welcome.to.white.space.\n")
case ".zip":
zipFile := zip.NewWriter(c.Response().Writer)
mainGo, err := zipFile.Create(fmt.Sprintf("%s@%s/main.go", modPath, version))
if err != nil {
zipFile.Close()
}
_, err = mainGo.Write([]byte(("package main\n\nimport \"fmt\"\n\nfunc main() {\n\n}\n")))
if err != nil {
zipFile.Close()
return echoerror.NewHttp(500, fmt.Errorf("failed to write main.go: %v", err))
}
c.Response().Status = 143
return zipFile.Close()
}
return c.Redirect(302, backendProxy+modPathUnesc+"/@v/"+pRequest)
}
func Register(baseURI string, baseG *echo.Group) (gogetMiddleware echo.MiddlewareFunc) {
baseG.GET("*", func(c echo.Context) error {
fullURI := c.Request().RequestURI
if !strings.HasPrefix(fullURI, baseURI) {
return echo.ErrNotFound
}
fullURI = strings.TrimPrefix(fullURI, baseURI)
fullURIUnEscaped, err := url.PathUnescape(fullURI)
if err != nil {
return echoerror.NewHttp(400, fmt.Errorf("invalid URI: %v", err))
}
if strings.Contains(fullURIUnEscaped, "/@v/") {
split := strings.SplitN(fullURIUnEscaped, "/@v/", 2)
return resolveModule(c, strings.TrimPrefix(split[0], "/"), split[1])
}
return echo.ErrNotFound
})
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if c.Request().Method == "GET" && c.QueryParam("go-get") == "1" {
unescapedPath, err := url.PathUnescape(c.Request().URL.Path)
if err != nil {
return echoerror.NewHttp(400, fmt.Errorf("invalid module path: %v", err))
}
ctx := goGetHtmlTemplateCtx{
ModulePath: c.Request().Host + unescapedPath,
ProxyBase: c.Scheme() + "://" + c.Request().Host + baseURI,
}
return goGetHtmlTemplate.Execute(c.Response().Writer, ctx)
}
return next(c)
}
}
}

327
internal/health/api.go Normal file
View file

@ -0,0 +1,327 @@
package health
import (
"encoding/json"
"fmt"
"html"
"log"
"strconv"
"strings"
"sync"
"time"
"github.com/eternal-flame-AD/yoake/internal/auth"
"github.com/eternal-flame-AD/yoake/internal/comm"
"github.com/eternal-flame-AD/yoake/internal/comm/model"
"github.com/eternal-flame-AD/yoake/internal/comm/telegram"
"github.com/eternal-flame-AD/yoake/internal/db"
"github.com/eternal-flame-AD/yoake/internal/util"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/labstack/echo/v4"
)
func telegramHandler(database db.DB) telegram.CommandHandler {
return func(bot *telegram.Bot, role telegram.Role, update tgbotapi.Update) error {
msg := update.Message
if callback := update.CallbackQuery; callback != nil {
msg = callback.Message.ReplyToMessage
switch msg.Command() {
case "medtake":
fields := strings.Fields(callback.Data)
if fields[0] == "" {
return nil
}
switch fields[0] {
case "medtake_confirm":
if msg := callback.Message; msg != nil {
if _, err := bot.Client().Send(tgbotapi.NewEditMessageReplyMarkup(msg.Chat.ID, msg.MessageID, tgbotapi.InlineKeyboardMarkup{
InlineKeyboard: [][]tgbotapi.InlineKeyboardButton{},
})); err != nil {
return err
}
}
return nil
case "medtake_undo":
uuid := fields[1]
delDose := ComplianceLog{
UUID: uuid,
Actual: ComplianceDoseInfo{
Time: time.Now(),
Dose: -1,
},
}
if err := DBMedComplianceLogSetOne(database, Direction{}, &delDose); err != nil {
return err
}
if err := bot.SendHTML(msg.Chat.ID, "Last action has been undone."); err != nil {
return err
}
if _, err := bot.Client().Request(tgbotapi.NewDeleteMessage(msg.Chat.ID, callback.Message.MessageID)); err != nil {
return err
}
return nil
}
}
return nil
} else if msg != nil {
if role != telegram.RoleOwner {
bot.SendHTML(msg.Chat.ID, "EPERM: You are not authorized to use this command.")
return nil
}
switch msg.Command() {
case "medtake":
argStr := strings.TrimSpace(msg.CommandArguments())
if len(argStr) == 0 || argStr == "help" {
return bot.SendHTML(msg.Chat.ID, html.EscapeString("Usage: /medtake <keyname> [dose] [YYYY-MM-DDZHH:mm:ss]"))
}
args := strings.Split(argStr, " ")
meds, err := DBMedListGet(database)
if err != nil {
return err
}
for _, med := range meds {
if med.KeyName() == strings.ToLower(args[0]) {
dose := med.Dosage
if len(args) > 1 {
dose, err = strconv.Atoi(args[1])
if err != nil {
return err
}
}
timestamp := time.Now()
if len(args) > 2 {
timestamp, err = time.Parse("2006-01-02Z15:04:05", args[2])
if err != nil {
return err
}
}
var log ComplianceLog
log.MedKeyname = med.KeyName()
log.Actual.Time = timestamp
log.Actual.Dose = dose
if err := DBMedComplianceLogSetOne(database, med, &log); err != nil {
return err
}
logJSON, err := json.MarshalIndent(log, "", " ")
if err != nil {
return err
}
message := tgbotapi.NewMessage(msg.Chat.ID, fmt.Sprintf("Medication %s taken. <pre>%s</pre>", med.Name, logJSON))
message.ChatID = msg.Chat.ID
message.ParseMode = tgbotapi.ModeHTML
message.ReplyToMessageID = msg.MessageID
message.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup([]tgbotapi.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("Undo", fmt.Sprintf("medtake_undo %s", log.UUID)),
tgbotapi.NewInlineKeyboardButtonData("Confirm", "medtake_confirm"),
})
_, err = bot.Client().Send(message)
return err
}
}
return bot.SendHTML(msg.Chat.ID, "keyname %s not found", args[0])
case "medinfo":
meds, err := DBMedListGet(database)
if err != nil {
return err
}
logs, err := DBMedComplianceLogGet(database, util.DateRangeAround(time.Now(), 1))
if err != nil {
return err
}
argStr := strings.TrimSpace(msg.CommandArguments())
if argStr == "help" {
bot.SendHTML(msg.Chat.ID, "Usage: /medinfo [[keyname...]|all]")
return nil
}
all := true
keynames := strings.Split(msg.CommandArguments(), " ")
var replies []strings.Builder
if argStr == "" || argStr == "all" {
all = argStr == "all"
replies = make([]strings.Builder, len(meds))
} else {
replies = make([]strings.Builder, len(keynames))
}
for i, med := range meds {
index := -1
if argStr != "" && argStr != "all" {
for j, keyname := range keynames {
if med.KeyName() == keyname {
index = j
break
}
}
if index == -1 {
continue
}
} else {
index = i
}
name, dir := med.ShortHand()
fmt.Fprintf(&replies[index], "<b>%s</b> <i>%s</i>\n", html.EscapeString(name), html.EscapeString(dir))
nextDose := logs.ProjectNextDose(med)
stateStr := "unknown"
if util.Contain(med.Flags, DirectionFlagPRN) && nextDose.DoseOffset >= 0 {
stateStr = "available"
} else if nextDose.DoseOffset > 0 {
stateStr = "DUE"
} else if util.Contain(med.Flags, DirectionFlagAdLib) {
stateStr = "available"
} else if nextDose.DoseOffset < 0 {
stateStr = "scheduled"
}
fmt.Fprintf(&replies[index], "Status: %s\n", stateStr)
fmt.Fprintf(&replies[index], "Offset: %+.2f period, expected %.2f hrs (%s)\n", nextDose.DoseOffset, float64(time.Until(nextDose.Expected.Time))/float64(time.Hour), nextDose.Expected.Time.Format("2006-01-02 15:04:05"))
if !all && nextDose.DoseOffset < 0 {
replies[index].Reset()
}
}
var out strings.Builder
for _, reply := range replies {
if reply.Len() > 0 {
out.WriteString(reply.String())
out.WriteString("\n")
}
}
return bot.SendHTML(msg.Chat.ID, out.String())
}
return nil
}
return nil
}
}
func Register(g *echo.Group, database db.DB, comm *comm.Communicator) {
megsG := g.Group("/meds")
{
shortHands := megsG.Group("/shorthand")
{
shortHands.GET("/parse", RESTParseShorthand())
shortHands.POST("/parse", RESTParseShorthand())
shortHands.POST("/format", RESTFormatShorthand())
}
writeMutex := new(sync.Mutex)
directions := megsG.Group("/directions", auth.RequireMiddleware(auth.RoleAdmin))
{
directions.GET("", RESTMedGetDirections(database))
directions.POST("", RESTMedPostDirections(database, writeMutex))
directions.DELETE("/:name", RESTMedDeleteDirections(database, writeMutex))
}
compliance := megsG.Group("/compliance", auth.RequireMiddleware(auth.RoleAdmin))
{
complianceByMed := compliance.Group("/med/:med")
{
complianceByMed.GET("/log", RESTComplianceLogGet(database))
complianceByMed.GET("/project", RESTComplianceLogProjectMed(database))
}
compliance.GET("/log", RESTComplianceLogGet(database))
compliance.POST("/log", RESTComplianceLogPost(database, writeMutex))
compliance.POST("/recalc", RESTRecalcMedComplianceLog(database, writeMutex))
}
}
if tgBot := comm.GetMethod("telegram"); tgBot != nil {
bot := tgBot.(*telegram.Bot)
handler := telegramHandler(database)
if err := bot.RegisterCommand("medtake", "take 1 med", handler); err != nil {
log.Printf("failed to register telegram command: %v", err)
}
if err := bot.RegisterCommand("medinfo", "current med info", handler); err != nil {
log.Printf("failed to register telegram command: %v", err)
}
}
go func() {
ticker := time.NewTicker(5 * time.Minute)
notified := make(map[string]time.Time)
for {
func() {
txn := database.NewTransaction(false)
defer txn.Discard()
existingNotified := make(map[string]time.Time)
err := db.GetJSON(txn, []byte("health_meds_compliance_notified_meds"), &existingNotified)
if err != nil && !db.IsNotFound(err) {
log.Println("Error getting notified meds: ", err)
return
}
for k, v := range existingNotified {
o := notified[k]
if o.Before(v) {
notified[k] = v
}
}
txn.Discard()
txn = database.NewTransaction(true)
err = db.SetJSON(txn, []byte("health_meds_compliance_notified_meds"), notified)
if err != nil {
log.Println("Error setting notified meds: ", err)
return
} else if err := txn.Commit(); err != nil {
log.Println("Error committing notified meds: ", err)
return
}
}()
meds, err := DBMedListGet(database)
if err != nil {
log.Println("Error getting med list:", err)
continue
}
logs, err := DBMedComplianceLogGet(database, util.DateRangeAround(time.Now(), 1))
if err != nil {
log.Println("Error getting med compliance log:", err)
continue
}
var notifications []CommCtx
hasNew := false
for _, med := range meds {
nextDose := logs.ProjectNextDose(med)
if nextDose.Expected.Time.Before(time.Now()) {
if lastNotified, ok := notified[med.KeyName()]; !ok ||
lastNotified.Before(nextDose.Expected.Time) ||
lastNotified.Add(4*time.Hour).Before(time.Now()) {
{
if !util.Contain(med.Flags, DirectionFlagPRN) {
hasNew = true
}
}
notifications = append(notifications, CommCtx{
Med: med,
Dose: nextDose,
})
}
}
}
if hasNew {
if err := comm.SendGenericMessage("gotify", &model.GenericMessage{
Subject: "Medications Due",
Body: commTemplate,
MIME: "text/markdown+html/template",
Context: notifications,
}, true); err != nil {
log.Println("Error sending med compliance notification:", err)
}
for _, n := range notifications {
notified[n.Med.KeyName()] = time.Now()
}
}
<-ticker.C
}
}()
}

View file

@ -0,0 +1,182 @@
package health
import (
"fmt"
"sync"
"time"
"github.com/eternal-flame-AD/yoake/internal/db"
"github.com/eternal-flame-AD/yoake/internal/echoerror"
"github.com/eternal-flame-AD/yoake/internal/util"
"github.com/labstack/echo/v4"
)
const yearAbsZero = 2000
func RESTComplianceLogGet(database db.DB) func(c echo.Context) error {
return func(c echo.Context) error {
filterKeyname := c.Param("med")
from := c.QueryParam("from")
to := c.QueryParam("to")
if to == "" {
to = time.Now().Format("2006-01-02")
}
if from == "" {
from = time.Now().AddDate(0, 0, -30).Format("2006-01-02")
}
fromTime, err := time.Parse("2006-01-02", from)
if err != nil {
return echoerror.NewHttp(400, err)
}
toTime, err := time.Parse("2006-01-02", to)
if err != nil {
return echoerror.NewHttp(400, err)
}
period := util.NewDateRange(fromTime, toTime)
if days := period.Days(); days > 180 {
return echoerror.NewHttp(400, fmt.Errorf("invalid date range: %v", period))
}
logs, err := DBMedComplianceLogGet(database, period)
if db.IsNotFound(err) || logs == nil {
return c.JSON(200, ComplianceLogList{})
} else if err != nil {
return echoerror.NewHttp(500, err)
}
if filterKeyname != "" {
filtered := make(ComplianceLogList, 0, len(logs))
for _, log := range logs {
if log.MedKeyname == filterKeyname {
filtered = append(filtered, log)
}
}
logs = filtered
}
return c.JSON(200, logs)
}
}
func RESTComplianceLogPost(db db.DB, writeMutex *sync.Mutex) echo.HandlerFunc {
return func(c echo.Context) error {
var input ComplianceLog
if err := c.Bind(&input); err != nil {
return echoerror.NewHttp(400, err)
}
if input.Actual.Time.IsZero() {
return echoerror.NewHttp(400, fmt.Errorf("invalid date"))
}
writeMutex.Lock()
defer writeMutex.Unlock()
meds, err := DBMedListGet(db)
if err != nil {
return echoerror.NewHttp(500, err)
}
var dir *Direction
for _, med := range meds {
d := med
if med.KeyName() == input.MedKeyname {
dir = &d
} else if med.Name == input.MedKeyname {
input.MedKeyname = med.KeyName()
dir = &d
}
}
if dir == nil {
return echoerror.NewHttp(404, fmt.Errorf("med not found"))
}
if err := DBMedComplianceLogSetOne(db, *dir, &input); err != nil {
return err
}
if input.Actual.Dose <= 0 {
return c.NoContent(204)
}
return c.JSON(200, input)
}
}
func RESTComplianceLogProjectMed(db db.DB) func(c echo.Context) error {
return func(c echo.Context) error {
keyName := c.Param("med")
meds, err := DBMedListGet(db)
if err != nil {
return echoerror.NewHttp(500, err)
}
var dir *Direction
for _, med := range meds {
if med.KeyName() == keyName {
d := med
dir = &d
}
}
if dir == nil {
return echoerror.NewHttp(404, fmt.Errorf("med not found"))
}
complianceLog, err := DBMedComplianceLogGet(db, util.DateRangeAround(time.Now(), 1))
if err != nil {
return echoerror.NewHttp(500, err)
}
return c.JSON(200, complianceLog.ProjectNextDose(*dir))
}
}
func RESTRecalcMedComplianceLog(db db.DB, writeMutex *sync.Mutex) func(c echo.Context) error {
return func(c echo.Context) error {
meds, err := DBMedListGet(db)
if err != nil {
return echoerror.NewHttp(500, err)
}
from := time.Date(yearAbsZero, 1, 1, 0, 0, 0, 0, time.UTC)
to := time.Now()
if fromStr := c.QueryParam("from"); fromStr != "" {
from, err = time.Parse("2006-01", fromStr)
if err != nil {
return echoerror.NewHttp(400, err)
}
}
if toStr := c.QueryParam("to"); toStr != "" {
to, err = time.Parse("2006-01", toStr)
if err != nil {
return echoerror.NewHttp(400, err)
}
}
writeMutex.Lock()
defer writeMutex.Unlock()
for year := from.Year(); year <= to.Year(); year++ {
for month := 1; month <= 12; month++ {
if year == from.Year() && month < int(from.Month()) {
continue
}
if year == to.Year() && month > int(to.Month()) {
continue
}
log, err := DBMedComplianceLogGet(db, util.DateRangeAround(time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC), 1))
if err != nil {
return echoerror.NewHttp(500, err)
}
if len(log) == 0 {
continue
}
for _, dir := range meds {
log.UpdateDoseOffset(dir)
}
if err := DBMedComplianceLogAppend(db, log); err != nil {
return echoerror.NewHttp(500, err)
}
}
}
return c.NoContent(204)
}
}

View file

@ -0,0 +1,92 @@
package health
import (
"fmt"
"net/http"
"strings"
"sync"
"github.com/eternal-flame-AD/yoake/internal/db"
"github.com/eternal-flame-AD/yoake/internal/echoerror"
"github.com/labstack/echo/v4"
)
func RESTMedGetDirections(db db.DB) func(c echo.Context) error {
return func(c echo.Context) error {
defer func() {
if err := recover(); err != nil {
c.Error(echoerror.NewHttp(500, fmt.Errorf("internal error: %v", err)))
}
}()
meds, err := DBMedListGet(db)
if err != nil {
return echoerror.NewHttp(500, err)
}
return c.JSON(200, meds)
}
}
func RESTMedPostDirections(db db.DB, writeMutex *sync.Mutex) func(c echo.Context) error {
return func(c echo.Context) error {
var input Direction
if err := c.Bind(&input); err != nil {
return echoerror.NewHttp(400, err)
}
if input.Name == "" {
return echoerror.NewHttp(400, fmt.Errorf("name cannot be empty"))
}
if input.Dosage <= 0 {
return echoerror.NewHttp(400, fmt.Errorf("dosage must be positive"))
}
if input.PeriodHours <= 0 {
return echoerror.NewHttp(400, fmt.Errorf("period must be positive"))
}
writeMutex.Lock()
defer writeMutex.Unlock()
meds, err := DBMedListGet(db)
if err != nil {
return echoerror.NewHttp(500, err)
}
found := false
for i, med := range meds {
if med.KeyName() == input.KeyName() {
meds[i] = input
found = true
}
}
if !found {
meds = append(meds, input)
}
if err := DBMedListSet(db, meds); err != nil {
return echoerror.NewHttp(500, err)
}
return c.JSON(200, meds)
}
}
func RESTMedDeleteDirections(db db.DB, writeMutex *sync.Mutex) func(c echo.Context) error {
return func(c echo.Context) error {
name := c.Param("name")
writeMutex.Lock()
defer writeMutex.Unlock()
meds, err := DBMedListGet(db)
if err != nil {
return echoerror.NewHttp(500, err)
}
found := false
for i, med := range meds {
if strings.EqualFold(med.KeyName(), name) {
meds = append(meds[:i], meds[i+1:]...)
found = true
break
}
}
if !found {
return echoerror.NewHttp(404, fmt.Errorf("med not found"))
}
if err := DBMedListSet(db, meds); err != nil {
return echoerror.NewHttp(500, err)
}
return c.NoContent(http.StatusNoContent)
}
}

View file

@ -0,0 +1,53 @@
package health
import (
"fmt"
"strings"
"github.com/eternal-flame-AD/yoake/internal/echoerror"
"github.com/labstack/echo/v4"
)
func RESTParseShorthand() func(c echo.Context) error {
return func(c echo.Context) error {
defer func() {
if err := recover(); err != nil {
c.Error(echoerror.NewHttp(500, fmt.Errorf("internal error: %v", err)))
}
}()
var inputStr string
if c.Request().Method == "GET" {
inputStr = c.QueryParam("shorthand")
} else if c.Request().Method == "POST" {
inputStr = c.FormValue("shorthand")
} else {
return echoerror.NewHttp(405, fmt.Errorf("unsupported method"))
}
inputStr = strings.TrimSpace(inputStr)
parsed, err := ParseShorthand(inputStr)
if err != nil {
return echoerror.NewHttp(400, err)
}
return c.JSON(200, parsed)
}
}
func RESTFormatShorthand() func(c echo.Context) error {
return func(c echo.Context) error {
defer func() {
if err := recover(); err != nil {
c.Error(echoerror.NewHttp(500, fmt.Errorf("internal error: %v", err)))
}
}()
var input Direction
if err := c.Bind(&input); err != nil {
return echoerror.NewHttp(400, err)
}
name, formatted := input.ShortHand()
return c.JSON(200, map[string]string{
"name": name,
"direction": formatted,
"__disclaimer": DirectionDisclaimer,
})
}
}

View file

@ -0,0 +1,164 @@
package health
import (
"encoding/json"
"math"
"sort"
"time"
"github.com/eternal-flame-AD/yoake/internal/util"
"github.com/google/uuid"
)
type ComplianceLog struct {
UUID string `json:"uuid,omitempty"`
MedKeyname string `json:"med_keyname"`
Expected ComplianceDoseInfo `json:"expected"`
Actual ComplianceDoseInfo `json:"actual"`
// 0 = closest to expected time +1 = closest to next expected dose
// get a cumsum of this to get a compliance stat
DoseOffset f64OrNan `json:"dose_offset"`
EffectiveLastDose *ComplianceLog `json:"effective_last_dose,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ComplianceDoseInfo struct {
Time time.Time `json:"time"`
Dose int `json:"dose"`
}
type f64OrNan float64
func (n *f64OrNan) UnmarshalJSON(b []byte) error {
if string(b) == "null" {
*n = f64OrNan(math.NaN())
return nil
}
var f float64
if err := json.Unmarshal(b, &f); err != nil {
return err
}
*n = f64OrNan(f)
return nil
}
func (n f64OrNan) MarshalJSON() ([]byte, error) {
if math.IsNaN(float64(n)) {
return []byte("null"), nil
}
return json.Marshal(float64(n))
}
func doseOffset(dir Direction, this ComplianceLog, last ComplianceLog) float64 {
if last.UUID == "" {
return math.NaN()
}
offset := float64(this.Actual.Time.Sub(last.Actual.Time))/
float64(time.Duration(dir.PeriodHours)*time.Hour) - 1
// for prn ignore positive offsets
if util.Contain(dir.Flags, DirectionFlagPRN) {
if offset > 0 {
return 0
}
}
// ad lib ignore negative offsets
if util.Contain(dir.Flags, DirectionFlagAdLib) {
if offset < 0 {
return 0
}
}
return offset
}
type ComplianceLogList []ComplianceLog
func (c ComplianceLogList) findEffectiveLastDose(dir Direction, this ComplianceLog) ComplianceLog {
// for ad lib directions, this finds the last dose
// for default scheduling, this find the earliest dose that does not cumulatively exceed a whole dose
var lastDose ComplianceLog
var cumDosage int
for ptr := 0; ptr < len(c); ptr++ {
if c[ptr].MedKeyname == dir.KeyName() && c[ptr].Actual.Time.Before(this.Actual.Time) {
if dir.OptSchedule == OptScheduleWholeDose {
return c[ptr]
}
cumDosage += c[ptr].Actual.Dose
if cumDosage > dir.Dosage {
return lastDose
} else if cumDosage == dir.Dosage {
return c[ptr]
}
lastDose = c[ptr]
}
}
return lastDose
}
func (c ComplianceLogList) ProjectNextDose(dir Direction) (nextDose ComplianceLog) {
tmpUUID := uuid.New().String()
nextDose = ComplianceLog{
UUID: tmpUUID,
MedKeyname: dir.KeyName(),
Expected: ComplianceDoseInfo{
Time: time.Now(),
Dose: dir.Dosage,
},
Actual: ComplianceDoseInfo{
Time: time.Now(),
Dose: dir.Dosage,
},
DoseOffset: 0,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
lastDose := c.findEffectiveLastDose(dir, nextDose)
if lastDose.UUID == "" /* not found */ {
return
}
nextDose.EffectiveLastDose = &lastDose
nextDose.Expected.Time = lastDose.Actual.Time.Add(time.Duration(dir.PeriodHours) * time.Hour)
nextDose.DoseOffset = f64OrNan(doseOffset(dir, nextDose, lastDose))
return
}
func (c ComplianceLogList) UpdateDoseOffset(dir Direction) {
sort.Sort(c)
for i := range c {
if c[i].MedKeyname == dir.KeyName() {
lastDose, thisDose := c.findEffectiveLastDose(dir, c[i]), c[i]
if lastDose.UUID == "" /* not found */ {
return
}
c[i].DoseOffset = f64OrNan(doseOffset(dir, thisDose, lastDose))
}
}
}
func (c ComplianceLogList) Len() int {
return len(c)
}
func (c ComplianceLogList) Less(i, j int) bool {
return c[i].Actual.Time.After(c[j].Actual.Time)
}
func (c ComplianceLogList) Swap(i, j int) {
c[i], c[j] = c[j], c[i]
}

208
internal/health/db.go Normal file
View file

@ -0,0 +1,208 @@
package health
import (
"errors"
"sort"
"time"
"github.com/eternal-flame-AD/yoake/internal/db"
"github.com/eternal-flame-AD/yoake/internal/echoerror"
"github.com/eternal-flame-AD/yoake/internal/util"
"github.com/google/uuid"
)
func DBMedListGet(database db.DB) ([]Direction, error) {
txn := database.NewTransaction(false)
defer txn.Discard()
var meds []Direction
if err := db.GetJSON(txn, []byte("health_meds_list"), &meds); db.IsNotFound(err) {
err = DBMedListSet(database, []Direction{})
return meds, err
} else if err != nil {
return nil, err
}
return meds, nil
}
func DBMedListSet(database db.DB, meds []Direction) error {
txn := database.NewTransaction(true)
defer txn.Discard()
for i, med := range meds {
_, meds[i].DirectionShorthand = med.ShortHand()
}
if err := db.SetJSON(txn, []byte("health_meds_list"), meds); err != nil {
return err
}
return txn.Commit()
}
const dbMedComplianceLogPrefix = "health_meds_compliance_log_"
func DBMedComplianceLogGet(database db.DB, dates util.DateRange) (ComplianceLogList, error) {
txn := database.NewTransaction(false)
defer txn.Discard()
endIndex := dates.To.UTC().Format("2006-01")
indexesToFetch := []string{endIndex}
for indexNow := dates.From.UTC().AddDate(0, -1, 0); indexNow.Before(dates.To.AddDate(0, 1, 0)); indexNow = indexNow.AddDate(0, 1, 0) {
indexesToFetch = append(indexesToFetch, indexNow.Format("2006-01"))
}
indexesToFetch = util.Unique(indexesToFetch)
sort.Strings(indexesToFetch)
var res ComplianceLogList
for _, index := range indexesToFetch {
var log []ComplianceLog
if err := db.GetJSON(txn, []byte(dbMedComplianceLogPrefix+index), &log); db.IsNotFound(err) {
continue
} else if err != nil {
return nil, err
}
res = append(res, log...)
}
sort.Sort(res)
return res, nil
}
func DBMedComplianceLogAppend(database db.DB, pending ComplianceLogList) error {
txn := database.NewTransaction(true)
defer txn.Discard()
for len(pending) > 0 {
index := pending[0].Actual.Time.UTC().Format("2006-01")
var origLogs ComplianceLogList
if err := db.GetJSON(txn, []byte(dbMedComplianceLogPrefix+index), &origLogs); db.IsNotFound(err) {
origLogs = []ComplianceLog{}
} else if err != nil {
return err
}
for i := len(pending) - 1; i >= 0; i-- {
if pending[i].Actual.Time.UTC().Format("2006-01") != index {
continue
}
origLogs = append(origLogs, pending[i])
pending = append(pending[:i], pending[i+1:]...)
uuidMap := make(map[string]int)
for j := len(origLogs) - 1; j >= 0; j-- {
if _, ok := uuidMap[origLogs[j].UUID]; ok {
origLogs = append(origLogs[:j], origLogs[j+1:]...)
} else {
uuidMap[origLogs[j].UUID] = j
}
}
}
sort.Sort(origLogs)
if err := db.SetJSON(txn, []byte(dbMedComplianceLogPrefix+index), origLogs); err != nil {
return err
}
}
return txn.Commit()
}
func DBMedComplianceLogSetOne(database db.DB, dir Direction, log *ComplianceLog) error {
index := log.Actual.Time.UTC().Format("2006-01")
existingLogs, err := DBMedComplianceLogGet(database, util.DateRangeAround(log.Actual.Time, 1))
if err != nil {
return err
}
txn := database.NewTransaction(true)
defer txn.Discard()
del := false
if log.Actual.Dose == 0 {
return echoerror.NewHttp(400, errors.New("dose cannot be zero"))
} else if log.Actual.Dose < 0 {
del = true
}
if log.UUID != "" {
foundIdx := -1
for i, existingLog := range existingLogs {
if existingLog.UUID == log.UUID {
log.UpdatedAt = time.Now()
foundIdx = i
break
}
}
if foundIdx < 0 {
return echoerror.NewHttp(404, errors.New("log with specified UUID not found"))
}
origLog := existingLogs[foundIdx]
log.CreatedAt = origLog.CreatedAt
origLogIdx := origLog.Actual.Time.UTC().Format("2006-01")
if origLogIdx == index {
// update and return
if del {
existingLogs = append(existingLogs[:foundIdx], existingLogs[foundIdx+1:]...)
} else {
log.UpdatedAt = time.Now()
existingLogs[foundIdx] = *log
}
sort.Sort(existingLogs)
if err := db.SetJSON(txn, []byte(dbMedComplianceLogPrefix+index), existingLogs); err != nil {
return err
}
return txn.Commit()
} else {
// delete from old index
existingLogs = append(existingLogs[:foundIdx], existingLogs[foundIdx+1:]...)
if err := db.SetJSON(txn, []byte(dbMedComplianceLogPrefix+origLogIdx), existingLogs); err != nil {
return err
}
}
}
if del {
return txn.Commit()
}
if log.UUID == "" {
log.UUID = uuid.New().String()
log.CreatedAt = time.Now()
if log.Expected.Dose == 0 {
nextDose := existingLogs.ProjectNextDose(dir)
log.Expected.Time = nextDose.Expected.Time
log.Expected.Dose = nextDose.Expected.Dose
}
}
log.UpdatedAt = time.Now()
var logs ComplianceLogList
if err := db.GetJSON(txn, []byte(dbMedComplianceLogPrefix+index), &logs); db.IsNotFound(err) {
logs = []ComplianceLog{*log}
} else if err != nil {
return err
} else {
logs = append(logs, *log)
}
logs.UpdateDoseOffset(dir)
uuid := log.UUID
sort.Sort(ComplianceLogList(logs))
if err := db.SetJSON(txn, []byte(dbMedComplianceLogPrefix+index), logs); err != nil {
return err
}
for i, l := range logs {
if l.UUID == uuid {
*log = logs[i]
}
}
return txn.Commit()
}

244
internal/health/parser.go Normal file
View file

@ -0,0 +1,244 @@
package health
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/eternal-flame-AD/yoake/internal/util"
)
type Direction struct {
Name string `json:"name"`
PeriodHours int `json:"period_hours"`
Dosage int `json:"dosage"`
DosageUnit string `json:"dosage_unit"`
DosageRoute string `json:"dosage_route"`
Flags []DirectionFlag `json:"flags"`
DirectionShorthand string `json:"shorthand"`
OptSchedule OptSchedule `json:"schedule"`
Disclaimer string `json:"__disclaimer"`
}
const DirectionDisclaimer = "For personal use only. No warranty of accuracy."
type DirectionFlag string
type OptSchedule string
const (
DirectionFlagAM DirectionFlag = "qam"
DirectionFlagHS DirectionFlag = "qhs"
DirectionFlagPRN DirectionFlag = "prn"
DirectionFlagAdLib DirectionFlag = "ad lib"
OptScheduleDefault OptSchedule = "default"
OptScheduleWholeDose OptSchedule = "whole"
)
func ParseShorthand(shorthand string) (*Direction, error) {
res := new(Direction)
res.Disclaimer = DirectionDisclaimer
res.Flags = make([]DirectionFlag, 0)
words := strings.Split(shorthand, " ")
optionsRegex := regexp.MustCompile(`^([a-zA-Z]+)\((\w+)\)$`)
for i := len(words) - 1; i >= 0; i-- {
if match := optionsRegex.FindStringSubmatch(words[i]); match != nil {
name, value := match[1], match[2]
switch strings.ToLower(name) {
case "sched":
fallthrough
case "schedule":
if res.OptSchedule != "" {
return nil, fmt.Errorf("duplicate schedule option")
}
switch strings.ToLower(value) {
case "default":
fallthrough
case "":
res.OptSchedule = OptScheduleDefault
case "whole":
res.OptSchedule = OptScheduleWholeDose
default:
return nil, fmt.Errorf("invalid schedule option: %s", value)
}
default:
return nil, fmt.Errorf("unknown option %s", name)
}
words = words[:i]
}
}
if res.OptSchedule == "" {
res.OptSchedule = OptScheduleDefault
}
// combined numbers and units
for i := range words {
digits := regexp.MustCompile(`^\d+$`)
if digits.MatchString(words[i]) {
words[i] = words[i] + words[i+1]
words[i+1] = ""
}
if strings.ToLower(words[i]) == "ad" && strings.ToLower(words[i+1]) == "lib" {
res.Flags = append(res.Flags, DirectionFlagAdLib)
words[i] = ""
words[i+1] = ""
} else if strings.ToLower(words[i]) == "adlib" {
res.Flags = append(res.Flags, DirectionFlagAdLib)
words[i] = ""
}
}
words = util.AntiJoin(words, []string{""})
// find prn keyword
for i := len(words) - 1; i >= 0; i-- {
if strings.EqualFold(words[i], "prn") {
if util.Contain(res.Flags, DirectionFlagAdLib) {
return nil, fmt.Errorf("cannot use 'ad lib' and 'prn' together")
}
res.Flags = append(res.Flags, DirectionFlagPRN)
words = append(words[:i], words[i+1:]...)
break
}
}
freqIdx := len(words) - 1
if lastWord := strings.ToLower(words[len(words)-1]); lastWord == "bid" {
res.PeriodHours = 12
words = words[:len(words)-1]
} else if lastWord == "tid" {
res.PeriodHours = 8
words = words[:len(words)-1]
} else if lastWord == "qid" {
res.PeriodHours = 6
words = words[:len(words)-1]
} else {
for i := len(words) - 1; i >= 0; i-- {
if strings.HasPrefix(strings.ToLower(words[i]), "q") {
freqIdx = i
break
}
}
freqStr := strings.ToLower(strings.Join(words[freqIdx:], ""))[1:]
if freqStr == "am" {
res.Flags = append(res.Flags, DirectionFlagAM)
res.PeriodHours = 24
} else if freqStr == "hs" {
res.Flags = append(res.Flags, DirectionFlagHS)
res.PeriodHours = 24
} else {
if !(freqStr[0] >= '0' && freqStr[0] <= '9') {
freqStr = "1" + freqStr
}
freqRegexp := regexp.MustCompile(`^([0-9]+)([a-z]+)$`)
freqMatch := freqRegexp.FindStringSubmatch(freqStr)
if freqMatch == nil {
return nil, fmt.Errorf("invalid frequency: %s", freqStr)
}
freq, err := strconv.Atoi(freqMatch[1])
if err != nil {
return nil, fmt.Errorf("invalid frequency number : %s", freqMatch[1])
}
if freqMatch[2] == "d" {
res.PeriodHours = freq * 24
} else if freqMatch[2] == "h" {
res.PeriodHours = freq
} else {
return nil, fmt.Errorf("invalid frequency unit: %s", freqMatch[2])
}
}
}
words = words[:freqIdx]
dosageRegexp := regexp.MustCompile(`^([0-9]+)([a-z]*)$`)
var dosageMatch []string
if dosageMatch = dosageRegexp.FindStringSubmatch(words[len(words)-1]); dosageMatch == nil {
if dosageMatch = dosageRegexp.FindStringSubmatch(words[len(words)-2]); dosageMatch == nil {
return nil, fmt.Errorf("invalid dosage: %s", words[len(words)-2:])
} else {
res.DosageRoute = words[len(words)-1]
words = words[:len(words)-2]
}
} else {
words = words[:len(words)-1]
}
dosage, err := strconv.Atoi(dosageMatch[1])
if err != nil {
return nil, fmt.Errorf("invalid dosage number: %s", dosageMatch[1])
}
res.Dosage = dosage
res.DosageUnit = dosageMatch[2]
res.Name = strings.Join(words, " ")
s1, s2 := res.ShortHand()
res.DirectionShorthand = s1 + " " + s2
return res, nil
}
func (d Direction) KeyName() string {
return strings.ToLower(strings.SplitN(d.Name, " ", 2)[0])
}
func (d *Direction) ShortHand() (name string, direction string) {
builder := new(strings.Builder)
builder.WriteString(strconv.Itoa(d.Dosage))
if d.DosageUnit != "" {
builder.WriteString(" ")
builder.WriteString(d.DosageUnit)
}
if d.DosageRoute != "" {
builder.WriteString(" ")
builder.WriteString(d.DosageRoute)
}
if d.PeriodHours%24 == 0 {
qNd := d.PeriodHours / 24
qNdS := strconv.Itoa(qNd)
if qNd == 1 {
if util.Contain(d.Flags, DirectionFlagAM) {
qNdS = "AM"
} else if util.Contain(d.Flags, DirectionFlagHS) {
qNdS = "HS"
} else {
qNdS = "d"
}
} else {
qNdS += "d"
}
fmt.Fprintf(builder, " q%s", qNdS)
} else if d.PeriodHours == 12 {
builder.WriteString(" bid")
} else if d.PeriodHours == 8 {
builder.WriteString(" tid")
} else if d.PeriodHours == 6 {
builder.WriteString(" qid")
} else {
fmt.Fprintf(builder, " q%sh", strconv.Itoa(d.PeriodHours))
}
if util.Contain(d.Flags, DirectionFlagPRN) {
builder.WriteString(" PRN")
} else if util.Contain(d.Flags, DirectionFlagAdLib) {
builder.WriteString(" ad lib")
}
if d.OptSchedule != "" && d.OptSchedule != OptScheduleDefault {
if d.OptSchedule == OptScheduleWholeDose {
builder.WriteString(" sched(whole)")
}
}
return d.Name, builder.String()
}
type Dose struct {
Time time.Time
Dose int
}

View file

@ -0,0 +1,84 @@
package health
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestShortHandParser(t *testing.T) {
cases := [][]interface{}{
{"Atorvastatin 10mg QD", &Direction{
Name: "Atorvastatin",
PeriodHours: 24,
Dosage: 10,
DosageUnit: "mg",
Flags: make([]DirectionFlag, 0),
OptSchedule: OptScheduleDefault,
Disclaimer: DirectionDisclaimer,
}},
{"Atorvastatin 10mg TAB 10mg PO bid", &Direction{
Name: "Atorvastatin 10mg TAB",
PeriodHours: 12,
Dosage: 10,
DosageUnit: "mg",
DosageRoute: "PO",
Flags: make([]DirectionFlag, 0),
OptSchedule: OptScheduleDefault,
Disclaimer: DirectionDisclaimer,
}},
{"metformin 500mg qHS", &Direction{
Name: "metformin",
PeriodHours: 24,
Dosage: 500,
DosageUnit: "mg",
Flags: []DirectionFlag{DirectionFlagHS},
OptSchedule: OptScheduleDefault,
Disclaimer: DirectionDisclaimer,
}},
{"Amphetamine 10mg qam", &Direction{
Name: "Amphetamine",
PeriodHours: 24,
Dosage: 10,
DosageUnit: "mg",
Flags: []DirectionFlag{DirectionFlagAM},
OptSchedule: OptScheduleDefault,
Disclaimer: DirectionDisclaimer,
}},
{"Something 10mg tid ad lib", &Direction{
Name: "Something",
PeriodHours: 8,
Dosage: 10,
DosageUnit: "mg",
Flags: []DirectionFlag{DirectionFlagAdLib},
OptSchedule: OptScheduleDefault,
Disclaimer: DirectionDisclaimer,
}},
{"Hydroxyzine 50mg qid prn sched(whole)", &Direction{
Name: "Hydroxyzine",
PeriodHours: 6,
Dosage: 50,
DosageUnit: "mg",
Flags: []DirectionFlag{DirectionFlagPRN},
OptSchedule: OptScheduleWholeDose,
Disclaimer: DirectionDisclaimer,
}}}
for _, c := range cases {
input, expected := c[0].(string), c[1].(*Direction)
actual, err := ParseShorthand(input)
if expected == nil {
assert.Error(t, err)
} else {
assert.NoError(t, err)
expected.DirectionShorthand = actual.DirectionShorthand
assert.Equal(t, expected, actual)
name, encoded := actual.ShortHand()
assert.Equal(t, expected.Name, name)
encodedDecoded, err := ParseShorthand(name + " " + encoded)
assert.NoError(t, err)
assert.Equal(t, expected, encodedDecoded)
}
}
}

View file

@ -0,0 +1,33 @@
package health
const commTemplate = `
The following doses are due:
{{ range . }}
---
### {{ .Med.Name }} _{{ .Med.DirectionShorthand }}_
{{ $isPRN := false -}}
{{- range .Med.Flags -}}
{{- if eq . "prn" -}}
{{- $isPRN = true -}}
{{end}}{{- end -}}
{{ if not $isPRN -}}
Expected at: {{ .Dose.Expected.Time }}
{{if .Dose.EffectiveLastDose -}}
Last Taken at: {{ .Dose.EffectiveLastDose.Actual.Time }}
{{ end -}}
Offset: {{ .Dose.DoseOffset }}
{{ else -}}
avail as PRN
{{- end }}
{{ end }}
`
type CommCtx struct {
Med Direction
Dose ComplianceLog
}

View file

@ -0,0 +1,15 @@
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
}
}

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,19 @@
package funcmap
import (
"bytes"
"encoding/xml"
"net/url"
)
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
}
func EscapeQuery(s string) string {
return url.QueryEscape(s)
}

View file

@ -0,0 +1,7 @@
package funcmap
import "net/url"
func FileAccess(path string) string {
return "https://yoake.yumechi.jp/file_access.html?path=" + url.QueryEscape(path)
}

View file

@ -0,0 +1,113 @@
package funcmap
import (
"fmt"
"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]
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,
"stop": Stop,
"trima_img": TrimaImg,
"parse_json": ParseJSON,
"json": MarshalJSON,
"get_auth": AuthGet,
"sprintf": func(format string, input ...interface{}) interface{} {
return fmt.Sprintf(format, input...)
},
"http": HttpRequest,
"version": Version,
"find_word": FindWord,
"contain": Contain,
"query": EscapeQuery,
"theme_color": ThemeColor,
}
}

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,52 @@
package funcmap
import (
"io"
"net/http"
"github.com/PuerkitoBio/goquery"
)
const (
ResponseTypeHTML = "html"
ResponseTypeStrippedHTML = "html_stripped"
ResponseTypeText = "text"
)
func HttpRequest(method string, URL string, selector string, responseType string) (data interface{}, err error) {
if method == "" {
method = http.MethodGet
}
if responseType == "" {
responseType = ResponseTypeHTML
}
req, err := http.NewRequest(method, URL, nil)
if err != nil {
return nil, err
}
client := http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if responseType == ResponseTypeHTML || responseType == ResponseTypeStrippedHTML {
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil, err
}
result := doc.Contents()
if selector != "" {
result = result.Find(selector)
}
if responseType == ResponseTypeStrippedHTML {
return result.Text(), nil
}
return result.Html()
}
response, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return string(response), nil
}

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,254 @@
package funcmap
var nipponColors = map[string]string{
"nadeshiko": "#DC9F84", "撫子": "#DC9F84",
"kohbai": "#E16B8C", "紅梅": "#E16B8C",
"suoh": "#8E354A", "蘇芳": "#8E354A",
"taikoh": "#F8C3CD", "退紅": "#F8C3CD",
"ikkonzome": "#F4A7B9", "一斥染": "#F4A7B9",
"kuwazome": "#64363c", "桑染": "#64363c",
"momo": "#F596AA", "桃": "#F596AA",
"ichigo": "#B5495B", "莓": "#B5495B",
"usubeni": "#E87A90", "薄紅": "#E87A90",
"imayoh": "#D05A6E", "今様": "#D05A6E",
"nakabeni": "#DB4D6D", "中紅": "#DB4D6D",
"sakura": "#FEDFE1", "桜": "#FEDFE1",
"umenezumi": "#9E7A7A", "梅鼠": "#9E7A7A",
"karakurenai": "#D0104C", "韓紅花": "#D0104C",
"enji": "#9F353A", "燕脂": "#9F353A",
"kurenai": "#CB1B45", "紅": "#CB1B45",
"toki": "#EEA9A9", "鴇": "#EEA9A9",
"cyohsyun": "#BF6766", "長春": "#BF6766",
"kokiake": "#86473F", "深緋": "#86473F",
"sakuranezumi": "#B19693", "桜鼠": "#B19693",
"jinzamomi": "#EB7A77", "甚三紅": "#EB7A77",
"azuki": "#954A45", "小豆": "#954A45",
"suohkoh": "#A96360", "蘇芳香": "#A96360",
"akabeni": "#CB4042", "赤紅": "#CB4042",
"shinsyu": "#AB3B3A", "真朱": "#AB3B3A",
"haizakura": "#D7C4BB", "灰桜": "#D7C4BB",
"kuriume": "#904840", "栗梅": "#904840",
"ebicha": "#734338", "海老茶": "#734338",
"ginsyu": "#C73E3A", "銀朱": "#C73E3A",
"kurotobi": "#554236", "黒鳶": "#554236",
"benitobi": "#994639", "紅鳶": "#994639",
"akebono": "#F19483", "曙": "#F19483",
"benikaba": "#B54434", "紅樺": "#B54434",
"mizugaki": "#B9887D", "水がき": "#B9887D",
"sangosyu": "#F17C67", "珊瑚朱": "#F17C67",
"benihiwada": "#884C3A", "紅檜皮": "#884C3A",
"syojyohi": "#E83015", "猩猩緋": "#E83015",
"entan": "#D75455", "鉛丹": "#D75455",
"shikancha": "#B55D4C", "芝翫茶": "#B55D4C",
"hiwada": "#854836", "檜皮": "#854836",
"kakishibu": "#A35E47", "柿渋": "#A35E47",
"ake": "#CC543A", "緋": "#CC543A",
"tobi": "#724832", "鳶": "#724832",
"benihi": "#F75C2F", "紅緋": "#F75C2F",
"kurikawacha": "#6A4028", "栗皮茶": "#6A4028",
"bengara": "#9A5034", "弁柄": "#9A5034",
"terigaki": "#C46243", "照柿": "#C46243",
"edocha": "#AF5F3C", "江戸茶": "#AF5F3C",
"araisyu": "#FB966E", "洗朱": "#FB966E",
"momoshiocha": "#724938", "百塩茶": "#724938",
"karacha": "#B47157", "唐茶": "#B47157",
"tokigaracha": "#DB8E71", "ときがら茶": "#DB8E71",
"ohni": "#F05E1C", "黄丹": "#F05E1C",
"sohi": "#ED784A", "纁": "#ED784A",
"ensyucha": "#CA7853", "遠州茶": "#CA7853",
"kabacha": "#B35C37", "樺茶": "#B35C37",
"kogecha": "#563F2E", "焦茶": "#563F2E",
"akakoh": "#E3916E", "赤香": "#E3916E",
"suzumecha": "#8F5A3C", "雀茶": "#8F5A3C",
"shishi": "#F0A986", "宍": "#F0A986",
"sodenkaracha": "#A0674B", "宗伝唐茶": "#A0674B",
"kaba": "#c1693c", "樺": "#c1693c",
"kokikuchinashi": "#FB9966", "深支子": "#FB9966",
"kurumi": "#947A6D", "胡桃": "#947A6D",
"taisya": "#A36336", "代赭": "#A36336",
"araigaki": "#E79460", "洗柿": "#E79460",
"kohrozen": "#7D532C", "黄櫨染": "#7D532C",
"akakuchiba": "#C78550", "赤朽葉": "#C78550",
"tonocha": "#985F2A", "礪茶": "#985F2A",
"akashiritsurubami": "#E1A679", "赤白橡": "#E1A679",
"sencha": "#855B32", "煎茶": "#855B32",
"kanzo": "#FC9F4D", "萱草": "#FC9F4D",
"sharegaki": "#FFBA84", "洒落柿": "#FFBA84",
"beniukon": "#E98B2A", "紅鬱金": "#E98B2A",
"umezome": "#E9A368", "梅染": "#E9A368",
"biwacha": "#B17844", "枇杷茶": "#B17844",
"chojicha": "#96632E", "丁子茶": "#96632E",
"kenpohzome": "#43341B", "憲法染": "#43341B",
"kohaku": "#CA7A2C", "琥珀": "#CA7A2C",
"usugaki": "#ECB88A", "薄柿": "#ECB88A",
"kyara": "#78552B", "伽羅": "#78552B",
"chojizome": "#B07736", "丁子染": "#B07736",
"fushizome": "#967249", "柴染": "#967249",
"kuchiba": "#E2943B", "朽葉": "#E2943B",
"kincha": "#C7802D", "金茶": "#C7802D",
"kitsune": "#9B6E23", "狐": "#9B6E23",
"susutake": "#6E552F", "煤竹": "#6E552F",
"usukoh": "#EBB471", "薄香": "#EBB471",
"tonoko": "#D7B98E", "砥粉": "#D7B98E",
"ginsusutake": "#82663A", "銀煤竹": "#82663A",
"ohdo": "#B68E55", "黄土": "#B68E55",
"shiracha": "#BC9F77", "白茶": "#BC9F77",
"kobicha": "#876633", "媚茶": "#876633",
"kigaracha": "#C18A26", "黄唐茶": "#C18A26",
"yamabuki": "#FFB11B", "山吹": "#FFB11B",
"yamabukicha": "#D19826", "山吹茶": "#D19826",
"hajizome": "#DDA52D", "櫨染": "#DDA52D",
"kuwacha": "#C99833", "桑茶": "#C99833",
"tamago": "#F9BF45", "玉子": "#F9BF45",
"shirotsurubami": "#DCB879", "白橡": "#DCB879",
"kitsurubami": "#BA9132", "黄橡": "#BA9132",
"tamamorokoshi": "#E8B647", "玉蜀黍": "#E8B647",
"hanaba": "#F7C242", "花葉": "#F7C242",
"namakabe": "#7D6C46", "生壁": "#7D6C46",
"torinoko": "#DAC9A6", "鳥の子": "#DAC9A6",
"usuki": "#FAD689", "浅黄": "#FAD689",
"kikuchiba": "#D9AB42", "黄朽葉": "#D9AB42",
"kuchinashi": "#F6C555", "梔子": "#F6C555",
"tohoh": "#FFC408", "籐黄": "#FFC408",
"ukon": "#EFBB24", "鬱金": "#EFBB24",
"karashi": "#CAAD5F", "芥子": "#CAAD5F",
"higosusutake": "#8D742A", "肥後煤竹": "#8D742A",
"rikyushiracha": "#B4A582", "利休白茶": "#B4A582",
"aku": "#877F6C", "灰汁": "#877F6C",
"rikyucha": "#897D55", "利休茶": "#897D55",
"rokohcha": "#74673E", "路考茶": "#74673E",
"nataneyu": "#A28C37", "菜種油": "#A28C37",
"uguisucha": "#6C6024", "鶯茶": "#6C6024",
"kimirucha": "#867835", "黄海松茶": "#867835",
"mirucha": "#62592C", "海松茶": "#62592C",
"kariyasu": "#E9CD4C", "刈安": "#E9CD4C",
"nanohana": "#F7D94C", "菜の花": "#F7D94C",
"kihada": "#FBE251", "黄蘗": "#FBE251",
"mushikuri": "#D9CD90", "蒸栗": "#D9CD90",
"aokuchiba": "#ADA142", "青朽葉": "#ADA142",
"ominaeshi": "#DDD23B", "女郎花": "#DDD23B",
"hiwacha": "#A5A051", "鶸茶": "#A5A051",
"hiwa": "#BEC23F", "鶸": "#BEC23F",
"uguisu": "#6C6A2D", "鶯": "#6C6A2D",
"yanagicha": "#939650", "柳茶": "#939650",
"koke": "#838A2D", "苔": "#838A2D",
"kikujin": "#B1B479", "麹塵": "#B1B479",
"rikancha": "#616138", "璃寛茶": "#616138",
"aikobicha": "#4B4E2A", "藍媚茶": "#4B4E2A",
"miru": "#5B622E", "海松": "#5B622E",
"sensaicha": "#4D5139", "千歳茶": "#4D5139",
"baikocha": "#89916B", "梅幸茶": "#89916B",
"hiwamoegi": "#90B44B", "鶸萌黄": "#90B44B",
"yanagizome": "#91AD70", "柳染": "#91AD70",
"urayanagi": "#B5CAA0", "裏柳": "#B5CAA0",
"iwaicha": "#646A58", "岩井茶": "#646A58",
"moegi": "#7BA23F", "萌黄": "#7BA23F",
"nae": "#86C166", "苗": "#86C166",
"yanagisusutake": "#4A593D", "柳煤竹": "#4A593D",
"matsuba": "#42602D", "松葉": "#42602D",
"aoni": "#516E41", "青丹": "#516E41",
"usuao": "#91B493", "薄青": "#91B493",
"yanaginezumi": "#808F7C", "柳鼠": "#808F7C",
"tokiwa": "#1B813E", "常磐": "#1B813E",
"wakatake": "#5DAC81", "若竹": "#5DAC81",
"chitosemidori": "#36563C", "千歳緑": "#36563C",
"midori": "#227D51", "緑": "#227D51",
"byakuroku": "#A8D8B9", "白緑": "#A8D8B9",
"oitake": "#6A8372", "老竹": "#6A8372",
"tokusa": "#2D6D4B", "木賊": "#2D6D4B",
"onandocha": "#465D4C", "御納戸茶": "#465D4C",
"rokusyoh": "#24936E", "緑青": "#24936E",
"sabiseiji": "#86A697", "錆青磁": "#86A697",
"aotake": "#00896C", "青竹": "#00896C",
"veludo": "#096148", "ビロード": "#096148",
"mushiao": "#20604F", "虫襖": "#20604F",
"aimirucha": "#0F4C3A", "藍海松茶": "#0F4C3A",
"tonocha2": "#4F726C", "沈香茶": "#4F726C",
"aomidori": "#00AA90", "青緑": "#00AA90",
"seiji": "#69B0AC", "青磁": "#69B0AC",
"tetsu": "#26453D", "鉄": "#26453D",
"mizuasagi": "#66BAB7", "水浅葱": "#66BAB7",
"seiheki": "#268785", "青碧": "#268785",
"sabitetsuonando": "#405B55", "錆鉄御納戸": "#405B55",
"korainando": "#305A56", "高麗納戸": "#305A56",
"byakugun": "#78C2C4", "白群": "#78C2C4",
"omeshicha": "#376B6D", "御召茶": "#376B6D",
"kamenozoki": "#A5DEE4", "瓶覗": "#A5DEE4",
"fukagawanezumi": "#77969A", "深川鼠": "#77969A",
"sabiasagi": "#6699A1", "錆浅葱": "#6699A1",
"mizu": "#81C7D4", "水": "#81C7D4",
"asagi": "#33A6B8", "浅葱": "#33A6B8",
"onando": "#0C4842", "御納戸": "#0C4842",
"ai": "#0D5661", "藍": "#0D5661",
"shinbashi": "#0089A7", "新橋": "#0089A7",
"sabionando": "#336774", "錆御納戸": "#336774",
"tetsuonando": "#255359", "鉄御納戸": "#255359",
"hanaasagi": "#1E88A8", "花浅葱": "#1E88A8",
"ainezumi": "#566C73", "藍鼠": "#566C73",
"masuhana": "#577C8A", "舛花": "#577C8A",
"sora": "#58B2DC", "空": "#58B2DC",
"noshimehana": "#2B5F75", "熨斗目花": "#2B5F75",
"chigusa": "#3A8FB7", "千草": "#3A8FB7",
"omeshionando": "#2E5C6E", "御召御納戸": "#2E5C6E",
"hanada": "#006284", "縹": "#006284",
"wasurenagusa": "#7DB9DE", "勿忘草": "#7DB9DE",
"gunjyo": "#51A8DD", "群青": "#51A8DD",
"tsuyukusa": "#2EA9DF", "露草": "#2EA9DF",
"kurotsurubami": "#0B1013", "黒橡": "#0B1013",
"kon": "#0F2540", "紺": "#0F2540",
"kachi": "#08192D", "褐": "#08192D",
"ruri": "#005CAF", "瑠璃": "#005CAF",
"rurikon": "#0B346E", "瑠璃紺": "#0B346E",
"benimidori": "#7B90D2", "紅碧": "#7B90D2",
"fujinezumi": "#6E75A4", "藤鼠": "#6E75A4",
"tetsukon": "#261E47", "鉄紺": "#261E47",
"konjyo": "#113285", "紺青": "#113285",
"benikakehana": "#4E4F97", "紅掛花": "#4E4F97",
"konkikyo": "#211E55", "紺桔梗": "#211E55",
"fuji": "#8B81C3", "藤": "#8B81C3",
"futaai": "#70649A", "二藍": "#70649A",
"ouchi": "#9B90C2", "楝": "#9B90C2",
"fujimurasaki": "#8A6BBE", "藤紫": "#8A6BBE",
"kikyo": "#6A4C9C", "桔梗": "#6A4C9C",
"shion": "#8F77B5", "紫苑": "#8F77B5",
"messhi": "#533D5B", "滅紫": "#533D5B",
"usu": "#B28FCE", "薄": "#B28FCE",
"hashita": "#986DB2", "半": "#986DB2",
"edomurasaki": "#77428D", "江戸紫": "#77428D",
"shikon": "#3C2F41", "紫紺": "#3C2F41",
"kokimurasaki": "#4A225D", "深紫": "#4A225D",
"sumire": "#66327C", "菫": "#66327C",
"murasaki": "#592C63", "紫": "#592C63",
"ayame": "#6F3381", "菖蒲": "#6F3381",
"fujisusutake": "#574C57", "藤煤竹": "#574C57",
"benifuji": "#B481BB", "紅藤": "#B481BB",
"kurobeni": "#3F2B36", "黒紅": "#3F2B36",
"nasukon": "#572A3F", "茄子紺": "#572A3F",
"budohnezumi": "#5E3D50", "葡萄鼠": "#5E3D50",
"hatobanezumi": "#72636E", "鳩羽鼠": "#72636E",
"kakitsubata": "#622954", "杜若": "#622954",
"ebizome": "#6D2E5B", "蒲葡": "#6D2E5B",
"botan": "#C1328E", "牡丹": "#C1328E",
"umemurasaki": "#A8497A", "梅紫": "#A8497A",
"nisemurasaki": "#562E37", "似紫": "#562E37",
"tsutsuji": "#E03C8A", "躑躅": "#E03C8A",
"murasakitobi": "#60373E", "紫鳶": "#60373E",
"shironeri": "#FCFAF2", "白練": "#FCFAF2",
"gofun": "#FFFFFB", "胡粉": "#FFFFFB",
"shironezumi": "#BDC0BA", "白鼠": "#BDC0BA",
"ginnezumi": "#91989F", "銀鼠": "#91989F",
"namari": "#787878", "鉛": "#787878",
"hai": "#828282", "灰": "#828282",
"sunezumi": "#787D7B", "素鼠": "#787D7B",
"rikyunezumi": "#707C74", "利休鼠": "#707C74",
"nibi": "#656765", "鈍": "#656765",
"aonibi": "#535953", "青鈍": "#535953",
"dobunezumi": "#4F4F48", "溝鼠": "#4F4F48",
"benikeshinezumi": "#52433D", "紅消鼠": "#52433D",
"aisumicha": "#373C38", "藍墨茶": "#373C38",
"binrojizome": "#3A3226", "檳榔子染": "#3A3226",
"keshizumi": "#434343", "消炭": "#434343",
"sumi": "#1C1C1C", "墨": "#1C1C1C",
"kuro": "#080808", "黒": "#080808",
"ro": "#0C0C0C", "呂": "#0C0C0C",
}

View file

@ -0,0 +1,41 @@
package funcmap
import (
"fmt"
"reflect"
"regexp"
"strings"
)
func FindWord(s string, target ...string) bool {
s = strings.ToLower(s)
fields := strings.Fields(s)
nonChar := regexp.MustCompile("[^a-z]")
for i := range fields {
fields[i] = nonChar.ReplaceAllString(fields[i], "")
}
s = " " + strings.Join(fields, " ") + " "
for _, t := range target {
if strings.Contains(s, " "+t+" ") {
return true
}
}
return false
}
func Contain(slice, target reflect.Value) (bool, error) {
if slice.Kind() != reflect.Slice {
return false, fmt.Errorf("Contain: slice is not a slice")
}
if slice.Type().Elem() != target.Type() {
return false, fmt.Errorf("Contain: slice and target are not the same type")
}
for i := 0; i < slice.Len(); i++ {
if slice.Index(i).Interface() == target.Interface() {
return true, nil
}
}
return false, nil
}

View file

@ -0,0 +1,10 @@
package funcmap
import "fmt"
func ThemeColor(key string) (string, error) {
if c, ok := nipponColors[key]; ok {
return c, nil
}
return "", fmt.Errorf("no such color %s", key)
}

View file

@ -0,0 +1,46 @@
package funcmap
import (
"encoding/base64"
"fmt"
"io"
"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 io.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,15 @@
package funcmap
import "github.com/eternal-flame-AD/yoake/internal/version"
type V struct {
Version string
Date string
}
func Version() (*V, error) {
return &V{
Version: version.Version,
Date: version.Date,
}, nil
}

View file

@ -0,0 +1,177 @@
package servetpl
import (
"bytes"
"errors"
"fmt"
"html/template"
"io"
"log"
"mime"
"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" {
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 {
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)
ext := path.Ext(p)
c.Response().Header().Set(echo.HeaderContentType, mime.TypeByExtension(ext))
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,39 @@
package session
import (
"log"
"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 name, s := range checkedOutSessions {
if err := s.Save(c.Request(), c.Response()); err != nil {
log.Printf("error saving session %s: %v", name, err)
}
}
}
}

262
internal/twilio/api.go Normal file
View file

@ -0,0 +1,262 @@
package twilio
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/eternal-flame-AD/yoake/config"
"github.com/eternal-flame-AD/yoake/internal/comm/model"
"github.com/eternal-flame-AD/yoake/internal/comm/telegram"
"github.com/eternal-flame-AD/yoake/internal/filestore"
"github.com/eternal-flame-AD/yoake/internal/servetpl/funcmap"
"github.com/labstack/echo/v4"
"github.com/spf13/afero"
"github.com/twilio/twilio-go"
openapi "github.com/twilio/twilio-go/rest/api/v2010"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
const rfc2822 = "Mon, 02 Jan 2006 15:04:05 -0700"
type RecordingStatusCallbackForm struct {
AccountSid string `form:"AccountSid" json:"AccountSid"`
CallSid string `form:"CallSid" json:"CallSid"`
RecordingSid string `form:"RecordingSid" json:"RecordingSid"`
RecordingUrl string `form:"RecordingUrl" json:"RecordingUrl"`
RecordingStatus string `form:"RecordingStatus" json:"RecordingStatus"`
RecodgingDuration int `form:"RecordingDuration" json:"RecordingDuration"`
RecordingChannels int `form:"RecordingChannels" json:"RecordingChannels"`
RecodgingStartTime string `form:"RecordingStartTime" json:"RecordingStartTime"`
RecordingSource string `form:"RecordingSource" json:"RecordingSource"`
RecordingTrack string `form:"RecordingTrack" json:"RecordingTrack"`
}
type CallStatusCallbackForm struct {
CallSid string `form:"CallSid" json:"CallSid"`
AccountSid string `form:"AccountSid" json:"AccountSid"`
From string `form:"From" json:"From"`
To string `form:"To" json:"To"`
CallStatus string `form:"CallStatus" json:"CallStatus"`
ApiVersion string `form:"ApiVersion" json:"ApiVersion"`
Direction string `form:"Direction" json:"Direction"`
ForwardedFrom string `form:"ForwardedFrom" json:"ForwardedFrom"`
CallerName string `form:"CallerName" json:"CallerName"`
ParentCallSid string `form:"ParentCallSid" json:"ParentCallSid"`
}
type CallStatusCallbackProgressForm struct {
CallStatusCallbackForm
// https://www.twilio.com/docs/voice/api/call-resource#statuscallbackevent
CallStatus string `form:"CallStatus" json:"CallStatus"`
Duration int `form:"Duration" json:"Duration"`
CallDuration int `form:"CallDuration" json:"CallDuration"`
SipResponseCode int `form:"SipResponseCode" json:"SipResponseCode"`
RecordingUrl string `form:"RecordingUrl" json:"RecordingUrl"`
RecordingSid string `form:"RecordingSid" json:"RecordingSid"`
RecordingDuration int `form:"RecordingDuration" json:"RecordingDuration"`
TimeStamp string `form:"TimeStamp" json:"TimeStamp"`
CallbackSource string `form:"CallbackSource" json:"CallbackSource"`
SequenceNumber int `form:"SequenceNumber" json:"SequenceNumber"`
}
func findCallDir(callsDir filestore.FS, callSid string, from string, to string) (filestore.FS, string, error) {
if callSid == "" {
return nil, "", fmt.Errorf("callSid is empty")
}
dirs, err := afero.ReadDir(callsDir, ".")
if err != nil {
return nil, "", err
}
for _, dir := range dirs {
if dir.IsDir() {
if strings.HasSuffix(dir.Name(), callSid) {
return filestore.ChrootFS(callsDir, dir.Name()), dir.Name(), nil
}
}
}
now := time.Now()
newName := fmt.Sprintf("%s_%s_%s_%s", now.Format("2006-01-02T15.04.05"), from, to, callSid)
if err := callsDir.Mkdir(newName, 0770); err != nil {
return nil, "", err
}
return filestore.ChrootFS(callsDir, newName), newName, nil
}
func fetchRecording(apiClient *twilio.RestClient, callDir filestore.FS, sid string, recType string) error {
prm := new(openapi.ListRecordingParams)
prm.SetCallSid(sid)
recordings, err := apiClient.Api.ListRecording(prm)
if err != nil {
return err
}
if err := callDir.Mkdir("recordings", 0770); err != nil && !os.IsExist(err) {
return err
}
recDir := filestore.ChrootFS(callDir, "recordings")
wg := new(sync.WaitGroup)
for i, recording := range recordings {
wg.Add(1)
go func(i int, recording openapi.ApiV2010Recording) {
defer wg.Done()
rSid := recording.Sid
if rSid == nil {
rSidS := fmt.Sprintf("unknown-%d-%d", time.Now().Unix(), i)
rSid = &rSidS
}
jsonF, err := recDir.OpenFile(recType+"_"+*recording.Sid+".json", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0660)
if err != nil {
log.Printf("failed to open json file for recording %s: %v", *rSid, err)
return
}
defer jsonF.Close()
enc := json.NewEncoder(jsonF)
enc.SetIndent("", " ")
if err := enc.Encode(recording); err != nil {
log.Printf("failed to write json file for recording %s: %v", *rSid, err)
return
}
mediaResp, err := http.Get(*recording.MediaUrl + ".wav?requestedChannels=2")
if err != nil {
log.Printf("failed to download media file for recording %s: %v", *rSid, err)
return
}
defer mediaResp.Body.Close()
if mediaResp.StatusCode != http.StatusOK {
log.Printf("failed to download media file for recording %s: http status %d", *rSid, mediaResp.StatusCode)
}
mediaF, err := recDir.OpenFile(recType+"_"+*recording.Sid+".wav", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0660)
if err != nil {
log.Printf("failed to open media file for recording %s: %v", *rSid, err)
return
}
defer mediaF.Close()
if _, err := io.Copy(mediaF, mediaResp.Body); err != nil {
log.Printf("failed to write media file for recording %s: %v", *rSid, err)
return
}
if err := apiClient.Api.DeleteRecording(*rSid, new(openapi.DeleteRecordingParams)); err != nil {
log.Printf("failed to delete recording %s: %v", *rSid, err)
}
}(i, recording)
}
wg.Wait()
return nil
}
func Register(g *echo.Group, fs filestore.FS, comm model.Communicator) {
apiClient := twilio.NewRestClientWithParams(twilio.ClientParams{
AccountSid: config.Config().Twilio.AccountSid,
Username: config.Config().Twilio.AccountSid,
Password: config.Config().Twilio.AuthToken,
})
if err := fs.Mkdir("calls", 0770); err != nil && !os.IsExist(err) {
log.Panicf("failed to create calls directory: %v", err)
}
calls := filestore.ChrootFS(fs, "calls")
tg, hasTelegram := comm.GetMethod("telegram").(*telegram.Bot)
process := g.Group("/process", VerifyMiddleware("", config.Config().Twilio.BaseURL))
{
process.POST("/voicemail/:type", func(c echo.Context) error {
stateForm := new(CallStatusCallbackForm)
err := c.Bind(stateForm)
if err != nil {
return err
}
thenUrl := c.QueryParam("then")
typeStr := c.Param("type")
switch typeStr {
case "message":
err = tg.SendHTML(tg.OwnerChatID, "New voicemail request\n\ncallSid: %s\nFrom: %s\n", stateForm.CallSid, stateForm.From)
case "callback":
err = tg.SendHTML(tg.OwnerChatID, "New callback request\n\ncallSid: %s\nFrom: %s\n", stateForm.CallSid, stateForm.From)
default:
return c.String(http.StatusBadRequest, "invalid type")
}
if err != nil {
return fmt.Errorf("failed to send message: %v", err)
}
return c.Redirect(http.StatusTemporaryRedirect, thenUrl)
})
process.POST("/incoming_owner", func(c echo.Context) error {
return c.Redirect(http.StatusTemporaryRedirect, c.QueryParam("then"))
})
}
cb := g.Group("/callback", VerifyMiddleware("", config.Config().Twilio.BaseURL), func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if err := next(c); err != nil {
log.Printf("failed to process twilio callback: %v", err)
return err
}
return nil
}
})
{
cb.POST("/recording/:type", func(c echo.Context) error {
form := new(RecordingStatusCallbackForm)
if err := c.Bind(form); err != nil {
return err
}
sid := form.CallSid
if sid == "" {
return c.String(http.StatusBadRequest, "missing call sid")
}
callDir, _, err := findCallDir(calls, form.CallSid, "", "")
if err != nil {
return err
}
return fetchRecording(apiClient, callDir, form.CallSid, c.Param("type"))
})
cb.POST("/voice", func(c echo.Context) error {
form := new(CallStatusCallbackForm)
if err := c.Bind(form); err != nil {
return err
}
callDir, callDirName, err := findCallDir(calls, form.CallSid, form.From, form.To)
if err != nil {
return err
}
callDirAbs := fmt.Sprintf("/calls/%s", callDirName)
log.Printf("call %s: %s -> %s dirAbs=%s", form.CallSid, form.From, form.To, callDirAbs)
if hasTelegram {
msg := tgbotapi.NewMessage(tg.OwnerChatID, fmt.Sprintf("Call From %s (%s):\n\nTo: %s\nSid: %s\nCallDir: <a href=\"%s\">%s</a>",
form.From, form.CallStatus, form.To, form.CallSid, funcmap.FileAccess(callDirAbs+"/"), callDirAbs))
msg.ParseMode = tgbotapi.ModeHTML
if _, err := tg.Client().Send(msg); err != nil {
log.Printf("failed to send telegram message: %v", err)
}
}
fileName := fmt.Sprintf("status.%d.json", time.Now().UnixNano())
f, err := callDir.OpenFile(fileName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0660)
if err != nil {
return err
}
defer f.Close()
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
if err := enc.Encode(form); err != nil {
return err
}
return nil
})
}
}

7
internal/twilio/mime.go Normal file
View file

@ -0,0 +1,7 @@
package twilio
import "mime"
func init() {
mime.AddExtensionType(".mp3", "audio/mpeg")
}

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

@ -0,0 +1,118 @@
package twilio
import (
"crypto/subtle"
"fmt"
"log"
"net/http"
"net/url"
"path"
"strings"
"time"
"github.com/gorilla/sessions"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/eternal-flame-AD/yoake/config"
"github.com/eternal-flame-AD/yoake/internal/auth"
"github.com/eternal-flame-AD/yoake/internal/session"
"github.com/twilio/twilio-go/client"
)
func firstUrlValues(vals ...url.Values) map[string]string {
res := make(map[string]string)
for _, val := range vals {
for k, v := range val {
res[k] = v[0]
}
}
return res
}
const verifySessionName = "twilio-verify"
func VerifyMiddleware(prefix string, baseurlS string) echo.MiddlewareFunc {
baseURL, err := url.Parse(baseurlS)
if err != nil {
log.Fatalf("invalid twilio baseurl: %v", baseurlS)
}
var basicAuth echo.MiddlewareFunc
if userpass := baseURL.User.String(); userpass != "" {
basicAuth = middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) {
ui := url.UserPassword(username, password)
return subtle.ConstantTimeCompare([]byte(ui.String()), []byte(userpass)) == 1, nil
})
}
return func(next echo.HandlerFunc) echo.HandlerFunc {
verifySignature := func(c echo.Context) error {
store := c.Get(session.SessionStoreKeyPrefix + "cookie").(sessions.Store)
if reqAuth := auth.GetRequestAuth(c); reqAuth.Valid && reqAuth.HasRole(auth.RoleAdmin) {
return next(c)
}
bypassOk := false
sess, _ := store.Get(c.Request(), verifySessionName)
if ts, ok := sess.Values["verified"].(int64); ok && time.Now().Unix() < ts {
bypassOk = true
}
cleanPath := path.Clean(c.Request().URL.Path)
if cleanPath == prefix || strings.HasPrefix(cleanPath, prefix+"/") {
fullReq := c.Request().Clone(c.Request().Context())
fullReq.URL = baseURL.ResolveReference(c.Request().URL)
fullReq.URL.User = nil
if err := TwilioValidate(c, fullReq); err != nil {
log.Printf("twilio verify failed: %v, url=%s", err, fullReq.URL.String())
if !bypassOk {
c.String(http.StatusOK, "We are sorry. Request Validation Failed. This is not your fault.")
return nil
}
} else {
sess.Values["verified"] = time.Now().Add(5 * time.Minute).Unix()
sess.Save(c.Request(), c.Response())
}
}
return next(c)
}
if basicAuth != nil {
return basicAuth(verifySignature)
}
return verifySignature
}
}
func TwilioValidate(c echo.Context, 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" {
query := c.QueryParams()
form, err := c.FormParams()
if err != nil {
return err
}
if !requestValidator.Validate(req.URL.String(), firstUrlValues(form), signature) {
req.URL.RawQuery = ""
if !requestValidator.Validate(req.URL.String(), firstUrlValues(form, query), 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
}

View file

@ -0,0 +1,4 @@
# uinext
Next generation UI based on go-app

View file

@ -0,0 +1,48 @@
package apicall
import (
"encoding/json"
"net/http"
"time"
"github.com/maxence-charriere/go-app/v9/pkg/app"
)
func GET(ctx app.Context, path string, dispatch func(app.Context, *http.Response, error)) {
resp, err := http.Get(path)
ctx.Dispatch(func(ctx app.Context) {
dispatch(ctx, resp, err)
})
}
func GetJSON(ctx app.Context, path string, result interface{}, dispatch func(app.Context, error)) {
resp, err := http.Get(path)
if err != nil {
ctx.Dispatch(func(ctx app.Context) {
dispatch(ctx, err)
})
return
}
ctx.Defer(func(app.Context) { resp.Body.Close() })
dec := json.NewDecoder(resp.Body)
ctx.Dispatch(func(ctx app.Context) {
err = dec.Decode(result)
dispatch(ctx, err)
})
}
type RequestAuth struct {
Present bool
Valid bool
Roles []string
Expire time.Time
Ident UserIdent
}
type UserIdent struct {
Username string `json:"username"`
PhotoURL string `json:"photo_url"`
DisplayName string `json:"display_name"`
}

View file

@ -0,0 +1,55 @@
package compo
import (
"fmt"
"github.com/eternal-flame-AD/yoake/internal/uinext/webapp"
"github.com/eternal-flame-AD/yoake/internal/version"
"github.com/maxence-charriere/go-app/v9/pkg/app"
)
// source: includes/navbar.tpl.html
type Navbar struct {
app.Compo
LoginUsername string
}
func (n *Navbar) renderBrand() app.UI {
return app.A().Class("navbar-brand", "col-md-3", "col-lg-2", "me-0", "px-3", "fs-6").
Href(webapp.Singleton.BasePath+"/").
Body(
app.Text("夜明け"),
app.Small().Class("fw-lighter", "text-muted", "px-2").Text(fmt.Sprintf("%s - %s", version.Version, version.Date)),
)
}
func (n *Navbar) renderNavBtn() app.UI {
return app.Button().Class("navbar-toggler", "position-absolute", "d-md-none", "collapsed").
Type("button").
Aria("aria-label", "Toggle navigation").
Attr("data-bs-toggle", "collapse").
Attr("data-bs-target", "#sidebar").
Attr("aria-controls", "sidebarMenu").
Attr("aria-expanded", "false").
Body(
app.Span().Class("navbar-toggler-icon"),
)
}
func (n *Navbar) renderAuthUsername() app.UI {
return app.Div().Class("navbar-nav").Body(
app.Div().Class("nav-item", "text-nowrap", "px-3").Body(
app.Text(n.LoginUsername),
),
)
}
func (n *Navbar) Render() app.UI {
return app.Nav().Class("navbar", "sticky-top", "flex-md-nowrap", "p-0").Body(
n.renderBrand(),
n.renderNavBtn(),
n.renderAuthUsername(),
)
}

View file

@ -0,0 +1,137 @@
package compo
import (
"log"
"github.com/eternal-flame-AD/yoake/internal/uinext/apicall"
"github.com/eternal-flame-AD/yoake/internal/uinext/webapp"
"github.com/maxence-charriere/go-app/v9/pkg/app"
)
type Sidebar struct {
app.Compo
routePath string
Auth apicall.RequestAuth
}
func (c *Sidebar) OnMount(ctx app.Context) {
ctx.Async(func() {
apicall.GetJSON(ctx, "/api/auth/auth.json", &c.Auth, func(ctx app.Context, err error) {
if err != nil {
log.Printf("Failed to get auth info: %v", err)
return
}
log.Printf("Got auth info: %+v", c.Auth)
})
})
}
func (c *Sidebar) OnNav(ctx app.Context) {
c.routePath = ctx.Page().URL().Path
c.Update()
}
func (c *Sidebar) Render() app.UI {
return app.Nav().ID("sidebar").
Class("col-md-3", "col-lg-2", "d-md-block", "bg-light", "sidebar", "collapse").
Body(
app.Div().Class("position-sticky", "pt-3", "sidebar-sticky").Body(
&SidebarItem{
Name: "Dashboard",
Link: webapp.Singleton.BasePath + "/",
CurPath: c.routePath,
Auth: c.Auth,
},
SidebarHeading("Entertainment"),
&SidebarItem{
Name: "YouTube Playlist",
Link: webapp.Singleton.BasePath + "/entertainment/youtube",
CurPath: c.routePath,
Auth: c.Auth,
},
),
)
}
type SidebarItem struct {
app.Compo
Name string
Link string
CurPath string
state SidebarItemState
checkAccess func(auth apicall.RequestAuth) bool
Auth apicall.RequestAuth
HasAccess bool
}
func (c *SidebarItem) OnMount(ctx app.Context) {
c.Update()
}
func (c *SidebarItem) OnUpdate(ctx app.Context) {
if c.checkAccess != nil {
c.HasAccess = c.checkAccess(c.Auth)
} else {
c.HasAccess = true
}
if c.Link == c.CurPath {
if c.HasAccess {
c.state = SidebarItemStateActive
} else {
c.state = SidebarItemStateAccessDenied
}
} else {
if c.HasAccess {
c.state = SidebarItemStateAvailable
} else {
c.state = SidebaritemStateAuthRequired
}
}
}
type SidebarItemState int
const (
SidebarItemStateUnknown SidebarItemState = 0
SidebarItemStateActive SidebarItemState = 1
SidebarItemStateAvailable SidebarItemState = 2
SidebaritemStateAuthRequired SidebarItemState = 3
SidebarItemStateAccessDenied SidebarItemState = 4
)
func (c *SidebarItem) Render() app.UI {
// TODO: allow theming
var img app.HTMLImg
switch c.state {
case SidebaritemStateAuthRequired:
img = app.Img().Src(webapp.Singleton.TrimaImgBase + "icon_t_vista_procedure_ineligible.gif")
case SidebarItemStateAccessDenied:
img = app.Img().Src(webapp.Singleton.TrimaImgBase + "icon_t_vista_procedure_invalid.gif")
case SidebarItemStateActive:
img = app.Img().Src(webapp.Singleton.TrimaImgBase + "icon_t_vista_procedure_optimal.gif")
case SidebarItemStateAvailable:
img = app.Img().Src(webapp.Singleton.TrimaImgBase + "icon_t_vista_procedure_valid.gif")
default:
img = app.Img().Src(webapp.Singleton.TrimaImgBase + "icon_t_vista_procedure_questionable.gif")
}
return app.Ul().Class("nav", "flex-column").Body(
app.Li().Class("nav-item").Body(
app.A().Class("nav-link").Href(c.Link).Body(
app.Span().Class("px-1").Body(img.Style("height", "2.5rem").Style("width", "2.5rem")),
app.Text(c.Name),
),
),
)
}
func SidebarHeading(title string) app.UI {
return app.H6().Class("sidebar-heading", "d-flex", "justify-content-between", "align-items-center", "px-3", "mt-4", "mb-1", "text-muted").Body(
app.Span().Text(title),
)
}

108
internal/uinext/ui/app.go Normal file
View file

@ -0,0 +1,108 @@
package ui
import (
"os"
"regexp"
"strings"
"time"
"github.com/eternal-flame-AD/yoake/internal/uinext/compo"
"github.com/eternal-flame-AD/yoake/internal/uinext/ui/page"
"github.com/eternal-flame-AD/yoake/internal/uinext/webapp"
"github.com/eternal-flame-AD/yoake/internal/util"
"github.com/eternal-flame-AD/yoake/internal/version"
"github.com/labstack/echo/v4"
"github.com/maxence-charriere/go-app/v9/pkg/app"
)
type App struct {
app.Compo
navbar *compo.Navbar
sidebar *compo.Sidebar
}
func (a *App) Render() app.UI {
if a.navbar == nil {
a.navbar = &compo.Navbar{}
}
if a.sidebar == nil {
a.sidebar = &compo.Sidebar{}
}
return app.Div().ID("app").Body(
a.navbar,
app.Div().Class("row").Body(
a.sidebar,
&page.Router{
Rules: []page.RouterRule{
{
CheckNav: page.RouterMatchPathRegexp(regexp.MustCompile(`^/$`)),
Page: &page.Dashboard{},
},
},
},
),
)
}
const BasePath = "/uinext"
func Register(g *echo.Group) {
webapp.Singleton.BasePath = BasePath
webapp.Singleton.TrimaImgBase = "https://yumechi.jp/img/trima/"
handler := &app.Handler{
Name: "Yoake PMS",
ShortName: "夜明け",
Description: "Yoake PMS - 夜明け",
RawHeaders: util.Join(headTagBootstrap, headTagDayjs, headTagCustom),
Lang: "en",
AutoUpdateInterval: 10 * time.Second,
BackgroundColor: "#FEDFE1",
ThemeColor: "#FEDFE1",
LoadingLabel: "Loading...",
Version: version.Version + "-" + version.Date,
Icon: app.Icon{
Default: webapp.Singleton.TrimaImgBase + "icon_squeeze.gif",
},
}
app.RouteWithRegexp("^"+BasePath+"/.*", new(App))
g.Group("/web").GET("*", func(c echo.Context) error {
handler.ServeHTTP(c.Response(), c.Request())
return nil
})
g.GET("/web/app.wasm", func(c echo.Context) error {
tryPaths := []string{
"web/app.wasm",
"dist/web/app.wasm",
"app.wasm",
}
for _, path := range tryPaths {
if stat, err := os.Stat(path); err == nil && !stat.IsDir() {
return c.File(path)
}
}
return c.NoContent(404)
})
g.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if util.Contain(staticFiles, strings.ToLower(c.Request().URL.Path)) {
handler.ServeHTTP(c.Response(), c.Request())
return nil
}
return next(c)
}
})
g.Group(BasePath).Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
next(c)
if !c.Response().Committed {
handler.ServeHTTP(c.Response(), c.Request())
return nil
}
return nil
}
})
}

View file

@ -0,0 +1,45 @@
package page
import "github.com/maxence-charriere/go-app/v9/pkg/app"
type Dashboard struct {
app.Compo
}
func (d *Dashboard) Render() app.UI {
return BasePage(BasicHeading("Dashboard"),
app.Div().Class("container").Body(
app.Div().Class("row").Body(
app.Div().Class("col").Body(
&Card{
Header: app.Text("Welcome"),
BodyClass: []string{"text-center"},
Body: []app.UI{
app.Blockquote().Class("blockquote").Body(
app.P().Text("夜明け前が一番暗い"),
app.P().Text("The night is darkest just before the dawn."),
),
app.Hr(),
app.Div().ID("welcome").Body(
app.P().Body(
app.Text("Welcome to yoake.yumechi.jp, Yumechi's "),
Abbr("PIM", "Personal Information Manager", "initialism", "https://en.wikipedia.org/wiki/Personal_information_manager"),
app.Text("."),
),
app.P().Body(
app.Text("Built with "),
Abbr("Echo", "Echo HTTP Framework", "", "https://echo.labstack.com/"),
app.Text(", "),
Abbr("Bootstrap", "Bootstrap CSS Framework", "", "https://getbootstrap.com/"),
app.Text(", and "),
Abbr("go-app", "Go PWA Framework", "", "https://go-app.dev/"),
app.Text("."),
),
),
},
},
),
),
),
)
}

View file

@ -0,0 +1,45 @@
package page
import "github.com/maxence-charriere/go-app/v9/pkg/app"
func BasePage(elems ...app.UI) app.UI {
return app.Main().Class("col-md-9", "ms-sm-auto", "col-lg-10", "px-md-4").Body(elems...)
}
func BasicHeading(name string) app.UI {
return app.Div().Body(
app.H1().Class("page-header").Text(name),
app.Hr(),
)
}
type Card struct {
app.Compo
HeaderClass []string
Header app.UI
BodyClass []string
Body []app.UI
}
func (c *Card) Render() app.UI {
return app.Div().Class("card", "border").Body(
app.Div().Class(append(c.HeaderClass, "card-header")...).Body(c.Header),
app.Div().Class(append(c.BodyClass, "card-body")...).Body(c.Body...),
)
}
func Abbr(abbr string, title string, class string, href string) app.UI {
abbrEle := app.Abbr()
if title != "" {
abbrEle = abbrEle.Title(title)
}
if class != "" {
abbrEle = abbrEle.Class(class)
}
if href != "" {
return app.A().Target("_blank").Rel("noopener noreferrer").Href(href).Body(abbrEle.Text(abbr))
}
return abbrEle.Text(abbr)
}

View file

@ -0,0 +1,59 @@
package page
import (
"net/url"
"regexp"
"strings"
"github.com/eternal-flame-AD/yoake/internal/uinext/webapp"
"github.com/maxence-charriere/go-app/v9/pkg/app"
)
type Router struct {
app.Compo
matchedPage app.UI
Rules []RouterRule
NotFound app.UI
}
func (r *Router) OnNav(ctx app.Context) {
defer r.Update()
for _, rule := range r.Rules {
urlCopy := *ctx.Page().URL()
if !rule.NoStripPrefix {
bp := webapp.Singleton.BasePath
if bp != "" {
urlCopy.Path = strings.TrimPrefix(urlCopy.Path, bp)
urlCopy.RawPath = strings.TrimPrefix(urlCopy.RawPath, bp)
}
}
if rule.CheckNav(&urlCopy) {
r.matchedPage = rule.Page
return
}
}
r.matchedPage = nil
}
func (r *Router) Render() app.UI {
if r.matchedPage != nil {
return r.matchedPage
}
if r.NotFound != nil {
return r.NotFound
}
return BasePage(BasicHeading("404 Not Found"), app.P().Text("The page you are looking for does not exist."))
}
type RouterRule struct {
NoStripPrefix bool
CheckNav func(*url.URL) bool
Page app.UI
}
func RouterMatchPathRegexp(regex *regexp.Regexp) func(*url.URL) bool {
return func(u *url.URL) bool {
return regex.MatchString(u.Path)
}
}

View file

@ -0,0 +1,36 @@
package ui
var headTagDayjs = []string{
`<script src="https://cdn.jsdelivr.net/npm/dayjs@1.11.6/dayjs.min.js"
integrity="sha256-EfJOqCcshFS/2TxhArURu3Wn8b/XDA4fbPWKSwZ+1B8=" crossorigin="anonymous"></script>`,
` <script src="https://cdn.jsdelivr.net/npm/dayjs@1.11.6/plugin/relativeTime.js"
integrity="sha256-muryXOPFkVJcJO1YFmhuKyXYmGDT2TYVxivG0MCgRzg=" crossorigin="anonymous"></script>`,
` <script src="https://cdn.jsdelivr.net/npm/dayjs@1.11.6/plugin/localizedFormat.js"
integrity="sha256-g+gxm1xmRq4IecSRujv2eKyUCo/i1b5kRnWNcSbYEO0=" crossorigin="anonymous"></script>`,
`<script>
dayjs.extend(window.dayjs_plugin_relativeTime);
dayjs.extend(window.dayjs_plugin_localizedFormat);</script>`,
}
var headTagBootstrap = []string{
`<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>`,
}
var headTagCustom = []string{
`<link rel="stylesheet" href="/style.css">`,
`<link rel="stylesheet" href="/dashboard.css">`,
}
var staticFiles = []string{
"/app-worker.js",
"/app.js",
"/app.css",
"/manifest.webmanifest",
"/wasm_exec.js",
}

View file

@ -0,0 +1,9 @@
package webapp
type IWebApp struct {
BasePath string
TrimaImgBase string
}
var Singleton IWebApp

View file

@ -0,0 +1,45 @@
//go:build linux
package util
import (
"fmt"
"log"
"os"
"strings"
"github.com/eternal-flame-AD/go-apparmor/apparmor"
"github.com/labstack/echo/v4"
)
type AAConMiddlewareEnforcer func(label string, mode string) (exit int, err error)
func AAConMiddleware(enforce AAConMiddlewareEnforcer) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
label, mode, err := apparmor.AAGetCon()
if err != nil {
log.Printf("failed to get apparmor label: %v", err)
label = "[ERROR]"
}
var sanitizedLabel string
if idx := strings.Index(label, "//"); idx == -1 {
sanitizedLabel = "//"
} else {
sanitizedLabel = label[idx:]
}
c.Response().Header().Set("X-App-Con", fmt.Sprintf("%s (%s)", sanitizedLabel, mode))
if enforce != nil {
if exitCode, err := enforce(label, mode); err != nil {
if exitCode == 0 {
c.Response().After(func() {
os.Exit(exitCode)
})
}
return err
}
}
return next(c)
}
}
}

View file

@ -0,0 +1,25 @@
package util
import "time"
type DateRange struct {
From time.Time
To time.Time
}
func NewDateRange(from time.Time, to time.Time) DateRange {
from, _ = time.Parse("2006-01-02", from.Format("2006-01-02"))
to, _ = time.Parse("2006-01-02", to.Format("2006-01-02"))
if from.After(to) {
from, to = to, from
}
return DateRange{from, to}
}
func (d DateRange) Days() int {
return int(d.To.Sub(d.From).Hours() / 24)
}
func DateRangeAround(date time.Time, months int) DateRange {
return NewDateRange(date.AddDate(0, -months, 0), date.AddDate(0, months, 0))
}

49
internal/util/slice.go Normal file
View file

@ -0,0 +1,49 @@
package util
// Contain checks if an element is in a slice.
func Contain[T comparable](a []T, x T) bool {
for _, n := range a {
if x == n {
return true
}
}
return false
}
// Unique returns a slice with all duplicate elements removed.
func Unique[T comparable](a []T) []T {
var result []T
for _, e := range a {
if !Contain(result, e) {
result = append(result, e)
}
}
return result
}
// AntiJOIN returns a slice with all elements in a that are not in b.
func AntiJoin[T comparable](a []T, b []T) []T {
var result []T
for _, e := range a {
if !Contain(b, e) {
result = append(result, e)
}
}
return result
}
func Join[T any](vals ...[]T) []T {
result := make([]T, 0)
for _, val := range vals {
result = append(result, val...)
}
return result
}
func Reverse[T any](a []T) []T {
var result []T
for i := len(a) - 1; i >= 0; i-- {
result = append(result, a[i])
}
return result
}

View file

@ -0,0 +1,48 @@
package utilapi
import (
"errors"
"os"
"time"
"github.com/alexedwards/argon2id"
"github.com/eternal-flame-AD/yoake/internal/auth"
"github.com/eternal-flame-AD/yoake/internal/echoerror"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func Register(g *echo.Group) (err error) {
limiterStore := middleware.NewRateLimiterMemoryStoreWithConfig(middleware.RateLimiterMemoryStoreConfig{
Rate: 1,
Burst: 5,
ExpiresIn: 1 * time.Minute,
})
cryptoG := g.Group("/crypto")
{
cryptoG.POST("/argon2id", func(c echo.Context) error {
if passwd := c.FormValue("password"); passwd != "" {
if hash, err := argon2id.CreateHash(passwd, auth.Argon2IdParams); err != nil {
return err
} else {
return c.JSON(200, map[string]string{"hash": hash})
}
}
return echoerror.NewHttp(400, errors.New("password not provided"))
}, middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
Skipper: func(c echo.Context) bool {
return auth.GetRequestAuth(c).HasRole(auth.RoleAdmin)
},
Store: limiterStore,
}))
}
g.GET("/tryopen", func(c echo.Context) error {
if _, err := os.ReadFile(c.QueryParam("path")); err != nil {
return err
}
return c.String(200, c.QueryParam("path"))
}, auth.RequireMiddleware(auth.RoleAdmin))
return nil
}

Some files were not shown because too many files have changed in this diff Show more