Skip to content

Commit

Permalink
Implement a Web UI (#2)
Browse files Browse the repository at this point in the history
* WIP web ui initial commit

* update url structure

* web ui progress

* configure CI to run golint

* actually install golint before running it

* add a GetTotalPoints() method to database

* add templating, better webui config, and a functional site

* oops

* implement authentication

* update README to include documentatin for the web UI

* that might not have been necessary after all

* update circleci config
  • Loading branch information
kamaln7 authored Oct 17, 2016
1 parent f5bd81b commit db99488
Show file tree
Hide file tree
Showing 17 changed files with 951 additions and 21 deletions.
39 changes: 36 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ karmabot is a Slack bot that listens for and performs karma operations (aka upvo
1. `git clone -b v0.1.0 https://github.com/kamaln7/karmabot.git`
2. run `go get` and then `go build` inside the repo's root
1. `cd karmabot`
1. `go get`
1. `go build`
2. `go get`
3. `go build`

### Download a Pre-built Release

Expand All @@ -54,13 +54,46 @@ karmabot is a Slack bot that listens for and performs karma operations (aka upvo
| option | required? | description | default |
| ----------------------- | --------- | ---------------------------------------- | -------------- |
| `-token string` | **yes** | slack RTM token | |
| `-debug bool` | no | set debug mode | `false` |
| `-debug bool` | no | set debug mode | `false` |
| `-db string` | no | path to sqlite database | `./db.sqlite3` |
| `-leaderboardlimit int` | no | the default amount of users to list in the leaderboard | `10` |
| `-maxpoints int` | no | the maximum amount of points that users can give/take at once | `6` |

In addition, see the table below for the options related to the web UI.

**example:** `./karmabot -token xoxb-abcdefg`

## Web UI

karmabot includes an optional web UI. The web UI uses TOTP tokens for authentication. While the token itself would only be valid for 30 seconds, once you have authenticated, you will stay so for 48 hours, after which your session will expire. This is not meant to be a fully-featured advanced authentication system, but rather a simple way to keep off people who do not belong to your Slack team.

### How to use the Web UI

#### Requisites

1. download the `www` directory from the repo's root and place it in a directory that is accessible to karmabot.
2. run `./karmabot -token YOUR_SLACK_TOKEN -webuipath /path/to/www -listenaddr 127.0.0.1:9000`. The initial values do not matter, as they will not be used at all. karmabot will generate a random TOTP key for you to use, print it, and exit. Copy that token.

#### Start karmabot

Once you have performed the steps detailed above, pass the necessary options to the `karmabot` binary:

| option | required? | description | default |
| -------------------- | --------- | ---------------------------------------- | --------------------------------- |
| `-listenaddr string` | **yes** | the address (`host:port`) on which to serve the web UI | |
| `-totp string` | **yes** | the TOTP key (see above) | |
| `-webuipath string` | **yes** | path to the `www` directory (see above) | |
| `-webuiurl string` | no | the URL which karmabot should use to generate links to the web UI | defaults to `-listenaddr`'s value |


If done correctly, the web UI should be accessible on the `listenaddr` that you have configured.

#### Usage

The web UI is authenticated, so you will have to generate authentication tokens through karmabot. You can access the web UI by typing `karmabot web` in the chat. karmabot will generate a TOTP token, append it to the `webuiurl` and send back the link. Click on the link and you should be authenticated for 48 hours.

Additionally, you may use also use the link provided in the Slack leaderboard (`karmabot leaderboard`) in order to log in and access the leaderboard.

## License

see [./LICENSE](/LICENSE)
10 changes: 6 additions & 4 deletions circle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,19 @@ machine:
environment:
PROJECT: $CIRCLE_PROJECT_REPONAME
IMPORT_PATH: "github.com/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME"
ROOTDIR: "/home/ubuntu/.go_workspace/src/$IMPORT_PATH"
dependencies:
override:
- mkdir -p "$GOPATH/src/$IMPORT_PATH"
- rsync -azC --delete ./ "$GOPATH/src/$IMPORT_PATH/"
- cd "$GOPATH/src/$IMPORT_PATH" && go get -t -d -v ./...
- cd $ROOTDIR && go get -t -d -v ./...
post:
- go get github.com/golang/lint/golint
test:
pre:
- go version
- go env
override:
- cd "$GOPATH/src/$IMPORT_PATH" && go vet ./...
- cd $ROOTDIR && go vet ./...
- cd $ROOTDIR && golint ./...
post:
- GOOS=linux GOARCH=amd64 go build -o $CIRCLE_ARTIFACTS/$PROJECT.linux.amd64 $IMPORT_PATH
# - GOOS=darwin GOARCH=amd64 go build -o $CIRCLE_ARTIFACTS/$PROJECT.darwin.amd64 $IMPORT_PATH
Expand Down
14 changes: 14 additions & 0 deletions database/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,17 @@ func GetLeaderboard(limit int) (Leaderboard, error) {

return leaderboard, nil
}

// GetTotalPoints returns the amount of points given or taken
// for all users
func GetTotalPoints() (int, error) {
var res int

err := db.QueryRow("select sum(abs(`points`)) from karma").Scan(&res)

if err != nil {
return 0, err
}

return res, nil
}
86 changes: 72 additions & 14 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,21 @@ import (
"github.com/nlopes/slack"

"github.com/kamaln7/karmabot/database"
"github.com/kamaln7/karmabot/webui"
)

var (
motivateRegexp = regexp.MustCompile(`^(?:\?|!)m\s+@?([^\s]+?)(?:\:\s)?$`)
karmaRegexp = regexp.MustCompile(`^@?([^\s]+?)(?:\:\s)?([\+]{2,}|[\-]{2,})((?: for)? (.*))?$`)
leaderboardRegexp = regexp.MustCompile(`^karma(?:bot)? (?:leaderboard|top|highscores) ?([0-9]+)?$`)
slackUserRegexp = regexp.MustCompile(`^<@([A-Za-z0-9]+)>$`)
regexps = map[string]*regexp.Regexp{
"motivate": regexp.MustCompile(`^(?:\?|!)m\s+@?([^\s]+?)(?:\:\s)?$`),
"karma": regexp.MustCompile(`^@?([^\s]+?)(?:\:\s)?([\+]{2,}|[\-]{2,})((?: for)? (.*))?$`),
"leaderboard": regexp.MustCompile(`^karma(?:bot)? (?:leaderboard|top|highscores) ?([0-9]+)?$`),
"url": regexp.MustCompile(`^karma(?:bot)? (?:url|web|link)?$`),
"slackUser": regexp.MustCompile(`^<@([A-Za-z0-9]+)>$`),
}

debug bool
hasWebUI bool
webUIURL string
maxPoints, leaderboardLimit int
bot *slack.Client
rtm *slack.RTM
Expand All @@ -36,12 +42,25 @@ func main() {
flagMaxPoints = flag.Int("maxpoints", 6, "the maximum amount of points that users can give/take at once")
flagLeaderboardLimit = flag.Int("leaderboardlimit", 10, "the default amount of users to list in the leaderboard")
flagDebug = flag.Bool("debug", false, "set debug mode")
flagTOTP = flag.String("totp", "", "totp key")
flagWebUIPath = flag.String("webuipath", "", "path to web UI files")
flagListenAddr = flag.String("listenaddr", "", "address to listen and serve the web ui on")
flagWebUIURL = flag.String("webuiurl", "", "url address for accessing the web ui")
)

flag.Parse()
maxPoints = *flagMaxPoints
leaderboardLimit = *flagLeaderboardLimit
debug = *flagDebug
hasWebUI = *flagWebUIPath != "" && *flagListenAddr != ""
if hasWebUI {
if *flagWebUIURL != "" {
webUIURL = *flagWebUIURL
} else {
webUIURL = fmt.Sprintf("http://%s/", *flagListenAddr)
}
}

if *flagToken == "" {
ll.Fatalln("please pass the slack RTM token using the -token option")
}
Expand All @@ -55,6 +74,20 @@ func main() {
rtm = bot.NewRTM()
go rtm.ManageConnection()

if hasWebUI {
webUIConfig := &webui.Config{
Logger: ll,
TOTPKey: *flagTOTP,
ListenAddr: *flagListenAddr,
FilesPath: *flagWebUIPath,
LeaderboardLimit: leaderboardLimit,
Debug: debug,
}

webui.Init(webUIConfig)
go webui.Listen()
}

for {
select {
case msg := <-rtm.IncomingEvents:
Expand Down Expand Up @@ -86,21 +119,38 @@ func handleMessage(msg slack.RTMEvent) {
}

// convert motivates into karmabot syntax
if match := motivateRegexp.FindStringSubmatch(ev.Text); len(match) > 0 {
if match := regexps["motivate"].FindStringSubmatch(ev.Text); len(match) > 0 {
ev.Text = match[1] + "++ for doing good work"
}

if karmaRegexp.MatchString(ev.Text) {
switch {
case regexps["url"].MatchString(ev.Text):
printURL(ev)

case regexps["karma"].MatchString(ev.Text):
givePoints(ev)
}

if leaderboardRegexp.MatchString(ev.Text) {
case regexps["leaderboard"].MatchString(ev.Text):
printLeaderboard(ev)
}
}

func printURL(ev *slack.MessageEvent) {
if !hasWebUI {
rtm.SendMessage(rtm.NewOutgoingMessage("webui not enabled. please pass the `-webuipath`, `-webuiurl`, and `-listenaddr` options in order to enable the web ui", ev.Channel))
return
}

token, err := webui.GetToken()
if handleError(err, ev.Channel) {
return
}

rtm.SendMessage(rtm.NewOutgoingMessage(fmt.Sprintf("%s?token=%s", webUIURL, token), ev.Channel))
}

func givePoints(ev *slack.MessageEvent) {
match := karmaRegexp.FindStringSubmatch(ev.Text)
match := regexps["karma"].FindStringSubmatch(ev.Text)
if len(match) == 0 {
return
}
Expand Down Expand Up @@ -154,7 +204,7 @@ func givePoints(ev *slack.MessageEvent) {
}

func printLeaderboard(ev *slack.MessageEvent) {
match := leaderboardRegexp.FindStringSubmatch(ev.Text)
match := regexps["leaderboard"].FindStringSubmatch(ev.Text)
if len(match) == 0 {
return
}
Expand All @@ -167,15 +217,23 @@ func printLeaderboard(ev *slack.MessageEvent) {
return
}
}
text := fmt.Sprintf("top %d leaderboard\n", limit)

text := fmt.Sprintf("*top %d leaderboard*\n", limit)

if hasWebUI {
token, err := webui.GetToken()
if err == nil {
text += fmt.Sprintf("%sleaderboard/%d?token=%s\n", webUIURL, limit, token)
}
}

leaderboard, err := database.GetLeaderboard(limit)
if handleError(err, ev.Channel) {
return
}

for _, user := range leaderboard {
text += fmt.Sprintf("%s == %d\n", munge(user.User), user.Points)
for i, user := range leaderboard {
text += fmt.Sprintf("%d. %s == %d\n", i+1, munge(user.User), user.Points)
}

rtm.SendMessage(rtm.NewOutgoingMessage(text, ev.Channel))
Expand All @@ -192,7 +250,7 @@ func handleError(err error, to string) bool {
}

func parseUser(user string) (string, error) {
if match := slackUserRegexp.FindStringSubmatch(user); len(match) > 0 {
if match := regexps["slackUser"].FindStringSubmatch(user); len(match) > 0 {
return getUserNameByID(match[1])
}

Expand Down
106 changes: 106 additions & 0 deletions webui/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package webui

import (
"net/http"
"sync"
"time"

"github.com/pquerna/otp/totp"
"github.com/satori/go.uuid"
)

type authMiddleware struct {
handler http.Handler
}

type Client struct {
UUID string
Added time.Time
}

var (
authedClients []*Client
clientsMutex sync.RWMutex
)

func mustAuth(h http.HandlerFunc) *authMiddleware {
return &authMiddleware{http.HandlerFunc(h)}
}

func (h *authMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")

if err != nil {
if err != http.ErrNoCookie {
config.Logger.Printf("could not auth user: %s\n", err.Error())
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)

return
}

err = nil
}

authed := false
if cookie != nil {
clientsMutex.RLock()
for _, client := range authedClients {
if cookie.Value == client.UUID {
authed = true
break
}
}
clientsMutex.RUnlock()
}

if !authed && hasValidTOTPToken(r) {
cookie = &http.Cookie{
Name: "session",
Value: uuid.NewV4().String(),
}

clientsMutex.Lock()
authedClients = append(authedClients, &Client{
UUID: cookie.Value,
Added: time.Now(),
})
clientsMutex.Unlock()

authed = true
http.SetCookie(w, cookie)
}

if !authed {
renderTemplate(w, "unauthed.html", &templateData{
Config: config,
Data: nil,
})
return
}

h.handler.ServeHTTP(w, r)
}

func hasValidTOTPToken(r *http.Request) bool {
token := r.URL.Query().Get("token")
if token == "" {
return false
}

return totp.Validate(token, config.TOTPKey)
}

func expireClients() {
for {
<-time.After(2 * time.Minute)
go func() {
now := time.Now()

for i, client := range authedClients {
if now.Sub(client.Added).Hours() >= 48 {
authedClients = append(authedClients[:i], authedClients[i+1:]...)
}
}
}()
}
}
Loading

0 comments on commit db99488

Please sign in to comment.