hswaw/voucherchecker: init

Change-Id: Id79ae9b14f61edf2f4abb3d9a60294edd6074f29
diff --git a/hswaw/voucherchecker/BUILD.bazel b/hswaw/voucherchecker/BUILD.bazel
new file mode 100644
index 0000000..17bd5da
--- /dev/null
+++ b/hswaw/voucherchecker/BUILD.bazel
@@ -0,0 +1,41 @@
+load("@io_bazel_rules_docker//container:container.bzl", "container_image", "container_layer", "container_push")
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["main.go"],
+    importpath = "code.hackerspace.pl/hscloud/hswaw/voucherchecker",
+    visibility = ["//visibility:private"],
+    deps = ["@com_github_golang_glog//:go_default_library"],
+)
+
+go_binary(
+    name = "voucherchecker",
+    embed = [":go_default_library"],
+    visibility = ["//visibility:public"],
+)
+
+container_layer(
+    name = "layer_bin",
+    files = [
+        ":voucherchecker",
+    ],
+    directory = "/voucherchecker/",
+)
+
+container_image(
+    name = "runtime",
+    base = "@prodimage-bionic//image",
+    layers = [
+        ":layer_bin",
+    ],
+)
+
+container_push(
+    name = "push",
+    image = ":runtime",
+    format = "Docker",
+    registry = "registry.k0.hswaw.net",
+    repository = "q3k/voucherchecker",
+    tag = "{BUILD_TIMESTAMP}-{STABLE_GIT_COMMIT}",
+)
diff --git a/hswaw/voucherchecker/main.go b/hswaw/voucherchecker/main.go
new file mode 100644
index 0000000..4708add
--- /dev/null
+++ b/hswaw/voucherchecker/main.go
@@ -0,0 +1,241 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"flag"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/http/cookiejar"
+	"regexp"
+	"strings"
+	"time"
+
+	"github.com/golang/glog"
+)
+
+func init() {
+	flag.Set("logtostderr", "true")
+}
+
+var (
+	flagListen string
+
+	reVoucher = regexp.MustCompile("[A-Z0-9]+")
+)
+
+type voucherstatus int
+
+const (
+	statusUnknown voucherstatus = iota
+	statusInvalid
+	statusUnused
+	statusUsed
+)
+
+func (v voucherstatus) String() string {
+	switch v {
+	case statusInvalid:
+		return "INVALID"
+	case statusUnused:
+		return "UNUSED"
+	case statusUsed:
+		return "USED"
+	}
+	return "UNKNOWN"
+}
+
+type vouchercache struct {
+	status  voucherstatus
+	expires time.Time
+}
+
+func (c *vouchercache) fresh() bool {
+	if c.status == statusUsed {
+		return true
+	}
+	if c.expires.Before(time.Now()) {
+		return false
+	}
+	return true
+}
+
+type statusReq struct {
+	voucher string
+	res     chan voucherstatus
+}
+
+type refreshRes struct {
+	voucher string
+	status  voucherstatus
+}
+
+type service struct {
+	statusReq chan *statusReq
+
+	pretixSem chan struct{}
+}
+
+func newService() *service {
+	return &service{
+		statusReq: make(chan *statusReq),
+		pretixSem: make(chan struct{}, 3),
+	}
+}
+
+func (s *service) worker(ctx context.Context) error {
+	cache := make(map[string]*vouchercache)
+	waiters := make(map[string][]chan voucherstatus)
+	refreshes := make(chan *refreshRes)
+
+	for {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+
+		// is there a refresh pending?
+		case ref := <-refreshes:
+			glog.Infof("cache feed: %v is %v", ref.voucher, ref.status)
+			expires := 30 * time.Minute
+			if ref.status == statusInvalid {
+				expires = 48 * time.Hour
+			}
+			cache[ref.voucher] = &vouchercache{
+				status:  ref.status,
+				expires: time.Now().Add(expires),
+			}
+			for _, w := range waiters[ref.voucher] {
+				w := w
+				go func() {
+					w <- ref.status
+				}()
+			}
+			delete(waiters, ref.voucher)
+
+		// is there a new request?
+		case req := <-s.statusReq:
+			// return cache if fresh
+			if el, ok := cache[req.voucher]; ok && el.fresh() {
+				go func() {
+					glog.Infof("cache hit: %v is %v", req.voucher, el.status)
+					req.res <- el.status
+				}()
+				continue
+			}
+			// is someone waiting for a refresh already?
+			if _, ok := waiters[req.voucher]; ok {
+				glog.Infof("cache miss, secondary: %v", req.voucher)
+				waiters[req.voucher] = append(waiters[req.voucher], req.res)
+				continue
+			}
+			// request refresh
+			glog.Infof("cache miss, primary: %v", req.voucher)
+			waiters[req.voucher] = []chan voucherstatus{req.res}
+			go func() {
+				s := s.getStatus(ctx, req.voucher)
+				refreshes <- &refreshRes{
+					voucher: req.voucher,
+					status:  s,
+				}
+			}()
+		}
+	}
+}
+
+func (s *service) run() {
+	mux := http.NewServeMux()
+	mux.HandleFunc("/status", s.handlerStatus)
+
+	ctx := context.Background()
+	go s.worker(ctx)
+
+	glog.Infof("Listening on %s...", flagListen)
+	if err := http.ListenAndServe(flagListen, mux); err != nil {
+		glog.Exitf("could not listen: %v", err)
+	}
+}
+
+func (s *service) handlerStatus(w http.ResponseWriter, r *http.Request) {
+	status := statusUnknown
+	defer func() {
+		e := json.NewEncoder(w)
+		e.Encode(struct {
+			Status string
+		}{
+			Status: status.String(),
+		})
+	}()
+
+	voucher := r.URL.Query().Get("voucher")
+	if voucher == "" || !strings.HasPrefix(voucher, "CHAOS") {
+		status = statusInvalid
+		return
+	}
+	if !reVoucher.MatchString(voucher) {
+		status = statusInvalid
+		return
+	}
+
+	resC := make(chan voucherstatus)
+	s.statusReq <- &statusReq{
+		voucher: voucher,
+		res:     resC,
+	}
+	status = <-resC
+}
+
+func (s *service) getStatus(ctx context.Context, voucher string) voucherstatus {
+	s.pretixSem <- struct{}{}
+	defer func() {
+		<-s.pretixSem
+	}()
+
+	cookieJar, _ := cookiejar.New(nil)
+	client := &http.Client{
+		Jar: cookieJar,
+	}
+
+	res, err := client.Get(fmt.Sprintf("https://tickets.events.ccc.de/36c3/redeem/?voucher=%s&subevent=&hello=this-is-q3k-at-hackerspace-pl-we-use-this-for-voucher-distribution", voucher))
+	if err != nil {
+		glog.Errorf("Getting main page: %v", err)
+		return statusUnknown
+	}
+	defer res.Body.Close()
+
+	setcookie := res.Header.Get("Set-Cookie")
+	if !strings.HasPrefix(setcookie, "pretix_session") {
+		glog.Errorf("Main page did not return session")
+		return statusUnknown
+	}
+
+	data, err := ioutil.ReadAll(res.Body)
+	if err != nil {
+		glog.Errorf("Reading result page: %v", err)
+		return statusUnknown
+	}
+
+	if strings.Contains(string(data), "not known") {
+		return statusInvalid
+	}
+	if strings.Contains(string(data), "already been") {
+		return statusUsed
+	}
+	if strings.Contains(string(data), "You entered a voucher code that allows you ") {
+		return statusUnused
+	}
+
+	glog.Errorf("Unexpected result for %s", voucher)
+	glog.Infof("%s", data)
+	status := statusUnknown
+
+	return status
+}
+
+func main() {
+	flag.StringVar(&flagListen, "listen", ":8081", "Listen address")
+	flag.Parse()
+
+	s := newService()
+	s.run()
+}