init
release-tag / release-image (push) Successful in 2m40s

This commit is contained in:
2026-05-27 02:34:11 +02:00
parent 0e567d8ab2
commit 309e94d362
19 changed files with 1037 additions and 1 deletions
+243
View File
@@ -0,0 +1,243 @@
package auth
import (
"context"
"crypto"
"crypto/hmac"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"net/url"
"os"
"strings"
"time"
)
type Config struct{ BaseURL, Issuer, ClientID, ClientSecret, SessionSecret string }
type Provider struct{ AuthURL, TokenURL, JWKSURL string }
type Claims struct {
Sub, Email, Name string
Exp int64
Iss, Aud string
}
type Manager struct {
Cfg Config
P Provider
DB *sql.DB
Client *http.Client
}
func New(ctx context.Context, db *sql.DB) (*Manager, error) {
c := Config{os.Getenv("APP_BASE_URL"), os.Getenv("OIDC_ISSUER"), os.Getenv("OIDC_CLIENT_ID"), os.Getenv("OIDC_CLIENT_SECRET"), os.Getenv("SESSION_SECRET")}
if c.SessionSecret == "" {
c.SessionSecret = "dev-secret-change-me"
}
m := &Manager{Cfg: c, DB: db, Client: &http.Client{Timeout: 10 * time.Second}}
if c.Issuer != "" {
if err := m.discover(ctx); err != nil {
return nil, err
}
}
return m, nil
}
func (m *Manager) discover(ctx context.Context) error {
req, _ := http.NewRequestWithContext(ctx, "GET", strings.TrimRight(m.Cfg.Issuer, "/")+"/.well-known/openid-configuration", nil)
resp, err := m.Client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var v struct {
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
JWKSURI string `json:"jwks_uri"`
}
if err := json.NewDecoder(resp.Body).Decode(&v); err != nil {
return err
}
m.P = Provider{v.AuthorizationEndpoint, v.TokenEndpoint, v.JWKSURI}
return nil
}
func Rand(n int) string {
b := make([]byte, n)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func sign(secret, s string) string {
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(s))
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
}
func (m *Manager) SetSignedCookie(w http.ResponseWriter, name, value string, maxAge int) {
v := value + "." + sign(m.Cfg.SessionSecret, value)
http.SetCookie(w, &http.Cookie{Name: name, Value: v, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: strings.HasPrefix(m.Cfg.BaseURL, "https://"), MaxAge: maxAge})
}
func (m *Manager) ReadSignedCookie(r *http.Request, name string) (string, bool) {
c, err := r.Cookie(name)
if err != nil {
return "", false
}
p := strings.LastIndex(c.Value, ".")
if p < 1 {
return "", false
}
val, sig := c.Value[:p], c.Value[p+1:]
return val, hmac.Equal([]byte(sig), []byte(sign(m.Cfg.SessionSecret, val)))
}
func (m *Manager) Login(w http.ResponseWriter, r *http.Request) {
st := Rand(24)
m.SetSignedCookie(w, "oidc_state", st, 300)
q := url.Values{"client_id": {m.Cfg.ClientID}, "redirect_uri": {m.Cfg.BaseURL + "/auth/callback"}, "response_type": {"code"}, "scope": {"openid profile email"}, "state": {st}}
http.Redirect(w, r, m.P.AuthURL+"?"+q.Encode(), 302)
}
func (m *Manager) Callback(w http.ResponseWriter, r *http.Request) {
st, ok := m.ReadSignedCookie(r, "oidc_state")
if !ok || st != r.URL.Query().Get("state") {
http.Error(w, "bad state", 400)
return
}
tok, err := m.exchange(r.Context(), r.URL.Query().Get("code"))
if err != nil {
http.Error(w, err.Error(), 500)
return
}
cl, err := m.verify(r.Context(), tok)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
res, err := m.DB.ExecContext(r.Context(), `INSERT INTO users(subject,email,name) VALUES(?,?,?) ON DUPLICATE KEY UPDATE email=VALUES(email), name=VALUES(name), id=LAST_INSERT_ID(id)`, cl.Sub, cl.Email, cl.Name)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
uid, err := res.LastInsertId()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
m.SetSignedCookie(w, "session", fmt.Sprint(uid), 86400*30)
http.Redirect(w, r, "/", 302)
}
func (m *Manager) exchange(ctx context.Context, code string) (string, error) {
data := url.Values{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {m.Cfg.BaseURL + "/auth/callback"}, "client_id": {m.Cfg.ClientID}, "client_secret": {m.Cfg.ClientSecret}}
req, _ := http.NewRequestWithContext(ctx, "POST", m.P.TokenURL, strings.NewReader(data.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := m.Client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode > 299 {
return "", fmt.Errorf("token error: %s", body)
}
var v struct {
IDToken string `json:"id_token"`
}
json.Unmarshal(body, &v)
return v.IDToken, nil
}
func (m *Manager) verify(ctx context.Context, jwt string) (Claims, error) {
parts := strings.Split(jwt, ".")
if len(parts) != 3 {
return Claims{}, errors.New("bad jwt")
}
headB, _ := base64.RawURLEncoding.DecodeString(parts[0])
var h struct{ Kid, Alg string }
json.Unmarshal(headB, &h)
if h.Alg != "RS256" {
return Claims{}, errors.New("only RS256 supported")
}
key, err := m.jwk(ctx, h.Kid)
if err != nil {
return Claims{}, err
}
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
sum := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if err := rsa.VerifyPKCS1v15(key, crypto.SHA256, sum[:], sig); err != nil {
return Claims{}, err
}
pay, _ := base64.RawURLEncoding.DecodeString(parts[1])
var raw map[string]any
json.Unmarshal(pay, &raw)
c := Claims{Sub: fmt.Sprint(raw["sub"]), Email: fmt.Sprint(raw["email"]), Name: fmt.Sprint(raw["name"]), Iss: fmt.Sprint(raw["iss"]), Aud: fmt.Sprint(raw["aud"])}
if exp, ok := raw["exp"].(float64); ok {
c.Exp = int64(exp)
}
if c.Exp < time.Now().Unix() {
return c, errors.New("token expired")
}
return c, nil
}
func (m *Manager) jwk(ctx context.Context, kid string) (*rsa.PublicKey, error) {
req, _ := http.NewRequestWithContext(ctx, "GET", m.P.JWKSURL, nil)
resp, err := m.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var jwks struct {
Keys []map[string]string `json:"keys"`
}
json.NewDecoder(resp.Body).Decode(&jwks)
for _, k := range jwks.Keys {
if k["kid"] == kid {
nB, _ := base64.RawURLEncoding.DecodeString(k["n"])
eB, _ := base64.RawURLEncoding.DecodeString(k["e"])
e := 0
for _, b := range eB {
e = e*256 + int(b)
}
return &rsa.PublicKey{N: new(big.Int).SetBytes(nB), E: e}, nil
}
}
return nil, errors.New("jwk not found")
}
func (m *Manager) Require(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uid, ok := m.ReadSignedCookie(r, "session")
if !ok || uid == "" {
http.Redirect(w, r, "/login", 302)
return
}
next.ServeHTTP(w, r)
})
}
func Logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: "session", Path: "/", MaxAge: -1})
http.Redirect(w, r, "/login", 302)
}
func (m *Manager) RequireAdmin(next http.Handler) http.Handler {
return m.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uid, _ := m.ReadSignedCookie(r, "session")
var email string
m.DB.QueryRowContext(r.Context(), `select email from users where id=?`, uid).Scan(&email)
var allowed string
m.DB.QueryRowContext(r.Context(), `select setting_value from app_settings where setting_key='admin_emails'`).Scan(&allowed)
if strings.TrimSpace(allowed) != "" {
ok := false
for _, part := range strings.Split(allowed, ",") {
if strings.EqualFold(strings.TrimSpace(part), strings.TrimSpace(email)) {
ok = true
break
}
}
if !ok {
http.Error(w, "admin access required", http.StatusForbidden)
return
}
}
next.ServeHTTP(w, r)
}))
}
View File
+50
View File
@@ -0,0 +1,50 @@
package db
import (
"context"
"database/sql"
"fmt"
"os"
_ "github.com/go-sql-driver/mysql"
)
func Open(ctx context.Context, url string) (*sql.DB, error) {
d, err := sql.Open("mysql", url)
if err != nil {
return nil, err
}
if err := d.PingContext(ctx); err != nil {
return nil, err
}
return d, nil
}
func Migrate(ctx context.Context, d *sql.DB) error {
d.ExecContext(ctx, `SELECT GET_LOCK('trading_tool_migrate', 30)`)
defer d.ExecContext(ctx, `SELECT RELEASE_LOCK('trading_tool_migrate')`)
if _, err := d.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations(version VARCHAR(255) PRIMARY KEY, applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)`); err != nil {
return err
}
files := []string{"migrations/001_init.sql", "migrations/002_admin.sql"}
for _, f := range files {
var exists bool
if err := d.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version=?)`, f).Scan(&exists); err != nil {
return err
}
if exists {
continue
}
b, err := os.ReadFile(f)
if err != nil {
return err
}
if _, err := d.ExecContext(ctx, string(b)); err != nil {
return fmt.Errorf("%s: %w", f, err)
}
if _, err := d.ExecContext(ctx, `INSERT INTO schema_migrations(version) VALUES(?)`, f); err != nil {
return err
}
}
return nil
}
+210
View File
@@ -0,0 +1,210 @@
package handlers
import (
"database/sql"
"net/http"
"strconv"
)
func (a *App) Admin(w http.ResponseWriter, r *http.Request) {
data := map[string]any{}
data["Departments"] = a.departments()
data["Items"] = a.items()
data["Settings"] = a.settings()
data["Statuses"] = a.statuses()
data["WorkflowRules"] = a.workflowRules()
data["QuestTemplates"] = a.questTemplates()
a.T.ExecuteTemplate(w, "admin.html", data)
}
func (a *App) AdminDepartment(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
action := r.FormValue("action")
id, _ := strconv.Atoi(r.FormValue("id"))
name := r.FormValue("name")
switch action {
case "delete":
a.DB.Exec(`delete from departments where id=?`, id)
default:
if id > 0 {
a.DB.Exec(`update departments set name=? where id=?`, name, id)
} else if name != "" {
a.DB.Exec(`insert ignore into departments(name) values(?)`, name)
}
}
http.Redirect(w, r, "/admin", 303)
}
func (a *App) AdminItem(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
action := r.FormValue("action")
id, _ := strconv.Atoi(r.FormValue("id"))
sku, name, category := r.FormValue("sku"), r.FormValue("name"), r.FormValue("category")
if category == "" {
category = "item"
}
switch action {
case "delete":
a.DB.Exec(`delete from items where id=?`, id)
default:
if id > 0 {
a.DB.Exec(`update items set sku=?, name=?, category=? where id=?`, sku, name, category, id)
} else if sku != "" && name != "" {
a.DB.Exec(`insert into items(sku,name,category) values(?,?,?)`, sku, name, category)
}
}
http.Redirect(w, r, "/admin", 303)
}
func (a *App) AdminSetting(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
key, value, desc := r.FormValue("setting_key"), r.FormValue("setting_value"), r.FormValue("description")
if key != "" {
a.DB.Exec(`insert into app_settings(setting_key,setting_value,description) values(?,?,?) on duplicate key update setting_value=values(setting_value), description=values(description)`, key, value, desc)
}
http.Redirect(w, r, "/admin", 303)
}
func (a *App) AdminStatus(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
action := r.FormValue("action")
id, _ := strconv.Atoi(r.FormValue("id"))
scope, value, label := r.FormValue("scope"), r.FormValue("value"), r.FormValue("label")
sortOrder, _ := strconv.Atoi(r.FormValue("sort_order"))
terminal := r.FormValue("is_terminal") == "on"
switch action {
case "delete":
a.DB.Exec(`delete from status_options where id=?`, id)
default:
if id > 0 {
a.DB.Exec(`update status_options set scope=?, value=?, label=?, sort_order=?, is_terminal=? where id=?`, scope, value, label, sortOrder, terminal, id)
} else if scope != "" && value != "" {
a.DB.Exec(`insert into status_options(scope,value,label,sort_order,is_terminal) values(?,?,?,?,?)`, scope, value, label, sortOrder, terminal)
}
}
http.Redirect(w, r, "/admin", 303)
}
func (a *App) AdminWorkflowRule(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
action := r.FormValue("action")
id, _ := strconv.Atoi(r.FormValue("id"))
name, trigger, cond, actions := r.FormValue("name"), r.FormValue("trigger_name"), r.FormValue("condition_json"), r.FormValue("actions_json")
enabled := r.FormValue("enabled") == "on"
if cond == "" {
cond = "{}"
}
if actions == "" {
actions = "[]"
}
switch action {
case "delete":
a.DB.Exec(`delete from workflow_rules where id=?`, id)
default:
if id > 0 {
a.DB.Exec(`update workflow_rules set name=?, trigger_name=?, condition_json=?, actions_json=?, enabled=? where id=?`, name, trigger, cond, actions, enabled, id)
} else if name != "" {
a.DB.Exec(`insert into workflow_rules(name,trigger_name,condition_json,actions_json,enabled) values(?,?,?,?,?)`, name, trigger, cond, actions, enabled)
}
}
http.Redirect(w, r, "/admin", 303)
}
func (a *App) AdminQuestTemplate(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
action := r.FormValue("action")
id, _ := strconv.Atoi(r.FormValue("id"))
deptID, _ := strconv.Atoi(r.FormValue("department_id"))
sortOrder, _ := strconv.Atoi(r.FormValue("sort_order"))
reward, _ := strconv.ParseFloat(r.FormValue("reward_percent"), 64)
typ, title := r.FormValue("type"), r.FormValue("title_template")
enabled := r.FormValue("enabled") == "on"
dept := sql.NullInt64{Int64: int64(deptID), Valid: deptID > 0}
switch action {
case "delete":
a.DB.Exec(`delete from quest_templates where id=?`, id)
default:
if id > 0 {
a.DB.Exec(`update quest_templates set type=?, title_template=?, department_id=?, reward_percent=?, enabled=?, sort_order=? where id=?`, typ, title, dept, reward, enabled, sortOrder, id)
} else if typ != "" && title != "" {
a.DB.Exec(`insert into quest_templates(type,title_template,department_id,reward_percent,enabled,sort_order) values(?,?,?,?,?,?)`, typ, title, dept, reward, enabled, sortOrder)
}
}
http.Redirect(w, r, "/admin", 303)
}
func (a *App) departments() []Row {
rows, _ := a.DB.Query(`select id,name from departments order by name`)
defer rows.Close()
var out []Row
for rows.Next() {
var id int
var name string
rows.Scan(&id, &name)
out = append(out, Row{"ID": id, "Name": name})
}
return out
}
func (a *App) items() []Row {
rows, _ := a.DB.Query(`select id,sku,name,category from items order by name`)
defer rows.Close()
var out []Row
for rows.Next() {
var id int
var sku, name, cat string
rows.Scan(&id, &sku, &name, &cat)
out = append(out, Row{"ID": id, "SKU": sku, "Name": name, "Category": cat})
}
return out
}
func (a *App) settings() []Row {
rows, _ := a.DB.Query(`select setting_key,setting_value,description from app_settings order by setting_key`)
defer rows.Close()
var out []Row
for rows.Next() {
var k, v, d string
rows.Scan(&k, &v, &d)
out = append(out, Row{"Key": k, "Value": v, "Description": d})
}
return out
}
func (a *App) statuses() []Row {
rows, _ := a.DB.Query(`select id,scope,value,label,sort_order,is_terminal from status_options order by scope,sort_order`)
defer rows.Close()
var out []Row
for rows.Next() {
var id, sort int
var scope, value, label string
var term bool
rows.Scan(&id, &scope, &value, &label, &sort, &term)
out = append(out, Row{"ID": id, "Scope": scope, "Value": value, "Label": label, "Sort": sort, "Terminal": term})
}
return out
}
func (a *App) workflowRules() []Row {
rows, _ := a.DB.Query(`select id,name,trigger_name,condition_json,actions_json,enabled from workflow_rules order by name`)
defer rows.Close()
var out []Row
for rows.Next() {
var id int
var name, trig, cond, actions string
var en bool
rows.Scan(&id, &name, &trig, &cond, &actions, &en)
out = append(out, Row{"ID": id, "Name": name, "Trigger": trig, "Condition": cond, "Actions": actions, "Enabled": en})
}
return out
}
func (a *App) questTemplates() []Row {
rows, _ := a.DB.Query(`select qt.id,qt.type,qt.title_template,coalesce(qt.department_id,0),coalesce(d.name,''),qt.reward_percent,qt.enabled,qt.sort_order from quest_templates qt left join departments d on d.id=qt.department_id order by qt.sort_order`)
defer rows.Close()
var out []Row
for rows.Next() {
var id, deptID, sort int
var typ, title, dept string
var reward float64
var en bool
rows.Scan(&id, &typ, &title, &deptID, &dept, &reward, &en, &sort)
out = append(out, Row{"ID": id, "Type": typ, "Title": title, "DeptID": deptID, "Dept": dept, "Reward": reward, "Enabled": en, "Sort": sort})
}
return out
}
+123
View File
@@ -0,0 +1,123 @@
package handlers
import (
"database/sql"
"html/template"
"net/http"
"strconv"
"strings"
)
type App struct {
DB *sql.DB
T *template.Template
}
type Row map[string]any
func New(db *sql.DB) *App {
return &App{DB: db, T: template.Must(template.ParseGlob("web/templates/*.html"))}
}
func (a *App) Home(w http.ResponseWriter, r *http.Request) {
reqs, _ := a.DB.Query(`select tr.id,tr.kind,i.name,tr.quantity,tr.price_limit,tr.status,tr.created_at from trade_requests tr join items i on i.id=tr.item_id order by tr.id desc limit 30`)
defer reqs.Close()
var rs []Row
for reqs.Next() {
var id, qty int
var kind, item, status string
var created []byte
var price float64
reqs.Scan(&id, &kind, &item, &qty, &price, &status, &created)
rs = append(rs, Row{"ID": id, "Kind": kind, "Item": item, "Qty": qty, "Price": price, "Status": status, "Created": string(created)})
}
qs, _ := a.DB.Query(`select q.id,q.type,q.title,coalesce(d.name,''),q.status,coalesce(q.contractor_note,'') from quests q left join departments d on d.id=q.department_id order by q.id desc limit 50`)
defer qs.Close()
var quests []Row
for qs.Next() {
var id int
var typ, title, dept, status, note string
qs.Scan(&id, &typ, &title, &dept, &status, &note)
quests = append(quests, Row{"ID": id, "Type": typ, "Title": title, "Dept": dept, "Status": status, "Note": note})
}
a.T.ExecuteTemplate(w, "home.html", map[string]any{"Requests": rs, "Quests": quests})
}
func (a *App) LoginPage(w http.ResponseWriter, r *http.Request) {
a.T.ExecuteTemplate(w, "login.html", nil)
}
func (a *App) NewRequest(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
rows, _ := a.DB.Query(`select id,name from items order by name`)
defer rows.Close()
var items []Row
for rows.Next() {
var id int
var name string
rows.Scan(&id, &name)
items = append(items, Row{"ID": id, "Name": name})
}
a.T.ExecuteTemplate(w, "request.html", map[string]any{"Items": items})
return
}
r.ParseForm()
item, _ := strconv.Atoi(r.FormValue("item_id"))
qty, _ := strconv.Atoi(r.FormValue("quantity"))
price, _ := strconv.ParseFloat(r.FormValue("price_limit"), 64)
kind := r.FormValue("kind")
if kind != "sell" {
kind = "buy"
}
res, err := a.DB.Exec(`insert into trade_requests(kind,item_id,quantity,price_limit,status) values(?,?,?,?, 'new')`, kind, item, qty, price)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
id, _ := res.LastInsertId()
a.generateQuests(r, id, kind, item, qty, price)
http.Redirect(w, r, "/", 303)
}
func (a *App) generateQuests(r *http.Request, reqID int64, kind string, itemID, qty int, price float64) {
var item string
a.DB.QueryRowContext(r.Context(), `select name from items where id=?`, itemID).Scan(&item)
var auto string
a.DB.QueryRowContext(r.Context(), `select setting_value from app_settings where setting_key='auto_generate_quests'`).Scan(&auto)
if auto == "false" || auto == "0" || auto == "no" {
a.DB.ExecContext(r.Context(), `insert into events(type,payload) values('request.created', JSON_OBJECT('request_id',?,'kind',?,'item_id',?,'quantity',?,'price',?,'auto_generate',false))`, reqID, kind, itemID, qty, price)
return
}
rows, err := a.DB.QueryContext(r.Context(), `select type,title_template,department_id,reward_percent from quest_templates where enabled=true order by sort_order,id`)
if err != nil {
return
}
defer rows.Close()
var prev sql.NullInt64
created := 0
for rows.Next() {
var typ, title string
var dept sql.NullInt64
var percent float64
rows.Scan(&typ, &title, &dept, &percent)
title = strings.ReplaceAll(title, "{{item}}", item)
title = strings.ReplaceAll(title, "{{quantity}}", strconv.Itoa(qty))
res, err := a.DB.ExecContext(r.Context(), `insert into quests(parent_request_id,depends_on_quest_id,type,title,department_id,reward,status) values(?,?,?,?,?,?,'open')`, reqID, prev, typ, title, dept, price*percent)
if err == nil {
qid, _ := res.LastInsertId()
prev = sql.NullInt64{Int64: qid, Valid: true}
created++
}
}
a.DB.ExecContext(r.Context(), `insert into events(type,payload) values('request.created', JSON_OBJECT('request_id',?,'kind',?,'item_id',?,'quantity',?,'price',?,'quests_created',?))`, reqID, kind, itemID, qty, price, created)
}
func (a *App) UpdateQuest(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
id, _ := strconv.Atoi(r.FormValue("id"))
status := r.FormValue("status")
note := r.FormValue("note")
if status == "" {
status = "open"
}
a.DB.Exec(`update quests set status=?, contractor_note=? where id=?`, status, note, id)
a.DB.Exec(`insert into quest_updates(quest_id,status,note) values(?,?,?)`, id, status, note)
if status == "done" {
a.DB.Exec(`update quests set status='open' where depends_on_quest_id=? and status='waiting'`, id)
}
http.Redirect(w, r, "/", 303)
}