wow: init

This is a shitty MMORPG server. Private. Do not touch.

Change-Id: Iddfce069f5895632d305a73fcaa2d963e25dc600
diff --git a/personal/q3k/wow/panel/main.go b/personal/q3k/wow/panel/main.go
new file mode 100644
index 0000000..cd1f586
--- /dev/null
+++ b/personal/q3k/wow/panel/main.go
@@ -0,0 +1,466 @@
+package main
+
+import (
+	"context"
+	"crypto/rand"
+	"encoding/hex"
+	"encoding/json"
+	"flag"
+	"fmt"
+	"html/template"
+	"io/ioutil"
+	"net/http"
+	"sync"
+	"time"
+
+	"github.com/boltdb/bolt"
+	oidc "github.com/coreos/go-oidc"
+	"github.com/golang/glog"
+	"github.com/gorilla/sessions"
+	"golang.org/x/oauth2"
+)
+
+var (
+	flagSOAPAddress       string
+	flagSOAPUsername      string
+	flagSOAPPassword      string
+	flagListen            string
+	flagSecret            string
+	flagOAuthClientID     string
+	flagOAuthClientSecret string
+	flagOAuthRedirectURL  string
+	flagDB                string
+	flagMOTD              string
+)
+
+func init() {
+	flag.Set("logtostderr", "true")
+}
+
+func main() {
+	flag.StringVar(&flagSOAPAddress, "soap_address", "http://127.0.0.1:7878", "Address of AC SOAP server")
+	flag.StringVar(&flagSOAPUsername, "soap_username", "test1", "SOAP username")
+	flag.StringVar(&flagSOAPPassword, "soap_password", "", "SOAP password")
+	flag.StringVar(&flagListen, "listen", "127.0.0.1:8080", "HTTP listen address")
+	flag.StringVar(&flagSecret, "secret", "", "Cookie secret")
+	flag.StringVar(&flagOAuthClientID, "oauth_client_id", "", "OAuth client ID")
+	flag.StringVar(&flagOAuthClientSecret, "oauth_client_secret", "", "OAuth client secret")
+	flag.StringVar(&flagOAuthRedirectURL, "oauth_redirect_url", "", "OAuth redirect URL")
+	flag.StringVar(&flagDB, "db", "db.db", "Path to database")
+	flag.StringVar(&flagMOTD, "motd", "", "Path to MOTD")
+	flag.Parse()
+
+	if flagSecret == "" {
+		glog.Exitf("-secret must be set")
+	}
+
+	var err error
+	var motd []byte
+	if flagMOTD == "" {
+		glog.Warningf("no MOTD defined, set -motd to get one")
+	} else {
+		motd, err = ioutil.ReadFile(flagMOTD)
+		if err != nil {
+			glog.Exitf("cannot read MOTD %q: %v", flagMOTD, err)
+		}
+	}
+
+	db, err := bolt.Open(flagDB, 0600, nil)
+	if err != nil {
+		glog.Exitf("opening database failed: %v", err)
+	}
+
+	provider, err := oidc.NewProvider(context.Background(), "https://sso.hackerspace.pl")
+	if err != nil {
+		glog.Exitf("newprovider: %v", err)
+	}
+	oauth2Config := oauth2.Config{
+		ClientID:     flagOAuthClientID,
+		ClientSecret: flagOAuthClientSecret,
+		RedirectURL:  flagOAuthRedirectURL,
+
+		// Discovery returns the OAuth2 endpoints.
+		Endpoint: provider.Endpoint(),
+
+		// "openid" is a required scope for OpenID Connect flows.
+		Scopes: []string{oidc.ScopeOpenID, "profile:read"},
+	}
+
+	err = db.Update(func(tx *bolt.Tx) error {
+		for _, name := range []string{
+			"emailToAccount",
+		} {
+			_, err := tx.CreateBucketIfNotExists([]byte(name))
+			if err != nil {
+				return fmt.Errorf("create bucket %q: %s", name, err)
+			}
+		}
+		return nil
+	})
+	if err != nil {
+		glog.Exitf("db setup failed: %v", err)
+	}
+
+	s := &server{
+		db:     db,
+		store:  sessions.NewCookieStore([]byte(flagSecret)),
+		oauth2: &oauth2Config,
+		motd:   string(motd),
+	}
+
+	http.HandleFunc("/", s.viewIndex)
+	http.HandleFunc("/login", s.viewLogin)
+	http.HandleFunc("/oauth", s.viewOauth)
+	http.HandleFunc("/callback", s.viewOauthCallback)
+	http.HandleFunc("/setup", s.viewOauthSetup)
+	http.HandleFunc("/reset", s.viewReset)
+	http.HandleFunc("/logout", s.viewLogout)
+
+	err = http.ListenAndServe(flagListen, nil)
+	if err != nil {
+		glog.Exitf("ListenAndServe: %v", err)
+	}
+}
+
+type server struct {
+	db     *bolt.DB
+	store  *sessions.CookieStore
+	oauth2 *oauth2.Config
+	motd   string
+
+	onlineLock     sync.RWMutex
+	onlineData     []playerinfo
+	onlineDeadline time.Time
+}
+
+var (
+	tLogin = template.Must(template.New("login").Parse(`<html>
+<title>super wow - who are you?</title>
+<body>
+<pre>
+
+ ___ _   _ _ __   ___ _ __  __      _______      __
+/ __| | | | '_ \ / _ \ '__| \ \ /\ / / _ \ \ /\ / /
+\__ \ |_| | |_) |  __/ |     \ V  V / (_) \ V  V /
+|___/\__,_| .__/ \___|_|      \_/\_/ \___/ \_/\_/
+          |_|
+ _         _       _             _           _       _
+| | _____ | | ___ (_)___ _ _ __ | | _____   | |_   _| |__
+| |/ / _ \| |/ _ \_  / _' | '_ \| |/ / _ \  | | | | | '_ \
+|   < (_) | |  __// / (_| | | | |   < (_) | | | |_| | |_) |
+|_|\_\___/|_|\___/___\__,_|_| |_|_|\_\___/  |_|\__,_|_.__/
+
+ _         _                    __
+| | _____ | | ___  __ _  ___    \ \
+| |/ / _ \| |/ _ \/ _' |/ _ \  (_) |
+|   < (_) | |  __/ (_| | (_) |  _| |
+|_|\_\___/|_|\___|\__, |\___/  (_) |
+                  |___/         /_/
+
+</pre>
+<a href="/oauth">Sign in (or create new account) with HSWAW SSO</a>.<br/>
+
+<p>
+Not a hswaw member? Talk to q3k.
+</p>
+</body>`))
+	tSetup = template.Must(template.New("setup").Parse(`<html>
+<title>super wow - setup account</title>
+<body>
+<b>hi, please provide details for your new WoW account</b><br/>
+pick any username you want, pick a 3-16 character password that isn't the same as your sso password (duh)<br />
+(this isn't your character name, this will only be used to log into WoW)
+{{ if .Error }}
+<br /><b>error</b>: {{ .Error }}<br />
+{{ end }}
+<form method="post" action="/setup">
+username:<input name="username" value={{ .Username }}></input><br/>
+password:<input name="password" type="password"></input><br/>
+<input type=submit value="create account"/>
+</form>
+</body>`))
+	tIndex = template.Must(template.New("index").Parse(`<html>
+<title>super wow</title>
+<style type="text/css">
+    body {
+        background-color: #fff;
+    }
+    table, th, td {
+        background-color: #eee;
+        padding: 0.2em 0.4em 0.2em 0.4em;
+    }
+    table th {
+        background-color: #c0c0c0;
+    }
+    table {
+        background-color: #fff;
+        border-spacing: 0.2em;
+    }
+</style>
+<body>
+<b>Hello, {{ .Username }}.</b></br>
+<a href="/logout">Log out.</a><br />
+{{ .MOTD }}
+<p>
+Your account name is {{ .Username }}. Use the password that you entered when logging in via SSO for the first time, or <form action="/reset" method="POST"><input name="password" type="password" /><input type="submit" value="set a new password"/>.</form>
+</p>
+<b>Currently in-game:</b>
+<table>
+<tr><th>Account</th><th>Character</th></tr>
+{{ range .Online }}
+<tr><td>{{ .Account }}</td><td>{{ .Character }}</td></tr>
+{{ end }}
+</table>
+</body>`))
+)
+
+func (s *server) session(r *http.Request) *sessions.Session {
+	session, _ := s.store.Get(r, sessionName)
+	return session
+}
+
+func (s *server) sessionGet(r *http.Request, k string) string {
+	v, ok := s.session(r).Values[k]
+	if !ok {
+		return ""
+	}
+	v2, ok := v.(string)
+	if !ok {
+		return ""
+	}
+	return v2
+}
+
+func (s *server) sessionPut(w http.ResponseWriter, r *http.Request, k, v string) {
+	session := s.session(r)
+	session.Values[k] = v
+	session.Save(r, w)
+}
+
+func (s *server) online(ctx context.Context) []playerinfo {
+	s.onlineLock.RLock()
+	if s.onlineData == nil || time.Now().After(s.onlineDeadline) {
+		s.onlineLock.RUnlock()
+		s.onlineLock.Lock()
+		data, err := onlinelist(ctx)
+		if err != nil {
+			glog.Errorf("onlinelist fatch failed: %v", err)
+			s.onlineDeadline = time.Now().Add(10 * time.Second)
+		} else {
+			s.onlineData = data
+			s.onlineDeadline = time.Now().Add(60 * time.Second)
+		}
+		s.onlineLock.Unlock()
+		s.onlineLock.RLock()
+	}
+
+	res := make([]playerinfo, len(s.onlineData))
+	for i, pi := range s.onlineData {
+		res[i] = pi
+	}
+	s.onlineLock.RUnlock()
+	return res
+}
+
+func (s *server) viewIndex(w http.ResponseWriter, r *http.Request) {
+	account := s.sessionGet(r, "account")
+	if account == "" {
+		http.Redirect(w, r, "/login", 302)
+		return
+	}
+	err := tIndex.Execute(w, map[string]interface{}{
+		"Username": account,
+		"Online":   s.online(r.Context()),
+		"MOTD":     template.HTML(s.motd),
+	})
+	if err != nil {
+		glog.Errorf("/: %v", err)
+		return
+	}
+}
+
+const sessionName = "wow"
+
+func (s *server) viewLogin(w http.ResponseWriter, r *http.Request) {
+	account := s.sessionGet(r, "account")
+	if account != "" {
+		http.Redirect(w, r, "/", 302)
+		return
+	}
+	err := tLogin.Execute(w, nil)
+	if err != nil {
+		glog.Errorf("/login: %v", err)
+		return
+	}
+}
+
+func (s *server) viewOauth(w http.ResponseWriter, r *http.Request) {
+	stateBytes := make([]byte, 8)
+	_, err := rand.Read(stateBytes)
+	if err != nil {
+		glog.Errorf("/oauth: random: %v", err)
+		return
+	}
+	state := hex.EncodeToString(stateBytes)
+	s.sessionPut(w, r, "ostate", state)
+	url := s.oauth2.AuthCodeURL(state)
+	http.Redirect(w, r, url, http.StatusTemporaryRedirect)
+}
+
+func (s *server) viewOauthCallback(w http.ResponseWriter, r *http.Request) {
+	if r.FormValue("errors") != "" {
+		fmt.Fprintf(w, "Errors: %s", r.FormValue("errors"))
+		return
+	}
+	state := s.sessionGet(r, "ostate")
+	if state == "" {
+		glog.Errorf("No state")
+		http.Redirect(w, r, "/", 302)
+		return
+	}
+	if state != r.FormValue("state") {
+		glog.Errorf("Invalid state")
+		http.Redirect(w, r, "/", 302)
+		return
+	}
+	oauth2Token, err := s.oauth2.Exchange(r.Context(), r.FormValue("code"))
+	if err != nil {
+		glog.Errorf("Exchange failed: %v", err)
+		http.Redirect(w, r, "/", 302)
+		return
+	}
+	client := s.oauth2.Client(r.Context(), oauth2Token)
+	res, err := client.Get("https://sso.hackerspace.pl/api/1/userinfo")
+	if err != nil {
+		glog.Errorf("Userinfo failed: %v", err)
+		http.Redirect(w, r, "/", 302)
+		return
+	}
+	defer res.Body.Close()
+	data, _ := ioutil.ReadAll(res.Body)
+
+	ui := userinfo{}
+	err = json.Unmarshal(data, &ui)
+	if err != nil || ui.Email == "" {
+		glog.Errorf("Userinfo unarshal failed: %v", err)
+		http.Redirect(w, r, "/", 302)
+		return
+	}
+
+	account, err := s.accountForEmail(ui.Email)
+	if err != nil {
+		glog.Errorf("account get failed: %v", err)
+		http.Redirect(w, r, "/", 302)
+		return
+	}
+	if account != "" {
+		s.sessionPut(w, r, "account", account)
+		http.Redirect(w, r, "/", 302)
+	} else {
+		s.sessionPut(w, r, "email", ui.Email)
+		http.Redirect(w, r, "/setup", 302)
+	}
+}
+
+func (s *server) viewOauthSetup(w http.ResponseWriter, r *http.Request) {
+	email := s.sessionGet(r, "email")
+	if email == "" {
+		glog.Errorf("No email")
+		http.Redirect(w, r, "/", 302)
+		return
+	}
+
+	if r.Method == "GET" {
+		tSetup.Execute(w, nil)
+		return
+	}
+
+	username := r.FormValue("username")
+	password := r.FormValue("password")
+	if !reAccount.MatchString(username) {
+		tSetup.Execute(w, map[string]string{
+			"Username": username,
+			"Error":    "Invalid username - must be 3-16 a-z 0-9 - _",
+		})
+		return
+	}
+	if !rePassword.MatchString(password) {
+		tSetup.Execute(w, map[string]string{
+			"Username": username,
+			"Error":    "Invalid password - must be 3-16 a-z A-Z 0-9 - _",
+		})
+		return
+	}
+
+	// this races. ugh. no way to list users. yolo.
+	err := createAccount(r.Context(), username, password)
+	if err != nil {
+		tSetup.Execute(w, map[string]string{
+			"Username": username,
+			"Error":    "Account already exists, pick a different username",
+		})
+		return
+	}
+
+	err = s.db.Update(func(tx *bolt.Tx) error {
+		b := tx.Bucket([]byte("emailToAccount"))
+		v := string(b.Get([]byte(email)))
+		if v != "" {
+			s.sessionPut(w, r, "account", v)
+			http.Redirect(w, r, "/", 302)
+			return nil
+		}
+		b.Put([]byte(email), []byte(username))
+		s.sessionPut(w, r, "account", username)
+		http.Redirect(w, r, "/", 302)
+		return nil
+	})
+	if err != nil {
+		glog.Errorf("setup tx: %v", err)
+		http.Redirect(w, r, "/", 302)
+	}
+}
+func (s *server) viewReset(w http.ResponseWriter, r *http.Request) {
+	account := s.sessionGet(r, "account")
+	if account == "" {
+		glog.Errorf("No account")
+		http.Redirect(w, r, "/", 302)
+		return
+	}
+
+	password := r.FormValue("password")
+	if !rePassword.MatchString(password) {
+		fmt.Fprintf(w, "pick a 3-16 password with not too many special chars")
+		return
+	}
+
+	// this races. ugh. no way to list users. yolo.
+	err := ensureAccount(r.Context(), account, password)
+	if err != nil {
+		glog.Errorf("ensureAccount(%q, _): %v", account, err)
+		fmt.Fprintf(w, "something went wrong, lol")
+		return
+	}
+	fmt.Fprintf(w, "new password set.")
+}
+
+func (s *server) viewLogout(w http.ResponseWriter, r *http.Request) {
+	s.sessionPut(w, r, "account", "")
+	s.sessionPut(w, r, "email", "")
+	http.Redirect(w, r, "/", 302)
+}
+
+func (s *server) accountForEmail(email string) (string, error) {
+	res := ""
+	err := s.db.View(func(tx *bolt.Tx) error {
+		b := tx.Bucket([]byte("emailToAccount"))
+		v := b.Get([]byte(email))
+		res = string(v)
+		return nil
+	})
+	return res, err
+}
+
+type userinfo struct {
+	Email string `json:"email"`
+}