Compare commits
No commits in common. "main" and "legacy" have entirely different histories.
239 changed files with 10660 additions and 12180 deletions
1
.env
1
.env
|
@ -1 +0,0 @@
|
||||||
DATABASE_URL=sqlite://data/development.db
|
|
9
.gitignore
vendored
9
.gitignore
vendored
|
@ -1,4 +1,7 @@
|
||||||
|
dist
|
||||||
|
install.sh
|
||||||
|
*.pid
|
||||||
|
config-*.yml
|
||||||
|
!config-test.yml
|
||||||
data
|
data
|
||||||
target
|
.vscode/launch.json
|
||||||
config-dev.yaml
|
|
||||||
config-prod.yaml
|
|
16
.vscode/c_cpp_properties.json
vendored
Normal file
16
.vscode/c_cpp_properties.json
vendored
Normal 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
12
.vscode/launch-tpl.json
vendored
Normal 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
6
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"files.associations": {
|
||||||
|
"*.tpl.*": "gotmpl",
|
||||||
|
"*.tpl.html": "html",
|
||||||
|
}
|
||||||
|
}
|
3448
Cargo.lock
generated
3448
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
61
Cargo.toml
61
Cargo.toml
|
@ -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
66
Makefile
Normal 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:
|
28
assets/msg-canvas-grades.tpl.html
Normal file
28
assets/msg-canvas-grades.tpl.html
Normal 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
99
cmd/server/server.go
Normal 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
25
config-test.yml
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
listen:
|
||||||
|
addr: :10098
|
||||||
|
ssl:
|
||||||
|
use: off
|
||||||
|
|
||||||
|
hosts:
|
||||||
|
"vault": vault
|
||||||
|
"": webroot
|
||||||
|
|
||||||
|
webroot:
|
||||||
|
sitename: "SITE NAME"
|
||||||
|
root: dist/webroot
|
||||||
|
sessionkey: test
|
||||||
|
log:
|
||||||
|
allrequests: on
|
||||||
|
|
||||||
|
upstream:
|
||||||
|
vault:
|
||||||
|
api: "http://vault"
|
||||||
|
ui: "file://dist/webroot/vault-ui"
|
||||||
|
|
||||||
|
twilio:
|
||||||
|
accountsid: ACxxxxxxxxxxxxxxxxxx
|
||||||
|
authtoken: "12345"
|
||||||
|
skipverify: off
|
48
config.yaml
48
config.yaml
|
@ -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
64
config.yml
Normal 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
20
config/canvas.go
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
type CanvasLMS struct {
|
||||||
|
Token string
|
||||||
|
Frequency string
|
||||||
|
MaxN string
|
||||||
|
Endpoint string
|
||||||
|
|
||||||
|
SubmissionName string
|
||||||
|
|
||||||
|
Message struct {
|
||||||
|
OnUpdate CanvasLMSMessage
|
||||||
|
OnStartup CanvasLMSMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type CanvasLMSMessage struct {
|
||||||
|
Comm string
|
||||||
|
Subject string
|
||||||
|
Template string
|
||||||
|
}
|
32
config/comm.go
Normal file
32
config/comm.go
Normal 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
94
config/config.go
Normal 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
5
config/db.go
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
type DBBadger struct {
|
||||||
|
Dir string
|
||||||
|
}
|
6
config/fs.go
Normal file
6
config/fs.go
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
type FileStore struct {
|
||||||
|
BasePath string
|
||||||
|
Serve bool
|
||||||
|
}
|
|
@ -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
33
etc/service-install.fish
Executable 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
19
etc/yoake-server.service
Normal 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
42
etc/yoake.apparmor
Normal 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
66
go.mod
Normal 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
618
go.sum
Normal 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
319
internal/auth/auth.go
Normal 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
7
internal/auth/gob.go
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import "encoding/gob"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
gob.Register(UserIdent{})
|
||||||
|
}
|
11
internal/auth/kdf.go
Normal file
11
internal/auth/kdf.go
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import "github.com/alexedwards/argon2id"
|
||||||
|
|
||||||
|
var Argon2IdParams = &argon2id.Params{
|
||||||
|
Memory: 64 * 1024,
|
||||||
|
Iterations: 4,
|
||||||
|
Parallelism: 2,
|
||||||
|
SaltLength: 16,
|
||||||
|
KeyLength: 32,
|
||||||
|
}
|
9
internal/auth/role.go
Normal file
9
internal/auth/role.go
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
type Role string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleAdmin Role = "admin"
|
||||||
|
RoleUser Role = "user"
|
||||||
|
RoleTelgram Role = "tg"
|
||||||
|
)
|
93
internal/auth/tglogin/verify.go
Normal file
93
internal/auth/tglogin/verify.go
Normal 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
24
internal/auth/yubikey.go
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/eternal-flame-AD/yoake/config"
|
||||||
|
"github.com/eternal-flame-AD/yubigo"
|
||||||
|
)
|
||||||
|
|
||||||
|
var yubiAuth *yubigo.YubiAuth
|
||||||
|
|
||||||
|
func yubiAuthLazyInit() {
|
||||||
|
if yubiAuth != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conf := config.Config()
|
||||||
|
if conf.Auth.Yubikey.ClientId != "" {
|
||||||
|
if a, err := yubigo.NewYubiAuth(conf.Auth.Yubikey.ClientId, conf.Auth.Yubikey.ClientKey); err != nil {
|
||||||
|
log.Panicf("failed to load yubigo: %v", err)
|
||||||
|
} else {
|
||||||
|
yubiAuth = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
146
internal/canvaslms/graph.go
Normal file
146
internal/canvaslms/graph.go
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
package canvaslms
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const GraphQuery = `query gradeQuery {
|
||||||
|
allCourses {
|
||||||
|
_id
|
||||||
|
id
|
||||||
|
name
|
||||||
|
state
|
||||||
|
courseCode
|
||||||
|
submissionsConnection(first: $maxn$, orderBy: {field: gradedAt, direction: descending}) {
|
||||||
|
nodes {
|
||||||
|
_id
|
||||||
|
id
|
||||||
|
assignment {
|
||||||
|
_id
|
||||||
|
id
|
||||||
|
name
|
||||||
|
dueAt
|
||||||
|
gradingType
|
||||||
|
pointsPossible
|
||||||
|
htmlUrl
|
||||||
|
}
|
||||||
|
score
|
||||||
|
enteredScore
|
||||||
|
grade
|
||||||
|
enteredGrade
|
||||||
|
gradingStatus
|
||||||
|
gradeHidden
|
||||||
|
gradedAt
|
||||||
|
posted
|
||||||
|
postedAt
|
||||||
|
state
|
||||||
|
user {
|
||||||
|
_id
|
||||||
|
id
|
||||||
|
name
|
||||||
|
sisId
|
||||||
|
email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
type GraphResponse struct {
|
||||||
|
Data struct {
|
||||||
|
AllCourses []struct {
|
||||||
|
IDLegacy string `json:"_id"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
State string `json:"state"`
|
||||||
|
CourseCode string `json:"courseCode"`
|
||||||
|
SubmissionsConnection struct {
|
||||||
|
Nodes []GraphSubmissionResponse `json:"nodes"`
|
||||||
|
} `json:"submissionsConnection"`
|
||||||
|
} `json:"allCourses"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
type GraphSubmissionResponse struct {
|
||||||
|
IDLegacy string `json:"_id"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Assignment struct {
|
||||||
|
IDLegacy string `json:"_id"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DueAt *string `json:"dueAt"`
|
||||||
|
GradingType string `json:"gradingType"`
|
||||||
|
PointsPossible float64 `json:"pointsPossible"`
|
||||||
|
HTMLUrl string `json:"htmlUrl"`
|
||||||
|
} `json:"assignment"`
|
||||||
|
Score *float64 `json:"score"`
|
||||||
|
EnteredScore *float64 `json:"enteredScore"`
|
||||||
|
Grade *string `json:"grade"`
|
||||||
|
EnteredGrade *string `json:"enteredGrade"`
|
||||||
|
GradingStatus string `json:"gradingStatus"`
|
||||||
|
GradeHidden bool `json:"gradeHidden"`
|
||||||
|
GradedAt *string `json:"gradedAt"`
|
||||||
|
Posted bool `json:"posted"`
|
||||||
|
PostedAt *string `json:"postedAt"`
|
||||||
|
State string `json:"state"`
|
||||||
|
User struct {
|
||||||
|
IDLegacy string `json:"_id"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
SISID *string `json:"sisId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email *string `json:"email"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type GraphSubmissionCompareFunc func(m1, m2 GraphSubmissionResponse) (m1HasPriority bool)
|
||||||
|
|
||||||
|
func parseJSONTime(s string) time.Time {
|
||||||
|
t, _ := time.Parse(time.RFC3339, s)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func GraphSubmissionCompareByDue(m1, m2 GraphSubmissionResponse) (m1HasPriority bool) {
|
||||||
|
if m1.Assignment.DueAt == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if m2.Assignment.DueAt == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
m1Time, m2Time := parseJSONTime(*m1.Assignment.DueAt), parseJSONTime(*m2.Assignment.DueAt)
|
||||||
|
now := time.Now()
|
||||||
|
m1IsPast, m2IsPast := now.After(m1Time), now.After(m2Time)
|
||||||
|
if m1IsPast && m2IsPast {
|
||||||
|
return m1Time.After(m2Time)
|
||||||
|
}
|
||||||
|
if !m1IsPast && !m2IsPast {
|
||||||
|
return m1Time.Before(m2Time)
|
||||||
|
}
|
||||||
|
return !m1IsPast
|
||||||
|
}
|
||||||
|
|
||||||
|
func laterTime(t1, t2 *string) *string {
|
||||||
|
if t1 == nil {
|
||||||
|
return t2
|
||||||
|
}
|
||||||
|
if t2 == nil {
|
||||||
|
return t1
|
||||||
|
}
|
||||||
|
t1T, t2T := parseJSONTime(*t1), parseJSONTime(*t2)
|
||||||
|
|
||||||
|
if t1T.After(t2T) {
|
||||||
|
return t1
|
||||||
|
}
|
||||||
|
return t2
|
||||||
|
}
|
||||||
|
|
||||||
|
func GraphSubmissionCompareByGradeTime(m1, m2 GraphSubmissionResponse) (m1HasPriority bool) {
|
||||||
|
m1LastUpdate := laterTime(m1.PostedAt, m1.GradedAt)
|
||||||
|
m2LastUpdate := laterTime(m2.PostedAt, m2.GradedAt)
|
||||||
|
if m2LastUpdate == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if m1LastUpdate == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return parseJSONTime(*m1LastUpdate).After(parseJSONTime(*m2LastUpdate))
|
||||||
|
}
|
318
internal/canvaslms/handler.go
Normal file
318
internal/canvaslms/handler.go
Normal file
|
@ -0,0 +1,318 @@
|
||||||
|
package canvaslms
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/eternal-flame-AD/yoake/config"
|
||||||
|
"github.com/eternal-flame-AD/yoake/internal/auth"
|
||||||
|
"github.com/eternal-flame-AD/yoake/internal/comm/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
|
||||||
|
}
|
38
internal/canvaslms/message.go
Normal file
38
internal/canvaslms/message.go
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
package canvaslms
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/eternal-flame-AD/yoake/config"
|
||||||
|
"github.com/eternal-flame-AD/yoake/internal/comm/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) SendGradeMessage(conf config.CanvasLMSMessage) error {
|
||||||
|
if conf.Template == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
mime := "text/plain+text/template"
|
||||||
|
if strings.HasSuffix(conf.Template, ".html") && strings.HasPrefix(conf.Template, "@") {
|
||||||
|
mime = "text/html+html/template"
|
||||||
|
}
|
||||||
|
grades, err := h.sortResponse(GraphSubmissionCompareByGradeTime)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.comm.SendGenericMessage(conf.Comm, &model.GenericMessage{
|
||||||
|
Subject: conf.Subject,
|
||||||
|
Body: conf.Template,
|
||||||
|
MIME: mime,
|
||||||
|
Context: GetGradesResponse{
|
||||||
|
Grades: grades,
|
||||||
|
LastRefresh: h.respCache.requestTime.Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
}, false); err != nil {
|
||||||
|
log.Printf("error sending grade message: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
71
internal/comm/api.go
Normal file
71
internal/comm/api.go
Normal 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})
|
||||||
|
})
|
||||||
|
}
|
125
internal/comm/communicator.go
Normal file
125
internal/comm/communicator.go
Normal 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
140
internal/comm/convert.go
Normal 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
|
||||||
|
}
|
80
internal/comm/email/handler.go
Normal file
80
internal/comm/email/handler.go
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/eternal-flame-AD/yoake/config"
|
||||||
|
"github.com/eternal-flame-AD/yoake/internal/comm/model"
|
||||||
|
"github.com/vanng822/go-premailer/premailer"
|
||||||
|
"gopkg.in/gomail.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
MIME string
|
||||||
|
Subject string
|
||||||
|
Message string
|
||||||
|
|
||||||
|
To string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
dialer *gomail.Dialer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler() (*Handler, error) {
|
||||||
|
conf := config.Config().Comm.Email
|
||||||
|
if conf.SMTP.Host == "" || conf.SMTP.Port == 0 {
|
||||||
|
return nil, fmt.Errorf("invalid email configuration")
|
||||||
|
}
|
||||||
|
dialer := gomail.NewDialer(conf.SMTP.Host, conf.SMTP.Port, conf.SMTP.UserName, conf.SMTP.Password)
|
||||||
|
return &Handler{
|
||||||
|
dialer: dialer,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) SendGenericMessage(gmsg *model.GenericMessage) error {
|
||||||
|
msg := Message{
|
||||||
|
MIME: gmsg.MIME,
|
||||||
|
Subject: gmsg.Subject,
|
||||||
|
Message: gmsg.Body,
|
||||||
|
}
|
||||||
|
return h.SendEmail(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) SupportedMIME() []string {
|
||||||
|
return []string{"text/plain", "text/html"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) SendEmail(msg Message) error {
|
||||||
|
conf := config.Config().Comm.Email
|
||||||
|
if !strings.HasPrefix(msg.MIME, "text/html") &&
|
||||||
|
!strings.HasPrefix(msg.MIME, "text/plain") {
|
||||||
|
return fmt.Errorf("does not know how to send MIME type %s", msg.MIME)
|
||||||
|
}
|
||||||
|
if msg.MIME == "text/html" {
|
||||||
|
prem, err := premailer.NewPremailerFromString(msg.Message, premailer.NewOptions())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
msg.Message, err = prem.Transform()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
email := gomail.NewMessage()
|
||||||
|
email.SetHeader("From", conf.SMTP.From)
|
||||||
|
if msg.To != "" {
|
||||||
|
email.SetHeader("To", msg.To)
|
||||||
|
} else {
|
||||||
|
email.SetHeader("To", conf.SMTP.To)
|
||||||
|
}
|
||||||
|
if msg.Subject != "" {
|
||||||
|
email.SetHeader("Subject", msg.Subject)
|
||||||
|
} else {
|
||||||
|
email.SetHeader("Subject", conf.SMTP.DefaultSubject)
|
||||||
|
}
|
||||||
|
email.SetBody(msg.MIME, msg.Message, gomail.SetPartEncoding("base64"))
|
||||||
|
return h.dialer.DialAndSend(email)
|
||||||
|
}
|
29
internal/comm/gotify/model.go
Normal file
29
internal/comm/gotify/model.go
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
package gotify
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
|
||||||
|
Extras struct {
|
||||||
|
ClientDisplay struct {
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
|
} `json:"client::display,omitempty"`
|
||||||
|
ClientNotification struct {
|
||||||
|
Click struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"click,omitempty"`
|
||||||
|
} `json:"client::notification,omitempty"`
|
||||||
|
} `json:"extras"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Health struct {
|
||||||
|
Database string `json:"database"`
|
||||||
|
Health string `json:"health"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Version struct {
|
||||||
|
BuildDate string `json:"buildDate"`
|
||||||
|
Commit string `json:"commit"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
104
internal/comm/gotify/send.go
Normal file
104
internal/comm/gotify/send.go
Normal 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
|
||||||
|
}
|
17
internal/comm/gotify/url.go
Normal file
17
internal/comm/gotify/url.go
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
package gotify
|
||||||
|
|
||||||
|
import "net/url"
|
||||||
|
|
||||||
|
var (
|
||||||
|
urlMessage = urlMustParse("/message")
|
||||||
|
urlHealth = urlMustParse("/health")
|
||||||
|
urlVersion = urlMustParse("/version")
|
||||||
|
)
|
||||||
|
|
||||||
|
func urlMustParse(s string) *url.URL {
|
||||||
|
u, err := url.Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
11
internal/comm/model/generic.go
Normal file
11
internal/comm/model/generic.go
Normal 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{}
|
||||||
|
}
|
20
internal/comm/model/interface.go
Normal file
20
internal/comm/model/interface.go
Normal 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
|
||||||
|
}
|
11
internal/comm/telegram/banner.go
Normal file
11
internal/comm/telegram/banner.go
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
const banner = `====================
|
||||||
|
Welcome: {name}
|
||||||
|
|
||||||
|
夜明け前が一番暗い
|
||||||
|
|
||||||
|
The night is darkest just before the dawn.
|
||||||
|
|
||||||
|
yoake.yumechi.jp
|
||||||
|
====================`
|
229
internal/comm/telegram/bot.go
Normal file
229
internal/comm/telegram/bot.go
Normal 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()
|
||||||
|
}
|
33
internal/comm/telegram/commands.go
Normal file
33
internal/comm/telegram/commands.go
Normal 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
41
internal/db/badger.go
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import "github.com/dgraph-io/badger/v3"
|
||||||
|
|
||||||
|
type BadgerDB struct {
|
||||||
|
db *badger.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BadgerDB) NewTransaction(readonly bool) DBTxn {
|
||||||
|
return &BadgerDBTxn{
|
||||||
|
txn: b.db.NewTransaction(readonly),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type BadgerDBTxn struct {
|
||||||
|
txn *badger.Txn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *BadgerDBTxn) Set(key []byte, value []byte) error {
|
||||||
|
return t.txn.Set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *BadgerDBTxn) Delete(key []byte) error {
|
||||||
|
return t.txn.Delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *BadgerDBTxn) Get(key []byte) ([]byte, error) {
|
||||||
|
item, err := t.txn.Get(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return item.ValueCopy(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *BadgerDBTxn) Commit() error {
|
||||||
|
return t.txn.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *BadgerDBTxn) Discard() {
|
||||||
|
t.txn.Discard()
|
||||||
|
}
|
49
internal/db/db.go
Normal file
49
internal/db/db.go
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
badger "github.com/dgraph-io/badger/v3"
|
||||||
|
"github.com/eternal-flame-AD/yoake/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DB interface {
|
||||||
|
NewTransaction(update bool) DBTxn
|
||||||
|
}
|
||||||
|
|
||||||
|
type DBTxn interface {
|
||||||
|
Set(key, value []byte) error
|
||||||
|
Delete(key []byte) error
|
||||||
|
Get(key []byte) ([]byte, error)
|
||||||
|
Commit() error
|
||||||
|
Discard()
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetJSON(t DBTxn, key []byte, v interface{}) error {
|
||||||
|
if data, err := t.Get(key); err != nil {
|
||||||
|
return err
|
||||||
|
} else {
|
||||||
|
return json.Unmarshal(data, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetJSON(t DBTxn, key []byte, v interface{}) error {
|
||||||
|
if data, err := json.Marshal(v); err != nil {
|
||||||
|
return err
|
||||||
|
} else {
|
||||||
|
return t.Set(key, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(conf config.C) (DB, error) {
|
||||||
|
if conf.DB.Badger.Dir != "" {
|
||||||
|
opts := badger.DefaultOptions(conf.DB.Badger.Dir)
|
||||||
|
if db, err := badger.Open(opts); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else {
|
||||||
|
return &BadgerDB{db}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("no database configured")
|
||||||
|
}
|
14
internal/db/error.go
Normal file
14
internal/db/error.go
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/dgraph-io/badger/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func IsNotFound(err error) bool {
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
23
internal/echoerror/httperror.go
Normal file
23
internal/echoerror/httperror.go
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
package echoerror
|
||||||
|
|
||||||
|
type HTTPError interface {
|
||||||
|
error
|
||||||
|
Code() int
|
||||||
|
}
|
||||||
|
|
||||||
|
type httpError struct {
|
||||||
|
code int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *httpError) Error() string {
|
||||||
|
return e.err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *httpError) Code() int {
|
||||||
|
return e.code
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHttp(code int, err error) HTTPError {
|
||||||
|
return &httpError{code, err}
|
||||||
|
}
|
105
internal/echoerror/middleware.go
Normal file
105
internal/echoerror/middleware.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
11
internal/entertainment/api.go
Normal file
11
internal/entertainment/api.go
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
package entertainment
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/eternal-flame-AD/yoake/internal/db"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Register(g *echo.Group, database db.DB) {
|
||||||
|
youtube := g.Group("/youtube")
|
||||||
|
registerYoutube(youtube, database)
|
||||||
|
}
|
10
internal/entertainment/util.go
Normal file
10
internal/entertainment/util.go
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
package entertainment
|
||||||
|
|
||||||
|
func contain[V comparable](slice []V, value V) bool {
|
||||||
|
for _, v := range slice {
|
||||||
|
if v == value {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
421
internal/entertainment/youtube.go
Normal file
421
internal/entertainment/youtube.go
Normal file
|
@ -0,0 +1,421 @@
|
||||||
|
package entertainment
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/eternal-flame-AD/yoake/internal/auth"
|
||||||
|
"github.com/eternal-flame-AD/yoake/internal/db"
|
||||||
|
"github.com/eternal-flame-AD/yoake/internal/echoerror"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type YoutubeVideoStore struct {
|
||||||
|
VideoID string `json:"video_id" form:"video_id" query:"video_id"`
|
||||||
|
Meta *YoutubeVideoEmbedMeta `json:"meta,omitempty"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Category string `param:"category" json:"category" form:"category" query:"category"`
|
||||||
|
|
||||||
|
Comment string `json:"comment" form:"comment"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type YoutubeVideoEmbedMeta struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
AuthorName string `json:"author_name"`
|
||||||
|
AuthorURL string `json:"author_url"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
ProviderName string `json:"provider_name"`
|
||||||
|
ProviderURL string `json:"provider_url"`
|
||||||
|
ThumbnailURL string `json:"thumbnail_url"`
|
||||||
|
ThumbnailWidth int `json:"thumbnail_width"`
|
||||||
|
ThumbnailHeight int `json:"thumbnail_height"`
|
||||||
|
Html string `json:"html"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetYoutubeVideoInfo(videoUrl string) (info *YoutubeVideoEmbedMeta, err error) {
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
resp, err := client.Get("https://www.youtube.com/oembed?format=json&url=https://www.youtube.com/watch?v=" + url.QueryEscape(videoUrl))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
info = new(YoutubeVideoEmbedMeta)
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(info)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type YoutubeCategoryStore struct {
|
||||||
|
ID string `json:"id" form:"id"`
|
||||||
|
DisplayName string `json:"display_name" form:"display_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type YoutubeTagStore struct {
|
||||||
|
ID string `json:"id" form:"id"`
|
||||||
|
DisplayName string `json:"display_name" form:"display_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type YoutubeVideoDBTxn struct {
|
||||||
|
txn db.DBTxn
|
||||||
|
}
|
||||||
|
|
||||||
|
func newYoutubeDBTxn(txn db.DBTxn) *YoutubeVideoDBTxn {
|
||||||
|
return &YoutubeVideoDBTxn{
|
||||||
|
txn: txn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *YoutubeVideoDBTxn) GetCategories() (categories []YoutubeCategoryStore, err error) {
|
||||||
|
err = db.GetJSON(t.txn, []byte("youtube_categories"), &categories)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *YoutubeVideoDBTxn) SetCategories(categories []YoutubeCategoryStore) (err error) {
|
||||||
|
return db.SetJSON(t.txn, []byte("youtube_categories"), categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *YoutubeVideoDBTxn) DeleteCategory(category string) (err error) {
|
||||||
|
categories, err := t.GetCategories()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
newCategories := make([]YoutubeCategoryStore, 0, len(categories))
|
||||||
|
for _, categoryS := range categories {
|
||||||
|
if categoryS.ID != category {
|
||||||
|
newCategories = append(newCategories, categoryS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = t.SetCategories(newCategories); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := t.txn.Delete([]byte("youtube_category:" + category + "_tags")); err != nil && !db.IsNotFound(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := t.txn.Delete([]byte("youtube_category:" + category + "_videos")); err != nil && !db.IsNotFound(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *YoutubeVideoDBTxn) GetTags(category string) (tags []YoutubeTagStore, err error) {
|
||||||
|
categories, err := t.GetCategories()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, categoryS := range categories {
|
||||||
|
if categoryS.ID == category {
|
||||||
|
err = db.GetJSON(t.txn, []byte("youtube_category:"+category+"_tags"), &tags)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, echoerror.NewHttp(404, fmt.Errorf("category not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *YoutubeVideoDBTxn) SetTags(category string, tags []YoutubeTagStore) (err error) {
|
||||||
|
categories, err := t.GetCategories()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, categoryS := range categories {
|
||||||
|
if categoryS.ID == category {
|
||||||
|
|
||||||
|
return db.SetJSON(t.txn, []byte("youtube_category:"+category+"_tags"), tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return echoerror.NewHttp(404, fmt.Errorf("category not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *YoutubeVideoDBTxn) GetVideos(category string, tags []string) (videos []YoutubeVideoStore, err error) {
|
||||||
|
videos = make([]YoutubeVideoStore, 0, 16)
|
||||||
|
categories, err := t.GetCategories()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, categoryS := range categories {
|
||||||
|
if categoryS.ID == category {
|
||||||
|
tagsAvail, err := t.GetTags(category)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var tagSelected []string
|
||||||
|
for _, tag := range tags {
|
||||||
|
for _, tagAvail := range tagsAvail {
|
||||||
|
if tagAvail.ID == tag {
|
||||||
|
tagSelected = append(tagSelected, tag)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var videosS []YoutubeVideoStore
|
||||||
|
if err = db.GetJSON(t.txn, []byte("youtube_category:"+category+"_videos"), &videosS); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(tagSelected) == 0 {
|
||||||
|
return videosS, nil
|
||||||
|
}
|
||||||
|
for _, video := range videosS {
|
||||||
|
matchtag:
|
||||||
|
for _, tagA := range tagSelected {
|
||||||
|
for _, tag := range video.Tags {
|
||||||
|
if tagA == tag {
|
||||||
|
videos = append(videos, video)
|
||||||
|
break matchtag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return videos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *YoutubeVideoDBTxn) SetVideos(category string, videos []YoutubeVideoStore) (err error) {
|
||||||
|
categories, err := t.GetCategories()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, categoryS := range categories {
|
||||||
|
if categoryS.ID == category {
|
||||||
|
existingTags, err := t.GetTags(category)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tagsUsed := make(map[string]YoutubeTagStore)
|
||||||
|
for _, video := range videos {
|
||||||
|
for _, tag := range video.Tags {
|
||||||
|
found := false
|
||||||
|
for _, existingTag := range existingTags {
|
||||||
|
if existingTag.ID == tag {
|
||||||
|
tagsUsed[tag] = existingTag
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return echoerror.NewHttp(400, fmt.Errorf("tag %s not found", tag))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tagsUsedList := make([]YoutubeTagStore, 0, len(tagsUsed))
|
||||||
|
for _, tag := range tagsUsed {
|
||||||
|
tagsUsedList = append(tagsUsedList, tag)
|
||||||
|
}
|
||||||
|
if err = t.SetTags(category, tagsUsedList); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return db.SetJSON(t.txn, []byte("youtube_category:"+category+"_videos"), videos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return echoerror.NewHttp(404, fmt.Errorf("category not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerYoutube(g *echo.Group, database db.DB) {
|
||||||
|
g.GET("/categories", func(c echo.Context) error {
|
||||||
|
txn := newYoutubeDBTxn(database.NewTransaction(false))
|
||||||
|
defer txn.txn.Discard()
|
||||||
|
|
||||||
|
categories, err := txn.GetCategories()
|
||||||
|
if err != nil && !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
16
internal/filestore/fs.go
Normal 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)
|
||||||
|
}
|
1
internal/gomod/go.test.mod
Normal file
1
internal/gomod/go.test.mod
Normal file
|
@ -0,0 +1 @@
|
||||||
|
WTFIsThis_0.0.0-20190101000000-000000000000
|
19
internal/gomod/htmlTemplate.go
Normal file
19
internal/gomod/htmlTemplate.go
Normal 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
129
internal/gomod/resolve.go
Normal 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
327
internal/health/api.go
Normal 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
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
182
internal/health/api_compliance.go
Normal file
182
internal/health/api_compliance.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
92
internal/health/api_directions.go
Normal file
92
internal/health/api_directions.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
53
internal/health/api_shorthand.go
Normal file
53
internal/health/api_shorthand.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
164
internal/health/compliance.go
Normal file
164
internal/health/compliance.go
Normal 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
208
internal/health/db.go
Normal 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
244
internal/health/parser.go
Normal 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
|
||||||
|
}
|
84
internal/health/parser_test.go
Normal file
84
internal/health/parser_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
33
internal/health/template.go
Normal file
33
internal/health/template.go
Normal 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
|
||||||
|
}
|
15
internal/servetpl/funcmap/auth.go
Normal file
15
internal/servetpl/funcmap/auth.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
13
internal/servetpl/funcmap/config_test.go
Normal file
13
internal/servetpl/funcmap/config_test.go
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
package funcmap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/eternal-flame-AD/yoake/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
config.ParseConfig("../../../config-test.yml")
|
||||||
|
config.MockConfig(true, func(config *config.C) {
|
||||||
|
config.Twilio.AuthToken = "12345"
|
||||||
|
config.Twilio.SkipVerify = false
|
||||||
|
})
|
||||||
|
}
|
19
internal/servetpl/funcmap/escape.go
Normal file
19
internal/servetpl/funcmap/escape.go
Normal 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)
|
||||||
|
}
|
7
internal/servetpl/funcmap/files.go
Normal file
7
internal/servetpl/funcmap/files.go
Normal 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)
|
||||||
|
}
|
113
internal/servetpl/funcmap/funcmap.go
Normal file
113
internal/servetpl/funcmap/funcmap.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
42
internal/servetpl/funcmap/getset.go
Normal file
42
internal/servetpl/funcmap/getset.go
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
package funcmap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
)
|
||||||
|
|
||||||
|
func FuncGet(target reflect.Value, name string) (interface{}, error) {
|
||||||
|
switch target.Kind() {
|
||||||
|
case reflect.Map:
|
||||||
|
if target.IsNil() {
|
||||||
|
target = reflect.MakeMap(reflect.MapOf(target.Type().Key(), reflect.TypeOf("")))
|
||||||
|
}
|
||||||
|
v := target.MapIndex(reflect.ValueOf(name))
|
||||||
|
if !v.IsValid() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return v.Interface(), nil
|
||||||
|
case reflect.Struct:
|
||||||
|
case reflect.Interface:
|
||||||
|
return Lookup(name, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("cannot get %s from type %v", name, target.Type())
|
||||||
|
}
|
||||||
|
|
||||||
|
func FuncSet(target reflect.Value, name string, value interface{}) (interface{}, error) {
|
||||||
|
switch target.Kind() {
|
||||||
|
case reflect.Map:
|
||||||
|
target.SetMapIndex(reflect.ValueOf(name), reflect.ValueOf(value))
|
||||||
|
return "", nil
|
||||||
|
case reflect.Struct:
|
||||||
|
case reflect.Interface:
|
||||||
|
field := target.FieldByName(name)
|
||||||
|
if field.IsValid() {
|
||||||
|
field.Set(reflect.ValueOf(value))
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("cannot set %s to type %v", name, target.Type())
|
||||||
|
}
|
52
internal/servetpl/funcmap/http.go
Normal file
52
internal/servetpl/funcmap/http.go
Normal 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
|
||||||
|
}
|
20
internal/servetpl/funcmap/json.go
Normal file
20
internal/servetpl/funcmap/json.go
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
package funcmap
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
func ParseJSON(s string) (interface{}, error) {
|
||||||
|
var v interface{}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarshalJSON(v interface{}) (string, error) {
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
43
internal/servetpl/funcmap/math.go
Normal file
43
internal/servetpl/funcmap/math.go
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
package funcmap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/Knetic/govaluate"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Math(expS string, args ...interface{}) (interface{}, error) {
|
||||||
|
exp, err := govaluate.NewEvaluableExpressionWithFunctions(expS,
|
||||||
|
map[string]govaluate.ExpressionFunction{
|
||||||
|
"argv": func(arguments ...interface{}) (interface{}, error) {
|
||||||
|
|
||||||
|
if len(arguments) != 1 {
|
||||||
|
return nil, fmt.Errorf("argv expects 1 argument, got %d", len(arguments))
|
||||||
|
}
|
||||||
|
idx := int(arguments[0].(float64))
|
||||||
|
|
||||||
|
if idx < 0 || idx > len(args) {
|
||||||
|
return nil, fmt.Errorf("argv index out of range: %d", idx)
|
||||||
|
}
|
||||||
|
if idx == 0 {
|
||||||
|
return expS, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
vJ, err := json.Marshal(args[idx-1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var v interface{}
|
||||||
|
if err := json.Unmarshal(vJ, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return v, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return exp.Evaluate(nil)
|
||||||
|
}
|
254
internal/servetpl/funcmap/nipponcolor.go
Normal file
254
internal/servetpl/funcmap/nipponcolor.go
Normal 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",
|
||||||
|
}
|
41
internal/servetpl/funcmap/text.go
Normal file
41
internal/servetpl/funcmap/text.go
Normal 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
|
||||||
|
}
|
10
internal/servetpl/funcmap/theme.go
Normal file
10
internal/servetpl/funcmap/theme.go
Normal 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)
|
||||||
|
}
|
46
internal/servetpl/funcmap/trimaimg.go
Normal file
46
internal/servetpl/funcmap/trimaimg.go
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
package funcmap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"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)
|
||||||
|
}
|
15
internal/servetpl/funcmap/version.go
Normal file
15
internal/servetpl/funcmap/version.go
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
package funcmap
|
||||||
|
|
||||||
|
import "github.com/eternal-flame-AD/yoake/internal/version"
|
||||||
|
|
||||||
|
type V struct {
|
||||||
|
Version string
|
||||||
|
Date string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Version() (*V, error) {
|
||||||
|
return &V{
|
||||||
|
Version: version.Version,
|
||||||
|
Date: version.Date,
|
||||||
|
}, nil
|
||||||
|
}
|
177
internal/servetpl/middleware.go
Normal file
177
internal/servetpl/middleware.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
25
internal/servetpl/parse.go
Normal file
25
internal/servetpl/parse.go
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
package servetpl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"os"
|
||||||
|
textTemplate "text/template"
|
||||||
|
|
||||||
|
"github.com/eternal-flame-AD/yoake/internal/servetpl/funcmap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ParseTemplateFileAs[M interface{ ~map[string]any }, T interface {
|
||||||
|
*template.Template | *textTemplate.Template
|
||||||
|
Parse(string) (T, error)
|
||||||
|
New(name string) T
|
||||||
|
Funcs(funcs M) T
|
||||||
|
}](tpl T, name string, path string) (T, error) {
|
||||||
|
slurpedFile, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error reading template file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := tpl.New(name).Funcs(funcmap.GetFuncMap()).Parse(string(slurpedFile))
|
||||||
|
return res, err
|
||||||
|
}
|
39
internal/session/manager.go
Normal file
39
internal/session/manager.go
Normal 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
262
internal/twilio/api.go
Normal 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
7
internal/twilio/mime.go
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
package twilio
|
||||||
|
|
||||||
|
import "mime"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
mime.AddExtensionType(".mp3", "audio/mpeg")
|
||||||
|
}
|
118
internal/twilio/verify.go
Normal file
118
internal/twilio/verify.go
Normal 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
|
||||||
|
}
|
4
internal/uinext/README.md
Normal file
4
internal/uinext/README.md
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
# uinext
|
||||||
|
|
||||||
|
Next generation UI based on go-app
|
||||||
|
|
48
internal/uinext/apicall/apicall.go
Normal file
48
internal/uinext/apicall/apicall.go
Normal 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"`
|
||||||
|
}
|
55
internal/uinext/compo/navbar.go
Normal file
55
internal/uinext/compo/navbar.go
Normal 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(),
|
||||||
|
)
|
||||||
|
}
|
137
internal/uinext/compo/sidebar.go
Normal file
137
internal/uinext/compo/sidebar.go
Normal 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
108
internal/uinext/ui/app.go
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
45
internal/uinext/ui/page/dashboard.go
Normal file
45
internal/uinext/ui/page/dashboard.go
Normal 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("."),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
45
internal/uinext/ui/page/partial.go
Normal file
45
internal/uinext/ui/page/partial.go
Normal 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)
|
||||||
|
}
|
59
internal/uinext/ui/page/router.go
Normal file
59
internal/uinext/ui/page/router.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
36
internal/uinext/ui/static.go
Normal file
36
internal/uinext/ui/static.go
Normal 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",
|
||||||
|
}
|
9
internal/uinext/webapp/webapp.go
Normal file
9
internal/uinext/webapp/webapp.go
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
package webapp
|
||||||
|
|
||||||
|
type IWebApp struct {
|
||||||
|
BasePath string
|
||||||
|
|
||||||
|
TrimaImgBase string
|
||||||
|
}
|
||||||
|
|
||||||
|
var Singleton IWebApp
|
45
internal/util/apparmor_header.go
Normal file
45
internal/util/apparmor_header.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
25
internal/util/daterange.go
Normal file
25
internal/util/daterange.go
Normal 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
49
internal/util/slice.go
Normal 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
|
||||||
|
}
|
48
internal/utilapi/handler.go
Normal file
48
internal/utilapi/handler.go
Normal 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
Loading…
Reference in a new issue