diff --git a/.drone.yml b/.drone.yml index e580348..a366ab9 100644 --- a/.drone.yml +++ b/.drone.yml @@ -8,6 +8,8 @@ steps: - name: build image: golang commands: + - apt-get update + - apt-get install -y libolm-dev - go build -ldflags="-s -w" -o matrix-pretix . - name: release diff --git a/go.mod b/go.mod index b6a08b1..6b79266 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,20 @@ module git.luj0ga.de/franconian/matrix-pretix go 1.18 -require maunium.net/go/mautrix v0.11.0 +require ( + github.com/mattn/go-sqlite3 v1.14.13 + maunium.net/go/mautrix v0.11.0 +) require ( + github.com/gorilla/mux v1.8.0 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/tidwall/gjson v1.14.1 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/tidwall/sjson v1.2.4 // indirect golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9 // indirect golang.org/x/net v0.0.0-20220513224357-95641704303c // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + maunium.net/go/maulogger/v2 v2.3.2 // indirect ) diff --git a/go.sum b/go.sum index 79d9ab1..c70b660 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,31 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +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= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/mattn/go-sqlite3 v1.14.13 h1:1tj15ngiFfcZzii7yd82foL+ks+ouQcj8j/TPq3fk1I= +github.com/mattn/go-sqlite3 v1.14.13/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.14.1 h1:iymTbGkQBhveq21bEvAQ81I0LEBork8BFe1CUZXdyuo= +github.com/tidwall/gjson v1.14.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.4 h1:cuiLzLnaMeBhRmEv00Lpk3tkYrcxpmbU81tAY4Dw0tc= +github.com/tidwall/sjson v1.2.4/go.mod h1:098SZ494YoMWPmMO6ct4dcFnqxwj9r/gF0Etp19pSNM= golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9 h1:NUzdAbFtCJSXU20AOXgeqaUwg8Ypg4MPYmL+d+rsB5c= golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/net v0.0.0-20220513224357-95641704303c h1:nF9mHSvoKBLkQNQhJZNsc66z2UzAMUbLGjC95CF3pU0= golang.org/x/net v0.0.0-20220513224357-95641704303c/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +maunium.net/go/maulogger/v2 v2.3.2 h1:1XmIYmMd3PoQfp9J+PaHhpt80zpfmMqaShzUTC7FwY0= +maunium.net/go/maulogger/v2 v2.3.2/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A= maunium.net/go/mautrix v0.11.0 h1:B1FBHcvE4Mud+AC+zgNQQOw0JxSVrt40watCejhVA7w= maunium.net/go/mautrix v0.11.0/go.mod h1:K29EcHwsNg6r7fMfwvi0GHQ9o5wSjqB9+Q8RjCIQEjA= diff --git a/internal/config/config.go b/internal/config/config.go index aae01d4..8840205 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,13 +6,27 @@ import ( ) type Config struct { + DB DatabaseConfig Matrix MatrixConfig + Server ServerConfig +} + +type DatabaseConfig struct { + Filename string } type MatrixConfig struct { + AllowedRooms []string + DisplayName string + LogLevel uint HomeserverURL string UserIdentifier string Password string + PickleKey string +} + +type ServerConfig struct { + ListenAddress string } func ParseFromFile(path string) (config *Config, err error) { diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..f7c4502 --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,15 @@ +package database + +import ( + "database/sql" + + _ "github.com/mattn/go-sqlite3" + + "git.luj0ga.de/franconian/matrix-pretix/internal/config" +) + +const DBDriverName = "sqlite3" + +func Open(config *config.DatabaseConfig) (*sql.DB, error) { + return sql.Open(DBDriverName, config.Filename) +} diff --git a/internal/matrix/client.go b/internal/matrix/client.go index c2b9f5f..594d735 100644 --- a/internal/matrix/client.go +++ b/internal/matrix/client.go @@ -1,29 +1,166 @@ package matrix import ( + "context" + "database/sql" + "log" + "sync" + "git.luj0ga.de/franconian/matrix-pretix/internal/config" + "git.luj0ga.de/franconian/matrix-pretix/internal/database" "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" ) -func NewClient(config *config.MatrixConfig) (*mautrix.Client, error) { +type Client struct { + client *mautrix.Client + config *config.MatrixConfig + db *sql.DB + deviceID id.DeviceID + olmMachine *crypto.OlmMachine + store *sqlStore + syncer *mautrix.DefaultSyncer + userID id.UserID +} + +func NewClient(config *config.MatrixConfig, db *sql.DB) (*Client, error) { client, err := mautrix.NewClient(config.HomeserverURL, "", "") if err != nil { return nil, err } - _, err = client.Login(&mautrix.ReqLogin{ - Type: mautrix.AuthTypePassword, - Identifier: mautrix.UserIdentifier{ - Type: mautrix.IdentifierTypeUser, - User: config.UserIdentifier, - }, - Password: config.Password, - StoreCredentials: true, - }) + syncer := mautrix.NewDefaultSyncer() + client.Syncer = syncer + + store := &sqlStore{db} + client.Store = store + + err = store.CreateTables() if err != nil { return nil, err } - return client, nil + userID, err := makeUserID(config.UserIdentifier, config.HomeserverURL) + if err != nil { + return nil, err + } + + deviceID := loadDeviceID(db, userID) + + return &Client{client, config, db, deviceID, nil, store, syncer, userID}, nil +} + +func (c *Client) Login() error { + c.syncer.OnEventType(event.StateMember, c.handleMemberEvent) + + _, err := c.client.Login(&mautrix.ReqLogin{ + Type: mautrix.AuthTypePassword, + Identifier: mautrix.UserIdentifier{ + Type: mautrix.IdentifierTypeUser, + User: c.config.UserIdentifier, + }, + Password: c.config.Password, + DeviceID: c.deviceID, + InitialDeviceDisplayName: c.config.DisplayName, + StoreCredentials: true, + }) + if err != nil { + return err + } + + return nil +} + +func (c *Client) Encrypt() error { + sqlCryptoStore := crypto.NewSQLCryptoStore( + c.db, + database.DBDriverName, + c.userID.String(), + c.deviceID, + []byte(c.config.PickleKey), + logger{}, + ) + + err := sqlCryptoStore.CreateTables() + if err != nil { + return err + } + + c.olmMachine = crypto.NewOlmMachine(c.client, &logger{}, sqlCryptoStore, c.store) + + err = c.olmMachine.Load() + if err != nil { + return err + } + + c.syncer.OnSync(c.olmMachine.ProcessSyncResponse) + + c.syncer.OnEventType(event.StateEncryption, c.handleEncryptionEvent) + + return nil +} + +func (c *Client) Sync(ctx context.Context, wg *sync.WaitGroup) { + wg.Add(1) + defer wg.Done() + + for { + select { + case <-ctx.Done(): + return + default: + err := c.client.SyncWithContext(ctx) + if err != nil && err != ctx.Err() { + log.Print(err) + } + } + } +} + +func (c *Client) handleMemberEvent(source mautrix.EventSource, evt *event.Event) { + if c.olmMachine != nil { + c.olmMachine.HandleMemberEvent(evt) + } + + c.store.SetMembership(evt.RoomID, evt.GetStateKey(), evt.Content.AsMember().Membership) + + if evt.GetStateKey() == c.userID.String() && evt.Content.AsMember().Membership == event.MembershipInvite { + allowed := false + for _, room := range c.config.AllowedRooms { + if room == evt.RoomID.String() { + allowed = true + break + } + } + + if allowed { + _, err := c.client.JoinRoomByID(evt.RoomID) + if err != nil { + log.Print(err) + } + } else { + _, err := c.client.LeaveRoom(evt.RoomID) + if err != nil { + log.Print(err) + } + } + } +} + +func (c *Client) handleEncryptionEvent(source mautrix.EventSource, evt *event.Event) { + c.store.SetEncryptionEvent(evt.RoomID, evt.Content.AsEncryption()) +} + +func loadDeviceID(db *sql.DB, accountID id.UserID) (deviceID id.DeviceID) { + row := db.QueryRow("SELECT device_id FROM crypto_account WHERE account_id = ?;", accountID) + + err := row.Scan(&deviceID) + if err != nil { + return "" + } + + return deviceID } diff --git a/internal/matrix/logger.go b/internal/matrix/logger.go new file mode 100644 index 0000000..153b7cb --- /dev/null +++ b/internal/matrix/logger.go @@ -0,0 +1,38 @@ +package matrix + +import ( + "fmt" + "log" +) + +type logger struct{ + level uint +} + +func (l logger) Error(message string, args ...interface{}) { + if l.level > 0 { + logLevel("error", message, args...) + } +} + +func (l logger) Warn(message string, args ...interface{}) { + if l.level > 1 { + logLevel("warning", message, args...) + } +} + +func (l logger) Debug(message string, args ...interface{}) { + if l.level > 2 { + logLevel("debug", message, args...) + } +} + +func (l logger) Trace(message string, args ...interface{}) { + if l.level > 3 { + logLevel("trace", message, args...) + } +} + +func logLevel(level, message string, args ...interface{}) { + log.Print("[", level, "] ", fmt.Sprintf(message, args...)) +} diff --git a/internal/matrix/store.go b/internal/matrix/store.go new file mode 100644 index 0000000..3d07674 --- /dev/null +++ b/internal/matrix/store.go @@ -0,0 +1,188 @@ +package matrix + +import ( + "database/sql" + "encoding/json" + "log" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type sqlStore struct { + db *sql.DB +} + +func (s sqlStore) CreateTables() error { + tables := []string{ + `CREATE TABLE IF NOT EXISTS filter_ids ( + user_id TEXT PRIMARY KEY ON CONFLICT REPLACE, + filter_id TEXT NOT NULL + ); + `, + `CREATE TABLE IF NOT EXISTS next_batch_tokens ( + user_id TEXT PRIMARY KEY ON CONFLICT REPLACE, + next_batch_token TEXT NOT NULL + ); + `, + `CREATE TABLE IF NOT EXISTS rooms ( + room_id TEXT PRIMARY KEY ON CONFLICT REPLACE, + encryption_event TEXT + ); + `, + `CREATE TABLE IF NOT EXISTS room_members ( + room_id TEXT, + user_id TEXT, + + PRIMARY KEY (room_id, user_id) + ); + `, + } + + tx, err := s.db.Begin() + if err != nil { + return err + } + + for _, table := range tables { + _, err := tx.Exec(table) + if err != nil { + if err := tx.Rollback(); err != nil { + log.Print(err) + } + + return err + } + } + + err = tx.Commit() + if err != nil { + return err + } + + return nil +} + +func (s sqlStore) SaveFilterID(userID id.UserID, filterID string) { + _, err := s.db.Exec("INSERT INTO filter_ids VALUES(?, ?);", userID, filterID) + if err != nil { + log.Print(err) + } +} + +func (s sqlStore) LoadFilterID(userID id.UserID) (filterID string) { + row := s.db.QueryRow("SELECT filter_id FROM filter_ids WHERE user_id = ?;", userID) + + err := row.Scan(&filterID) + if err != nil { + return "" + } + + return filterID +} + +func (s sqlStore) SaveNextBatch(userID id.UserID, nextBatchToken string) { + _, err := s.db.Exec("INSERT INTO next_batch_tokens VALUES(?, ?);", userID, nextBatchToken) + if err != nil { + log.Print(err) + } +} + +func (s sqlStore) LoadNextBatch(userID id.UserID) (nextBatchToken string) { + row := s.db.QueryRow("SELECT next_batch_token FROM next_batch_tokens WHERE user_id = ?;", userID) + + err := row.Scan(&nextBatchToken) + if err != nil { + return "" + } + + return nextBatchToken +} + +func (s sqlStore) SaveRoom(room *mautrix.Room) { +} + +func (s sqlStore) LoadRoom(roomID id.RoomID) *mautrix.Room { + return mautrix.NewRoom(roomID) +} + +func (s sqlStore) IsEncrypted(roomID id.RoomID) (isEncrypted bool) { + row := s.db.QueryRow("SELECT encryption_event NOT NULL FROM rooms WHERE room_id = ?;", roomID) + + err := row.Scan(&isEncrypted) + if err != nil { + return false + } + + return isEncrypted +} + +func (s sqlStore) GetEncryptionEvent(roomID id.RoomID) (encryptionEvent *event.EncryptionEventContent) { + row := s.db.QueryRow("SELECT encryption_event FROM rooms WHERE room_id = ?;", roomID) + + var data []byte + if err := row.Scan(&data); err != nil { + return nil + } + + err := json.Unmarshal(data, encryptionEvent) + if err != nil { + return nil + } + + return encryptionEvent +} + +func (s sqlStore) FindSharedRooms(userID id.UserID) (sharedRooms []id.RoomID) { + rows, err := s.db.Query("SELECT rooms.room_id FROM rooms, (SELECT room_id FROM room_members GROUP BY room_id HAVING COUNT(*) > 1) shared_rooms WHERE shared_rooms.room_id = rooms.room_id AND encryption_event NOT NULL;") + if err != nil { + return nil + } + defer rows.Close() + + for rows.Next() { + var roomID string + if err := rows.Scan(&roomID); err != nil { + continue + } + + sharedRooms = append(sharedRooms, id.RoomID(roomID)) + } + if rows.Err() != nil { + return nil + } + + return sharedRooms +} + +func (s sqlStore) SetMembership(roomID id.RoomID, userID string, membership event.Membership) { + if membership.IsInviteOrJoin() { + _, err := s.db.Exec("INSERT INTO room_members VALUES(?, ?);", roomID, userID) + if err != nil { + log.Print(err) + } + } else if membership.IsLeaveOrBan() { + _, err := s.db.Exec("DELETE FROM room_members WHERE room_id = ? AND user_id = ?;", roomID, userID) + if err != nil { + log.Print(err) + } + } +} + +func (s sqlStore) SetEncryptionEvent(roomID id.RoomID, encryptionEvent *event.EncryptionEventContent) { + var data []byte + if encryptionEvent != nil { + var err error + data, err = json.Marshal(encryptionEvent) + if err != nil { + log.Print(err) + return + } + } + + _, err := s.db.Exec("INSERT INTO rooms VALUES(?, ?);", roomID, data) + if err != nil { + log.Print(err) + } +} diff --git a/internal/matrix/user_id.go b/internal/matrix/user_id.go new file mode 100644 index 0000000..307ec37 --- /dev/null +++ b/internal/matrix/user_id.go @@ -0,0 +1,20 @@ +package matrix + +import "maunium.net/go/mautrix/id" + +func makeUserID(userIdentifier, homeserverURL string) (id.UserID, error) { + userID := id.UserID(userIdentifier) + localpart, _, err := userID.Parse() + if err != nil { + userID = id.NewUserID(userIdentifier, homeserverURL) + if _, _, err := userID.ParseAndValidate(); err != nil { + return "", err + } + } else if err := id.ValidateUserLocalpart(localpart); err != nil { + return "", err + } else if len(userID) > id.UserIDMaxLength { + return "", id.ErrUserIDTooLong + } + + return userID, nil +} diff --git a/internal/pretix/server.go b/internal/pretix/server.go new file mode 100644 index 0000000..c129e2c --- /dev/null +++ b/internal/pretix/server.go @@ -0,0 +1,75 @@ +package pretix + +import ( + "context" + "encoding/json" + "io" + "log" + "net/http" + "sync" + + "git.luj0ga.de/franconian/matrix-pretix/internal/config" + "git.luj0ga.de/franconian/matrix-pretix/internal/matrix" +) + +type Server struct { + client http.Client + matrix *matrix.Client +} + +func NewServer(matrix *matrix.Client) *Server { + return &Server{ + matrix: matrix, + } +} + +func (s Server) ListenAndServe(config *config.ServerConfig, ctx context.Context, wg *sync.WaitGroup) error { + http.HandleFunc("/", http.NotFound) + http.HandleFunc("/order_placed", s.orderPlaced) + + server := http.Server{ + Addr: config.ListenAddress, + } + + go func() { + wg.Add(1) + defer wg.Done() + + <-ctx.Done() + + err := server.Shutdown(context.Background()) + if err != nil { + log.Print(err) + } + }() + + err := server.ListenAndServe() + if err != http.ErrServerClosed { + return err + } + + return nil +} + +func (s Server) orderPlaced(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Content-Type") != "application/json" { + writeStatus(w, http.StatusBadRequest) + return + } + + decoder := json.NewDecoder(r.Body) + + var data map[string]interface{} + err := decoder.Decode(&data) + if err != nil { + writeStatus(w, http.StatusBadRequest) + return + } + + log.Print(data) +} + +func writeStatus(w http.ResponseWriter, code int) { + w.WriteHeader(code) + io.WriteString(w, http.StatusText(code)) +} diff --git a/main.go b/main.go index 4232563..b6308b8 100644 --- a/main.go +++ b/main.go @@ -1,11 +1,17 @@ package main import ( + "context" "log" "os" + "os/signal" + "sync" + "syscall" "git.luj0ga.de/franconian/matrix-pretix/internal/config" + "git.luj0ga.de/franconian/matrix-pretix/internal/database" "git.luj0ga.de/franconian/matrix-pretix/internal/matrix" + "git.luj0ga.de/franconian/matrix-pretix/internal/pretix" ) func main() { @@ -18,9 +24,51 @@ func main() { log.Fatal(err) } - client, err := matrix.NewClient(&config.Matrix) + db, err := database.Open(&config.DB) if err != nil { log.Fatal(err) } - _ = client + defer db.Close() + + client, err := matrix.NewClient(&config.Matrix, db) + if err != nil { + log.Fatal(err) + } + + err = client.Login() + if err != nil { + log.Fatal(err) + } + + err = client.Encrypt() + if err != nil { + log.Fatal(err) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + var wg sync.WaitGroup + go func() { + wg.Add(1) + defer wg.Done() + + <-ctx.Done() + + stop() + }() + + go client.Sync(ctx, &wg) + + server := pretix.NewServer(client) + + err = server.ListenAndServe(&config.Server, ctx, &wg) + if err != nil { + stop() + wg.Wait() + log.Fatal(err) + } + + wg.Wait() + log.Print("done") }