ampelmaennchen.git

commit df4ccb956a6718407ddb1592677bebff36b83994

Author: Adam Evyčędo <git@apiote.xyz>

return user info, create and validate tickets

 accounts/subscriptions.go | 129 ++++++++++++++++++++++++++++++++++
 accounts/users.go | 8 ++
 config-example.toml | 4 +
 config/config.go | 6 +
 db/migrations/20240529_users.sql | 6 +
 db/subscriptions.go | 106 +++++++++++++++++++++++++++
 db/users.go | 56 +-------------
 go.mod | 2 
 go.sum | 6 +
 main.go | 57 ++++++++++++++
 server/router.go | 19 ++--
 server/users.go | 117 ++++++++++++++++++++++++++---


diff --git a/accounts/subscriptions.go b/accounts/subscriptions.go
new file mode 100644
index 0000000000000000000000000000000000000000..b86a68074f32909329269e02ba928e1283e4827d
--- /dev/null
+++ b/accounts/subscriptions.go
@@ -0,0 +1,129 @@
+package accounts
+
+import (
+	"bytes"
+	"crypto"
+	"crypto/ed25519"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"time"
+
+	"git.sr.ht/~sircmpwn/go-bare"
+	"github.com/google/uuid"
+)
+
+type Seat string
+
+func ParseSeat(s string) (Seat, error) {
+	switch s {
+	case string(SEAT_BACK):
+		return SEAT_BACK, nil
+	case string(SEAT_WINDOW):
+		return SEAT_WINDOW, nil
+	case string(SEAT_FRONT):
+		return SEAT_FRONT, nil
+	case string(SEAT_DRIVER):
+		return SEAT_DRIVER, nil
+	case string(SEAT_PILOT):
+		return SEAT_PILOT, nil
+	case string(SEAT_GARAGE):
+		return SEAT_GARAGE, nil
+	default:
+		return SEAT_BACK, errors.New("No such seat: " + s)
+	}
+}
+
+func (s Seat) String() string {
+	return string(s)
+}
+
+const (
+	SEAT_BACK   Seat = "back"
+	SEAT_WINDOW Seat = "window"
+	SEAT_FRONT  Seat = "front"
+	SEAT_DRIVER Seat = "driver"
+	SEAT_PILOT  Seat = "pilot"
+	SEAT_GARAGE Seat = "garage"
+)
+
+type Subscription struct {
+	ID         string
+	Plan       Seat
+	ValidSince time.Time
+	Validity   int    // months
+	Creator    string // user ID
+	Signature  []byte
+	// TODO payment ID
+}
+
+type SubscriptionTicket struct {
+	ID        string
+	Plan      string
+	Validity  int    // months
+	Creator   string // user ID
+	Signature []byte
+}
+
+func (s SubscriptionTicket) Marshal() ([]byte, error) {
+	bytes, err := bare.Marshal(&s)
+	return bytes, err
+}
+
+func ParseSubscription(s string) (SubscriptionTicket, error) {
+	t := SubscriptionTicket{}
+	bytes, err := base64.StdEncoding.DecodeString(s)
+	if err != nil {
+		return SubscriptionTicket{}, fmt.Errorf("while decoding base64: %w", err)
+	}
+	err = bare.Unmarshal(bytes, &t)
+	if err != nil {
+		return t, fmt.Errorf("while unmarshalling: %w", err)
+	}
+	return t, nil
+}
+
+func CreateSubscription(plan Seat, validity int, keySeed []byte) error {
+	subscription := SubscriptionTicket{
+		ID:       uuid.NewString(),
+		Plan:     plan.String(),
+		Validity: validity,
+		Creator:  "cli",
+	}
+
+	signed, err := subscription.Marshal()
+	if err != nil {
+		return fmt.Errorf("while marshalling signed subscription: %w", err)
+	}
+
+	fmt.Println(base64.StdEncoding.EncodeToString(signed))
+
+	return nil
+}
+
+func sign(subscription SubscriptionTicket, keySeed []byte) (SubscriptionTicket, error) {
+	privateKey := ed25519.NewKeyFromSeed(keySeed)
+
+	unsigned, err := subscription.Marshal()
+	if err != nil {
+		return subscription, fmt.Errorf("while marshalling unsigned subscription: %w", err)
+	}
+
+	signature, err := privateKey.Sign(nil, unsigned, crypto.Hash(0))
+	if err != nil {
+		return subscription, fmt.Errorf("while signing subscription: %w", err)
+	}
+	subscription.Signature = signature
+
+	return subscription, nil
+}
+
+func CheckSignature(s SubscriptionTicket, keySeed []byte) (bool, error) {
+	signature := s.Signature
+	s.Signature = []byte{}
+	signed, err := sign(s, keySeed)
+	if err != nil {
+		return false, err
+	}
+	return bytes.Compare(signature, signed.Signature) == 0, nil
+}




diff --git a/accounts/users.go b/accounts/users.go
new file mode 100644
index 0000000000000000000000000000000000000000..1862b25a8e2fa36533466ea0d6e2b3d4cd942e7f
--- /dev/null
+++ b/accounts/users.go
@@ -0,0 +1,8 @@
+package accounts
+
+type User struct {
+	Name          string
+	MatrixHandle  string
+	Email         string
+	Subscriptions []Subscription
+}




diff --git a/config/config.go b/config/config.go
index 767b65f95141d42b86367808213d7097d68eb947..6b61a788746fe0d92de15945b5beca0e101e242b 100644
--- a/config/config.go
+++ b/config/config.go
@@ -42,6 +42,11 @@ 	Db       string
 	Params   string
 }
 
+type ServerConfig struct {
+	Host string
+	Port int
+}
+
 func (c DatabaseConfig) Uri() string {
 	return fmt.Sprintf("%s://%s:%s@%s:%d/%s?%s", c.Schema, c.Username, c.Password, c.Host, c.Port, c.Db, c.Params)
 }
@@ -49,6 +54,7 @@
 type Config struct {
 	Matrix   MatrixConfig
 	Database DatabaseConfig
+	Server   ServerConfig
 }
 
 func Load(path string) (config Config, err error) {




diff --git a/config-example.toml b/config-example.toml
index f3bf014c11cf39e0fcf65568d7136fe48c5ef21b..d6093372ab57e74c06125b871c4192f58f78883a 100644
--- a/config-example.toml
+++ b/config-example.toml
@@ -23,3 +23,7 @@ host = '127.0.0.1'
 port = 5432
 db = ampelmaennchen
 params = 'sslmode=disable'
+
+[server]
+host = 'localhost'
+port = 43637




diff --git a/db/migrations/20240529_users.sql b/db/migrations/20240529_users.sql
index 6cce2bb69fd0beba68cf42caea9aeebc04e2297a..ca119e0e8af1b1bd3bd85094c5a29109478ddc77 100644
--- a/db/migrations/20240529_users.sql
+++ b/db/migrations/20240529_users.sql
@@ -1,2 +1,4 @@
-create table users(id text primary key, matrix_handle text, name text)
-create table subscriptions(user_id text references users(id), valid_since timestamp with time zone, valid_till timestamp with time zone, plan text)
+create table users(id text primary key, matrix_handle text, name text);
+create type seat as enum('back', 'window', 'front', 'driver', 'pilot', 'garage');
+create table subscriptions(id text primary key, subscribed_user text references users(id), valid_since timestamp with time zone, validity integer, plan seat, signature bytea, creator text references users(id));
+create table keys(seed bytea)




diff --git a/db/subscriptions.go b/db/subscriptions.go
new file mode 100644
index 0000000000000000000000000000000000000000..5dbd52706988a2a873c7a4f18967b347bd052cc1
--- /dev/null
+++ b/db/subscriptions.go
@@ -0,0 +1,106 @@
+package db
+
+import (
+	"apiote.xyz/p/ampelmaennchen/accounts"
+
+	"database/sql"
+	"errors"
+	"fmt"
+	"time"
+)
+
+func GetValidSubscription(userID string) (accounts.Subscription, error) {
+	subscription := accounts.Subscription{}
+	row := db.QueryRow("select plan, valid_since, validity from subscriptions where subscribed_user = $1 and valid_since <= now() and valid_since + valididy >= now()", userID)
+	err := row.Scan(&subscription.Plan, &subscription.ValidSince, &subscription.Validity)
+	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			return accounts.Subscription{
+				Plan:       accounts.SEAT_BACK,
+				ValidSince: time.Now(),
+				Validity:   100 * 12,
+			}, nil
+		}
+		return subscription, fmt.Errorf("while selecting subscription: %w", err)
+	}
+	return subscription, nil
+}
+
+func GetSubscriptions(userID string) ([]accounts.Subscription, error) {
+	subscriptions := []accounts.Subscription{}
+	c, err := db.Query("select plan, valid_since, validity from subscriptions where subscribed_user = $1")
+	if err != nil {
+		return subscriptions, fmt.Errorf("while selecting subscriptions: %w", err)
+	}
+	for c.Next() {
+		var (
+			rawPlan    string
+			plan       accounts.Seat
+			validSince time.Time
+			validity   int
+		)
+		err = c.Scan(&rawPlan, &validSince, &validity)
+		if err != nil {
+			return subscriptions, fmt.Errorf("while scanning subscription: %w", err)
+		}
+		plan, err = accounts.ParseSeat(rawPlan)
+		if err != nil {
+			return subscriptions, fmt.Errorf("while parsing subscription plan: %w", err)
+		}
+		subscriptions = append(subscriptions, accounts.Subscription{
+			Plan:       plan,
+			ValidSince: validSince,
+			Validity:   validity,
+		})
+	}
+	if len(subscriptions) == 0 {
+		return []accounts.Subscription{
+			{Plan: accounts.SEAT_BACK, ValidSince: time.Now(), Validity: 100 * 12},
+		}, nil
+	} else {
+		return subscriptions, nil
+	}
+}
+
+func HasSubscriptionKey() (bool, error) {
+	var seed []byte
+	row := db.QueryRow("select seed from keys")
+	err := row.Scan(&seed)
+	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			return false, nil
+		}
+		return false, err
+	}
+	return true, nil
+}
+
+func GetSubscriptionKey() ([]byte, error) {
+	var seed []byte
+	row := db.QueryRow("select seed from keys")
+	err := row.Scan(&seed)
+	return seed, err
+}
+
+func SaveSubscriptionKey(seed []byte) error {
+	_, err := db.Exec("insert into keys values($1)", seed)
+	return err
+}
+
+func IsTicketUnused(ticket accounts.SubscriptionTicket) (bool, error) {
+	var subscribedUser *string
+	row := db.QueryRow("select subscribed_user from subscriptions")
+	err := row.Scan(&subscribedUser)
+	if err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			return true, nil
+		}
+		return false, err
+	}
+	return subscribedUser == nil, nil
+}
+
+func ValidateTicket(ticket accounts.SubscriptionTicket, userID string) error {
+	_, err := db.Exec("insert into subscriptions values($1, $2, $3, $4, $5, $6, $7) on conflict(id) do update set subscribed_user = $2, valid_since = $3", ticket.ID, userID, time.Now(), ticket.Validity, ticket.Plan, ticket.Signature, ticket.Creator)
+	return err
+}




diff --git a/db/users.go b/db/users.go
index 832c0ab8f11164d94493e18256c31960eced07c9..85d85aec49df42851dcfb7d5e1c6ca585bd7ea7b 100644
--- a/db/users.go
+++ b/db/users.go
@@ -1,34 +1,15 @@
 package db
 
 import (
+	"apiote.xyz/p/ampelmaennchen/accounts"
+
 	"database/sql"
 	"errors"
 	"fmt"
-	"time"
 )
 
-const (
-	SEAT_BACK = "back seat"
-	// ...
-	SEAT_GARAGE = "garage"
-)
-
-type Subscription struct {
-	Plan       string
-	ValidSince string // RFC3339
-	ValidTill  string // RFC3339
-	// TODO payment ID
-}
-
-type User struct {
-	Name          string
-	MatrixHandle  string
-	Email         string
-	Subscriptions []Subscription
-}
-
-func GetUser(userID string) (User, error) {
-	user := User{}
+func GetUser(userID string) (accounts.User, error) {
+	user := accounts.User{}
 	row := db.QueryRow("select name, matrix_handle from users where id = $1", userID)
 	err := row.Scan(&user.Name, &user.MatrixHandle)
 	if err != nil {
@@ -39,32 +20,3 @@ 		return user, fmt.Errorf("while selecting user: %w", err)
 	}
 	return user, nil
 }
-
-func GetValidSubscription(userID string) (Subscription, error) {
-	subscription := Subscription{}
-	var (
-		validSince time.Time
-		validTill  time.Time
-	)
-	row := db.QueryRow("select plan, valid_since, valid_till from subscriptions where user_id = $1 and valid_since <= now() and valid_till >= now()", userID)
-	err := row.Scan(&subscription.Plan, &validSince, &validTill)
-	if err != nil {
-		if errors.Is(err, sql.ErrNoRows) {
-			return Subscription{
-				Plan:      SEAT_BACK,
-				ValidTill: time.Date(9999, time.December, 31, 23, 59, 59, 0, time.UTC).Format(time.RFC3339),
-			}, nil
-		}
-		return subscription, fmt.Errorf("while selecting user: %w", err)
-	}
-	subscription.ValidSince = validSince.Format(time.RFC3339)
-	subscription.ValidTill = validTill.Format(time.RFC3339)
-	return subscription, nil
-}
-
-func GetSubscriptions(userID string) ([]Subscription, error) {
-	// TODO actually get subscriptions
-	return []Subscription{
-		{Plan: SEAT_BACK, ValidTill: time.Date(9999, time.December, 31, 23, 59, 59, 0, time.UTC).Format(time.RFC3339)},
-	}, nil
-}




diff --git a/go.mod b/go.mod
index 2fb8b8b961025393244c601ce14411613b506a1a..411b27c9d61c3ae6f00942d520e99c7b3e5234d8 100644
--- a/go.mod
+++ b/go.mod
@@ -5,8 +5,10 @@
 require (
 	apiote.xyz/p/gott/v2 v2.0.3
 	git.sr.ht/~sircmpwn/getopt v1.0.0
+	git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9
 	github.com/BurntSushi/toml v1.3.2
 	github.com/gabriel-vasile/mimetype v1.4.4
+	github.com/google/uuid v1.6.0
 	github.com/jackc/pgx/v5 v5.5.5
 	go.mongodb.org/mongo-driver/v2 v2.0.0-beta1
 	maunium.net/go/mautrix v0.18.1




diff --git a/go.sum b/go.sum
index 709fb0b67d4a3ce263ac113db94c5ed4983db032..bc5e521a87f3fb3b94bffe36bb5b9be62b53e43b 100644
--- a/go.sum
+++ b/go.sum
@@ -1,7 +1,10 @@
 apiote.xyz/p/gott/v2 v2.0.3 h1:CUFo0OAau20eRCPq/D11F9hQjKMB2cwLz6dZupgs7ME=
 apiote.xyz/p/gott/v2 v2.0.3/go.mod h1:H87aFMqvof1DWBzJuxzLaQRby4+PrIvFRwMfTTB6lK8=
+git.sr.ht/~sircmpwn/getopt v0.0.0-20191230200459-23622cc906b3/go.mod h1:wMEGFFFNuPos7vHmWXfszqImLppbc0wEhh6JBfJIUgw=
 git.sr.ht/~sircmpwn/getopt v1.0.0 h1:/pRHjO6/OCbBF4puqD98n6xtPEgE//oq5U8NXjP7ROc=
 git.sr.ht/~sircmpwn/getopt v1.0.0/go.mod h1:wMEGFFFNuPos7vHmWXfszqImLppbc0wEhh6JBfJIUgw=
+git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9 h1:Ahny8Ud1LjVMMAlt8utUFKhhxJtwBAualvsbc/Sk7cE=
+git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9/go.mod h1:BVJwbDfVjCjoFiKrhkei6NdGcZYpkDkdyCdg1ukytRA=
 github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
 github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
 github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
@@ -14,6 +17,8 @@ github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
 github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
 github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
@@ -45,6 +50,7 @@ github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
 github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=




diff --git a/main.go b/main.go
index 48e42106f1a16942649907238bef5742666a3c61..59814501c62c6de942dcd29f53fded8a272abc21 100644
--- a/main.go
+++ b/main.go
@@ -1,11 +1,13 @@
 package main
 
 import (
+	"apiote.xyz/p/ampelmaennchen/accounts"
 	"apiote.xyz/p/ampelmaennchen/config"
 	"apiote.xyz/p/ampelmaennchen/db"
 	"apiote.xyz/p/ampelmaennchen/matrix"
 	"apiote.xyz/p/ampelmaennchen/server"
 
+	"crypto/ed25519"
 	"fmt"
 	"log"
 	"os"
@@ -16,10 +18,11 @@
 var (
 	recreateRegistration bool
 	command              string
+	configPath           string = "./config.toml"
 )
 
 func parseArgs() {
-	opts, optind, err := getopt.Getopts(os.Args, "r")
+	opts, optind, err := getopt.Getopts(os.Args, "rc:")
 	if err != nil {
 		log.Fatalf("while getopts: %v", err)
 	}
@@ -27,6 +30,8 @@ 	for _, opt := range opts {
 		switch opt.Option {
 		case 'r':
 			recreateRegistration = true
+		case 'c':
+			configPath = opt.Value
 		}
 	}
 	for i, arg := range os.Args[optind:] {
@@ -38,7 +43,7 @@ }
 
 func main() {
 	parseArgs()
-	cfg, err := config.Load("./config.toml")
+	cfg, err := config.Load(configPath)
 	if err != nil {
 		log.Fatalf("while loading config: %v", err)
 	}
@@ -88,5 +93,51 @@ 	log.Println(err)
 	err = matrix.CreateHeadOfficeRoom(as, cfg.Matrix, spaceID)
 	log.Println(err)
 
-	server.Route("localhost:43637")
+	hasKeys, err := db.HasSubscriptionKey()
+	if err != nil {
+		log.Println(err)
+		os.Exit(1)
+	}
+	if !hasKeys {
+		_, private, err := ed25519.GenerateKey(nil)
+		if err != nil {
+			log.Println(err)
+			os.Exit(1)
+		}
+		err = db.SaveSubscriptionKey(private.Seed())
+		if err != nil {
+			log.Println(err)
+			os.Exit(1)
+		}
+	}
+
+	switch command {
+	case "serve":
+		server.Route(cfg.Server.Host, cfg.Server.Port)
+	case "csub":
+		var (
+			planRaw  string
+			plan     accounts.Seat
+			validity int
+			err      error
+		)
+		fmt.Printf("Plan (back, window, front, driver, pilot, garage): ")
+		fmt.Scanf("%s", &planRaw)
+		if plan, err = accounts.ParseSeat(planRaw); err != nil {
+			log.Printf("error parsing plan: %v\n", err)
+			os.Exit(1)
+		}
+		fmt.Printf("Validity (in months): ")
+		fmt.Scanf("%d", &validity)
+		keySeed, err := db.GetSubscriptionKey()
+		if err != nil {
+			log.Printf("while marshalling unsigned subscription: %w", err)
+			os.Exit(1)
+		}
+		err = accounts.CreateSubscription(plan, validity, keySeed)
+		if err != nil {
+			log.Printf("while creating subscription: %v\n", err)
+			os.Exit(1)
+		}
+	}
 }




diff --git a/server/router.go b/server/router.go
index 067a9b9a8974b4491e4a1f6b41282e0cf6d04329..35593315bf40539185f356ae202d3e211dc6f250 100644
--- a/server/router.go
+++ b/server/router.go
@@ -1,24 +1,25 @@
 package server
 
 import (
+	"fmt"
 	"log/slog"
 	"net/http"
 	"os"
 )
 
-func Route(listenAddress string) *http.Server {
+func Route(host string, port int) {
+	listenAddress := fmt.Sprintf("%s:%d", host, port)
 	srv := &http.Server{Addr: listenAddress}
 
 	http.HandleFunc("GET /users/{userID}", func(w http.ResponseWriter, r *http.Request) {
 		getUser(r.PathValue("userID"), w, r)
 	})
+	http.HandleFunc("POST /users/{userID}/subscriptions/", func(w http.ResponseWriter, r *http.Request) {
+		validateTicket(r.PathValue("userID"), w, r)
+	})
 
-	go func() {
-		if err := srv.ListenAndServe(); err != http.ErrServerClosed {
-			slog.Error("ListenAndServe error", slog.Any("error", err))
-			os.Exit(1)
-		}
-	}()
-
-	return srv
+	if err := srv.ListenAndServe(); err != http.ErrServerClosed {
+		slog.Error("ListenAndServe error", slog.Any("error", err))
+		os.Exit(1)
+	}
 }




diff --git a/server/users.go b/server/users.go
index 7601fafcd59c8c59401c5ca9c6ade65d6e3f29c2..b1f82e19de720ebc83d091fab2835b73f55e8a57 100644
--- a/server/users.go
+++ b/server/users.go
@@ -1,6 +1,9 @@
 package server
 
 import (
+	"fmt"
+
+	"apiote.xyz/p/ampelmaennchen/accounts"
 	"apiote.xyz/p/ampelmaennchen/db"
 
 	"encoding/json"
@@ -19,14 +22,24 @@ 	Sub   string `json:"sub"`
 	Email string `json:"email"`
 }
 
-func getUser(userID string, w http.ResponseWriter, r *http.Request) {
+type userInfoError struct {
+	status int
+	cause  error
+}
+
+func (e userInfoError) Error() string {
+	return e.cause.Error()
+}
+
+func getUserInfo(authorization string) (userInfo, error) {
 	client := http.Client{}
 	issuer := "https://oauth-bimba.apiote.xyz"
 	response, err := client.Get(issuer + "/.well-known/openid-configuration")
 	if err != nil || response.StatusCode != 200 {
-		w.WriteHeader(http.StatusInternalServerError)
-		log.Printf("while getting OIDC configuration: %v", err)
-		return
+		return userInfo{}, userInfoError{
+			status: http.StatusInternalServerError,
+			cause:  fmt.Errorf("while getting OIDC configuration: %w", err),
+		}
 	}
 	oidcConfig := oidcConfig{}
 	decoder := json.NewDecoder(response.Body)
@@ -34,36 +47,48 @@ 	decoder.Decode(&oidcConfig)
 
 	userinfoRequest, err := http.NewRequest(http.MethodGet, oidcConfig.UserinfoEndpoint, nil)
 	if err != nil {
-		w.WriteHeader(http.StatusInternalServerError)
-		log.Printf("while creating request to userinfo: %v", err)
-		return
+		return userInfo{}, userInfoError{
+			status: http.StatusInternalServerError,
+			cause:  fmt.Errorf("while creating request to userinfo: %w", err),
+		}
 	}
-	userinfoRequest.Header.Add("authorization", r.Header.Get("authorization"))
+	userinfoRequest.Header.Add("authorization", authorization)
 
 	response, err = client.Do(userinfoRequest)
 	if err != nil {
-		w.WriteHeader(http.StatusInternalServerError)
-		log.Printf("while performing request to userinfo: %v", err)
-		return
+		return userInfo{}, userInfoError{
+			status: http.StatusInternalServerError,
+			cause:  fmt.Errorf("while performing request to userinfo: %w", err),
+		}
 	}
 
 	if response.StatusCode == http.StatusForbidden || response.StatusCode == http.StatusUnauthorized {
-		w.WriteHeader(response.StatusCode)
-		log.Printf("%d from userinfo", response.StatusCode)
-		return
+		return userInfo{}, userInfoError{
+			status: response.StatusCode,
+			cause:  fmt.Errorf("%d from userinfo", response.StatusCode),
+		}
 	}
 
 	userInfo := userInfo{}
 	decoder = json.NewDecoder(response.Body)
 	decoder.Decode(&userInfo)
+	return userInfo, nil
+}
 
+func getUser(userID string, w http.ResponseWriter, r *http.Request) {
+	userInfo, err := getUserInfo(r.Header.Get("authorization"))
+	if err != nil {
+		w.WriteHeader(err.(userInfoError).status)
+		log.Printf("while getting user info: %v", err)
+		return
+	}
 	currentSubscription, err := db.GetValidSubscription(userInfo.Sub)
 	if err != nil {
 		w.WriteHeader(http.StatusInternalServerError)
 		log.Printf("while getting logged-in user subscription from db: %v", err)
 		return
 	}
-	if !(currentSubscription.Plan == db.SEAT_GARAGE || userInfo.Sub == userID) {
+	if !(currentSubscription.Plan == accounts.SEAT_GARAGE || userInfo.Sub == userID) {
 		w.WriteHeader(http.StatusForbidden)
 		return
 	}
@@ -90,3 +115,65 @@ 		return
 	}
 	w.Write(marshalledUser)
 }
+
+func validateTicket(userID string, w http.ResponseWriter, r *http.Request) {
+	userInfo, err := getUserInfo(r.Header.Get("authorization"))
+	if err != nil {
+		w.WriteHeader(err.(userInfoError).status)
+		log.Printf("while getting user info: %v", err)
+		return
+	}
+	if !(userInfo.Sub == userID) {
+		w.WriteHeader(http.StatusForbidden)
+		log.Printf("user mismatch")
+		return
+	}
+	r.ParseForm()
+
+	ticket, err := accounts.ParseSubscription(r.Form.Get("ticket"))
+	if err != nil {
+		w.WriteHeader(http.StatusBadRequest)
+		log.Printf("while parsing subscription: %v", err)
+		return
+	}
+
+	isTicketSlotFree, err := db.IsTicketUnused(ticket)
+	if err != nil {
+		w.WriteHeader(http.StatusInternalServerError)
+		log.Printf("while checking free ticket slot: %v", err)
+		return
+	}
+
+	if !isTicketSlotFree {
+		w.WriteHeader(http.StatusForbidden)
+		log.Printf("ticket used")
+		return
+	}
+
+	seed, err := db.GetSubscriptionKey()
+	if err != nil {
+		w.WriteHeader(http.StatusInternalServerError)
+		log.Printf("while getting seed: %v", err)
+		return
+	}
+
+	ticketSignedOK, err := accounts.CheckSignature(ticket, seed)
+	if err != nil {
+		w.WriteHeader(http.StatusInternalServerError)
+		log.Printf("while checking ticket signature: %v", err)
+		return
+	}
+
+	if !ticketSignedOK {
+		w.WriteHeader(http.StatusBadRequest)
+		log.Printf("ticket signature incorrect")
+		return
+	}
+
+	err = db.ValidateTicket(ticket, userID)
+	if err != nil {
+		w.WriteHeader(http.StatusInternalServerError)
+		log.Printf("while validating ticket: %v", err)
+		return
+	}
+}