diff --git a/cmd/root.go b/cmd/root.go index 5304020..bba1485 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -58,6 +58,9 @@ func init() { viper.SetDefault("bot.mongo.collection.entries", "entries") viper.SetDefault("bot.mongo.collection.lists", "lists") viper.SetDefault("bot.mongo.collection.rooms", "rooms") + viper.SetDefault("bot.mongo.collection.users", "users") + viper.SetDefault("bot.web.listen", "127.0.0.1:8123") + viper.SetDefault("bot.web.secret", "hunter2") cobra.OnInitialize(loadConfig) } @@ -75,4 +78,8 @@ func loadConfig() { if err := viper.ReadInConfig(); err == nil { log.Println("Using config file:", viper.ConfigFileUsed()) } + + if viper.GetString("bot.web.secret") == "hunter2" { + log.Println("Web secret is not set! YOUR INSTALLATION IS INSECURE!") + } } diff --git a/cmd/run.go b/cmd/run.go index 62192a2..e9dd08d 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -20,6 +20,7 @@ package cmd import ( "github.com/Unkn0wnCat/matrix-veles/internal/bot" "github.com/Unkn0wnCat/matrix-veles/internal/db" + "github.com/Unkn0wnCat/matrix-veles/internal/web" "github.com/spf13/cobra" ) @@ -33,6 +34,7 @@ var runCmd = &cobra.Command{ The bot will log in to the homeserver and start posting updates to subscribed channels.`, Run: func(cmd *cobra.Command, args []string) { db.Connect() + go web.StartServer() bot.Run() }, } diff --git a/go.mod b/go.mod index 6d8909f..e746fe0 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,12 @@ module github.com/Unkn0wnCat/matrix-veles go 1.16 require ( - github.com/gomarkdown/markdown v0.0.0-20210514010506-3b9f47219fe7 + github.com/golang-jwt/jwt/v4 v4.3.0 + github.com/gorilla/mux v1.8.0 github.com/spf13/cobra v1.3.0 github.com/spf13/viper v1.10.1 - go.mongodb.org/mongo-driver v1.8.3 // indirect + go.mongodb.org/mongo-driver v1.8.3 + golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 // indirect - golang.org/x/text v0.3.7 maunium.net/go/mautrix v0.9.14 ) diff --git a/go.sum b/go.sum index 80c97ea..eda4395 100644 --- a/go.sum +++ b/go.sum @@ -133,6 +133,8 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.3.0 h1:kHL1vqdqWNfATmA0FNMdmZNMyZI1U6O31X4rlIPoBog= +github.com/golang-jwt/jwt/v4 v4.3.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -167,8 +169,6 @@ github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.3 h1:fHPg5GQYlCeLIPB9BZqMVR5nR9A+IM5zcgeTdjMYmLA= github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/gomarkdown/markdown v0.0.0-20210514010506-3b9f47219fe7 h1:oKYOfNR7Hp6XpZ4JqolL5u642Js5Z0n7psPVl+S5heo= -github.com/gomarkdown/markdown v0.0.0-20210514010506-3b9f47219fe7/go.mod h1:aii0r/K0ZnHv7G0KF7xy1v0A7s2Ljrb5byB7MO5p6TU= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -182,6 +182,7 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= @@ -208,6 +209,7 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+ github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= +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.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= @@ -372,6 +374,7 @@ github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69 github.com/tidwall/gjson v1.6.8/go.mod h1:zeFuBCIqD4sN/gmqBzZ4j7Jd6UcA2Fc56x7QFsv+8fI= github.com/tidwall/match v1.0.3/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/tidwall/pretty v1.0.2 h1:Z7S3cePv9Jwm1KwS0513MRaoUe3S01WPbLNV40pwWZU= github.com/tidwall/pretty v1.0.2/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tidwall/sjson v1.1.5/go.mod h1:VuJzsZnTowhSxWdOgsAnb886i4AjEyTkk7tNtsL7EYE= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= @@ -404,7 +407,6 @@ go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqe go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= -golang.org/dl v0.0.0-20190829154251-82a15e2f2ead/go.mod h1:IUMfjQLJQd4UTqG1Z90tenwKoCX93Gn3MAQJMOSBsDQ= golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -671,6 +673,7 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 0026c67..ffd7797 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -158,19 +158,20 @@ func handleMessageEvent(matrixClient *mautrix.Client, startTs int64) mautrix.Eve } if content.URL != "" { - handleHashing(content, evt, matrixClient) - + // This has an attachment! + handleHashing(content, evt, matrixClient) // -> handleHashing.go return } + // No attachment, is this a command? if !strings.HasPrefix(content.Body, "!"+username) && !strings.HasPrefix(content.Body, "@"+username) && !(strings.HasPrefix(content.Body, username) && strings.HasPrefix(content.FormattedBody, "")) { return } - handleCommand(content.Body, evt.Sender, evt.RoomID, matrixClient) - + // It is a command! + handleCommand(content.Body, evt.Sender, evt.RoomID, matrixClient) // -> commandParser.go } } diff --git a/internal/bot/getState.go b/internal/bot/getState.go index 6624816..37a1eac 100644 --- a/internal/bot/getState.go +++ b/internal/bot/getState.go @@ -8,19 +8,19 @@ import ( "maunium.net/go/mautrix/id" ) -type StateEventPL struct { - Type string `json:"type"` - Sender string `json:"sender"` - RoomID string `json:"room_id"` - EventID string `json:"event_id"` - OriginServerTS int64 `json:"origin_server_ts"` - Content StateEventPLContent `json:"content"` +type StateEventPowerLevel struct { + Type string `json:"type"` + Sender string `json:"sender"` + RoomID string `json:"room_id"` + EventID string `json:"event_id"` + OriginServerTS int64 `json:"origin_server_ts"` + Content StateEventPowerLevelContent `json:"content"` Unsigned struct { Age int `json:"age"` } `json:"unsigned"` } -type StateEventPLContent struct { +type StateEventPowerLevelContent struct { Ban int `json:"ban"` Events map[string]int `json:"events"` EventsDefault int `json:"events_default"` @@ -33,45 +33,51 @@ type StateEventPLContent struct { UsersDefault int `json:"users_default"` } -func GetRoomState(matrixClient *mautrix.Client, roomId id.RoomID) (*StateEventPLContent, error) { +// GetRoomPowerLevelState returns the rooms current power levels from the state +func GetRoomPowerLevelState(matrixClient *mautrix.Client, roomId id.RoomID) (*StateEventPowerLevelContent, error) { + // https://matrix.example.com/_matrix/client/r0/rooms//state url := matrixClient.BuildURL("rooms", roomId.String(), "state") + res, err := matrixClient.MakeRequest("GET", url, nil, nil) if err != nil { return nil, fmt.Errorf("ERROR: Could request room state - %v", err) } - var stateEvents []StateEventPL + // res contains an array of state events + var stateEvents []StateEventPowerLevel err = json.Unmarshal(res, &stateEvents) if err != nil { return nil, fmt.Errorf("ERROR: Could parse room state - %v", err) } - var plEventContent StateEventPLContent + // plEventContent will hold the final event + var plEventContent StateEventPowerLevelContent found := false for _, e2 := range stateEvents { if e2.Type != event.StatePowerLevels.Type { - continue + continue // If the current event is not of the power level, skip. } + // This is what we're looking for! found = true plEventContent = e2.Content + break } if !found { return nil, fmt.Errorf("ERROR: Could find room power level - %v", err) } + // The following handle cases in which empty lists may have been parsed as nil if plEventContent.Events == nil { plEventContent.Events = make(map[string]int) } - if plEventContent.Notifications == nil { plEventContent.Notifications = make(map[string]int) } - if plEventContent.Users == nil { plEventContent.Users = make(map[string]int) } diff --git a/internal/bot/handleHashing.go b/internal/bot/handleHashing.go index e9947c8..906db34 100644 --- a/internal/bot/handleHashing.go +++ b/internal/bot/handleHashing.go @@ -17,6 +17,7 @@ import ( "maunium.net/go/mautrix/event" ) +// handleHashing hashes and checks a message, taking configured actions on match func handleHashing(content *event.MessageEventContent, evt *event.Event, matrixClient *mautrix.Client) { url, err := content.URL.Parse() if err != nil { @@ -30,9 +31,7 @@ func handleHashing(content *event.MessageEventContent, evt *event.Event, matrixC return } - defer func(reader io.ReadCloser) { - _ = reader.Close() - }(reader) + defer func(reader io.ReadCloser) { _ = reader.Close() }(reader) hashWriter := sha512.New() if _, err = io.Copy(hashWriter, reader); err != nil { @@ -42,52 +41,51 @@ func handleHashing(content *event.MessageEventContent, evt *event.Event, matrixC sum := hex.EncodeToString(hashWriter.Sum(nil)) + // Fetch room configuration for adjusting behaviour roomConfig := config.GetRoomConfig(evt.RoomID.String()) hashObj, err := db.GetEntryByHash(sum) if err != nil { if errors.Is(err, mongo.ErrNoDocuments) { - /*entry := model.DBEntry{ - ID: primitive.NewObjectID(), - HashValue: sum, - FileURL: "placeholder", - Timestamp: time.Now(), - AddedBy: nil, - Comments: nil, - } - - db.SaveEntry(&entry)*/ - if roomConfig.Debug { matrixClient.SendNotice(evt.RoomID, fmt.Sprintf("DEBUG - This file is not on the hashlist: %s", sum)) } - return } - if roomConfig.Debug { matrixClient.SendNotice(evt.RoomID, "DEBUG - Failed to check file. See log.") } - fmt.Printf("Error trying to check database: %v", err) return } if roomConfig.Debug { matrixClient.SendNotice(evt.RoomID, fmt.Sprintf("DEBUG !!! This file is on the hashlist: %s", sum)) - jsonVal, _ := json.Marshal(hashObj) - - var buf bytes.Buffer - - json.Indent(&buf, jsonVal, "", " ") - - matrixClient.SendNotice(evt.RoomID, fmt.Sprintf("DEBUG:\n%s", buf.String())) + matrixClient.SendNotice(evt.RoomID, fmt.Sprintf("DEBUG:\n%s", makeFancyJSON(jsonVal))) } + if !checkSubscription(&roomConfig, hashObj) { + return + } + + log.Printf("Illegal content detected in room %s!", roomConfig.RoomID) + + handleIllegalContent(evt, matrixClient, hashObj, roomConfig) +} + +// makeFancyJSON formats / indents a JSON string +func makeFancyJSON(input []byte) string { + var buf bytes.Buffer + json.Indent(&buf, input, "", " ") + return buf.String() +} + +// checkSubscription checks if the room is subscribed to one of hashObjs lists +func checkSubscription(roomConfig *config.RoomConfig, hashObj *model.DBEntry) bool { if roomConfig.HashChecker.SubscribedLists == nil { log.Printf("Room %s is not subscribed to any lists!", roomConfig.RoomID) - return // Not subscribed to any lists + return false // Not subscribed to any lists } subMap := make(map[string]bool) @@ -110,14 +108,42 @@ func handleHashing(content *event.MessageEventContent, evt *event.Event, matrixC if !found { log.Printf("Room %s is not subscribed to any lists of hashobj %s!", roomConfig.RoomID, hashObj.ID.Hex()) - return // Not subscribed + return false // Not subscribed } - log.Printf("Illegal content detected in room %s!", roomConfig.RoomID) - - handleIllegalContent(evt, matrixClient, hashObj, roomConfig) + return true } +// handleIllegalContent is called when a hash-match is found to take configured actions +func handleIllegalContent(evt *event.Event, matrixClient *mautrix.Client, hashObj *model.DBEntry, roomConfig config.RoomConfig) { + switch roomConfig.HashChecker.HashCheckMode { + case 0: + postNotice(evt, matrixClient, hashObj, roomConfig) + break + case 1: + redactMessage(evt, matrixClient, hashObj) + if roomConfig.HashChecker.NoticeToChat { + postNotice(evt, matrixClient, hashObj, roomConfig) + } + break + case 2: + muteUser(evt, matrixClient) + redactMessage(evt, matrixClient, hashObj) + if roomConfig.HashChecker.NoticeToChat { + postNotice(evt, matrixClient, hashObj, roomConfig) + } + break + case 3: + banUser(evt, matrixClient, hashObj) + redactMessage(evt, matrixClient, hashObj) + if roomConfig.HashChecker.NoticeToChat { + postNotice(evt, matrixClient, hashObj, roomConfig) + } + break + } +} + +// redactMessage deletes the message sent in the given event func redactMessage(evt *event.Event, matrixClient *mautrix.Client, hashObj *model.DBEntry) { opts := mautrix.ReqRedact{Reason: fmt.Sprintf("Veles has detected an hash-map-match! Tags: %s, ID: %s", hashObj.Tags, hashObj.ID.Hex())} _, err := matrixClient.RedactEvent(evt.RoomID, evt.ID, opts) @@ -126,8 +152,9 @@ func redactMessage(evt *event.Event, matrixClient *mautrix.Client, hashObj *mode } } -func muteUser(evt *event.Event, matrixClient *mautrix.Client, hashObj *model.DBEntry) { - plEventContent, err := GetRoomState(matrixClient, evt.RoomID) +// muteUser sets a users power-level to -1 to prevent them from sending messages +func muteUser(evt *event.Event, matrixClient *mautrix.Client) { + plEventContent, err := GetRoomPowerLevelState(matrixClient, evt.RoomID) if err != nil { log.Printf("ERROR: Could mute user - %v", err) return @@ -140,9 +167,9 @@ func muteUser(evt *event.Event, matrixClient *mautrix.Client, hashObj *model.DBE log.Printf("ERROR: Could mute user - %v", err) return } - } +// banUser bans the sender of an event from the room func banUser(evt *event.Event, matrixClient *mautrix.Client, hashObj *model.DBEntry) { req := mautrix.ReqBanUser{ Reason: fmt.Sprintf("Veles has detected an hash-map-match! Tags: %s, ID: %s", hashObj.Tags, hashObj.ID.Hex()), @@ -152,6 +179,7 @@ func banUser(evt *event.Event, matrixClient *mautrix.Client, hashObj *model.DBEn matrixClient.BanUser(evt.RoomID, &req) } +// postNotice posts a notice about the given event into its room func postNotice(evt *event.Event, matrixClient *mautrix.Client, hashObj *model.DBEntry, roomConfig config.RoomConfig) { local, server, err := evt.Sender.Parse() if err != nil { @@ -172,32 +200,3 @@ If you believe this action was an accident, please contact an room administrator SendAlert(matrixClient, evt.RoomID.String(), fmt.Sprintf( `Veles Triggered: The message by %s (on %s) was flagged for containing material used by spammers or trolls! (Reference: %s)`, local, server, hashObj.ID.Hex())) }*/ - -func handleIllegalContent(evt *event.Event, matrixClient *mautrix.Client, hashObj *model.DBEntry, roomConfig config.RoomConfig) { - switch roomConfig.HashChecker.HashCheckMode { - case 0: - postNotice(evt, matrixClient, hashObj, roomConfig) - break - case 1: - redactMessage(evt, matrixClient, hashObj) - if roomConfig.HashChecker.NoticeToChat { - postNotice(evt, matrixClient, hashObj, roomConfig) - } - break - case 2: - muteUser(evt, matrixClient, hashObj) - redactMessage(evt, matrixClient, hashObj) - if roomConfig.HashChecker.NoticeToChat { - postNotice(evt, matrixClient, hashObj, roomConfig) - } - break - case 3: - banUser(evt, matrixClient, hashObj) - redactMessage(evt, matrixClient, hashObj) - if roomConfig.HashChecker.NoticeToChat { - postNotice(evt, matrixClient, hashObj, roomConfig) - } - break - - } -} diff --git a/internal/bot/sendAlert.go b/internal/bot/sendAlert.go index e443ee2..2a09c20 100644 --- a/internal/bot/sendAlert.go +++ b/internal/bot/sendAlert.go @@ -8,7 +8,7 @@ package bot } if roomConfig.AlertChannel == nil { - roomPLState, err := GetRoomState(matrixClient, id.RoomID(room)) + roomPLState, err := GetRoomPowerLevelState(matrixClient, id.RoomID(room)) if err != nil { log.Printf("Failed to get room power levels - %v", err) return diff --git a/internal/config/structs.go b/internal/config/structs.go index 888359f..a054fe5 100644 --- a/internal/config/structs.go +++ b/internal/config/structs.go @@ -35,16 +35,21 @@ type RoomConfig struct { // Debug specifies if the bot shall run in dry run mode Debug bool `yaml:"debug" bson:"debug"` + // AlertChannel is currently unused AlertChannel *string `bson:"alert_channel"` + // AdminPowerLevel specifies the power-level a user has to have to manage the room AdminPowerLevel int `bson:"admin_power_level"` + // HashChecker contains configuration specific to the hash-checker HashChecker HashCheckerConfig `bson:"hash_checker"` } type HashCheckerConfig struct { + // NoticeToChat specifies weather or not to post a public notice to chat NoticeToChat bool `bson:"chat_notice"` + // NotificationPowerLevel is currently unused NotificationPowerLevel int `yaml:"notification_level" bson:"notification_level"` /* @@ -58,5 +63,6 @@ type HashCheckerConfig struct { */ HashCheckMode uint8 `yaml:"mode" bson:"hash_check_mode"` + // SubscribedLists contains the lists this room is subscribed to SubscribedLists []*primitive.ObjectID `bson:"subscribed_lists" json:"subscribed_lists"` } diff --git a/internal/db/db.go b/internal/db/db.go index 9321219..c5d536c 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -108,3 +108,51 @@ func GetListByID(id primitive.ObjectID) (*model.DBHashList, error) { return &object, nil } + +func SaveUser(user *model.DBUser) error { + db := DbClient.Database(viper.GetString("bot.mongo.database")) + + opts := options.Replace().SetUpsert(true) + + filter := bson.D{{"_id", user.ID}} + + _, err := db.Collection(viper.GetString("bot.mongo.collection.users")).ReplaceOne(context.TODO(), filter, user, opts) + + return err +} + +func GetUserByID(id primitive.ObjectID) (*model.DBUser, error) { + db := DbClient.Database(viper.GetString("bot.mongo.database")) + + res := db.Collection(viper.GetString("bot.mongo.collection.users")).FindOne(context.TODO(), bson.D{{"_id", id}}) + if res.Err() != nil { + return nil, res.Err() + } + + object := model.DBUser{} + + err := res.Decode(&object) + if err != nil { + return nil, err + } + + return &object, nil +} + +func GetUserByUsername(username string) (*model.DBUser, error) { + db := DbClient.Database(viper.GetString("bot.mongo.database")) + + res := db.Collection(viper.GetString("bot.mongo.collection.users")).FindOne(context.TODO(), bson.D{{"username", username}}) + if res.Err() != nil { + return nil, res.Err() + } + + object := model.DBUser{} + + err := res.Decode(&object) + if err != nil { + return nil, err + } + + return &object, nil +} diff --git a/internal/db/model/comment.go b/internal/db/model/comment.go index 87c9efb..df91fe2 100644 --- a/internal/db/model/comment.go +++ b/internal/db/model/comment.go @@ -3,6 +3,6 @@ package model import "go.mongodb.org/mongo-driver/bson/primitive" type DBComment struct { - CommentedBy *primitive.ObjectID `bson:"commented_by" json:"commented_by"` - Content string `bson:"content" json:"content"` + CommentedBy *primitive.ObjectID `bson:"commented_by" json:"commented_by"` // CommentedBy contains a reference to the user who commented + Content string `bson:"content" json:"content"` // Content is the body of the comment } diff --git a/internal/db/model/entry.go b/internal/db/model/entry.go index b89b2a5..e153af0 100644 --- a/internal/db/model/entry.go +++ b/internal/db/model/entry.go @@ -7,11 +7,11 @@ import ( type DBEntry struct { ID primitive.ObjectID `bson:"_id" json:"id"` - Tags []string `bson:"tags" json:"tags"` - PartOf []*primitive.ObjectID `bson:"part_of" json:"part_of"` - HashValue string `bson:"hash_value" json:"hash"` - FileURL string `bson:"file_url" json:"file_url"` - Timestamp time.Time `bson:"timestamp" json:"timestamp"` - AddedBy *primitive.ObjectID `bson:"added_by" json:"added_by"` - Comments []*DBComment `bson:"comments" json:"comments"` + Tags []string `bson:"tags" json:"tags"` // Tags used for searching entries and ordering + PartOf []*primitive.ObjectID `bson:"part_of" json:"part_of"` // PartOf specifies the lists this entry is part of + HashValue string `bson:"hash_value" json:"hash"` // HashValue is the SHA512-hash of the file + FileURL string `bson:"file_url" json:"file_url"` // FileURL may be set to a file link + Timestamp time.Time `bson:"timestamp" json:"timestamp"` // Timestamp of when this entry was added + AddedBy *primitive.ObjectID `bson:"added_by" json:"added_by"` // AddedBy is a reference to the user who added this + Comments []*DBComment `bson:"comments" json:"comments"` // Comments regarding this entry } diff --git a/internal/db/model/list.go b/internal/db/model/list.go index b280cda..6b2f57a 100644 --- a/internal/db/model/list.go +++ b/internal/db/model/list.go @@ -4,7 +4,7 @@ import "go.mongodb.org/mongo-driver/bson/primitive" type DBHashList struct { ID primitive.ObjectID `bson:"_id" json:"id"` - Tags []string `bson:"tags" json:"tags"` - Comments []*DBComment `bson:"comments" json:"comments"` - Maintainers []*primitive.ObjectID `bson:"maintainers" json:"maintainers"` + Tags []string `bson:"tags" json:"tags"` // Tags of this list for discovery, and sorting + Comments []*DBComment `bson:"comments" json:"comments"` // Comments regarding this list + Maintainers []*primitive.ObjectID `bson:"maintainers" json:"maintainers"` // Maintainers contains references to the users who may edit this list } diff --git a/internal/db/model/user.go b/internal/db/model/user.go index b7785ae..cd4717f 100644 --- a/internal/db/model/user.go +++ b/internal/db/model/user.go @@ -2,14 +2,20 @@ package model import ( "encoding/base64" + "go.mongodb.org/mongo-driver/bson/primitive" "golang.org/x/crypto/bcrypt" ) type DBUser struct { - Username string `bson:"username" json:"username"` - HashedPassword string `bson:"password" json:"password"` + ID primitive.ObjectID `bson:"_id" json:"id"` - Password *string `bson:"-" json:"-"` + Username string `bson:"username" json:"username"` // Username is the username the user has + HashedPassword string `bson:"password" json:"password"` // HashedPassword contains the bcrypt-ed password + + MatrixLinks []*string `bson:"matrix_links" json:"matrix_links"` // MatrixLinks is the matrix-users this user has verified ownership over + PendingMatrixLinks []*string `bson:"pending_matrix_links" json:"pending_matrix_links"` // PendingMatrixLinks is the matrix-users pending verification + + Password *string `bson:"-" json:"-"` // Password may never be sent out! } func (usr *DBUser) HashPassword() error { @@ -23,6 +29,7 @@ func (usr *DBUser) HashPassword() error { } usr.HashedPassword = base64.StdEncoding.EncodeToString(hash) + usr.Password = nil return nil } diff --git a/internal/web/api/api.go b/internal/web/api/api.go new file mode 100644 index 0000000..bdfcb77 --- /dev/null +++ b/internal/web/api/api.go @@ -0,0 +1,86 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "github.com/gorilla/mux" + "net/http" + "strings" +) + +func SetupAPI(router *mux.Router) { + router.NotFoundHandler = NotFoundHandler{} + router.MethodNotAllowedHandler = MethodNotAllowedHandler{} + + router.Path("/auth/login").Methods("POST").HandlerFunc(apiHandleAuthLogin) + router.Path("/auth/register").Methods("POST").HandlerFunc(apiHandleAuthRegister) + + bot := router.PathPrefix("/bot").Subrouter() + bot.Use(checkAuthMiddleware) + + bot.Path("/test").HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(200) + + claims := request.Context().Value("claims").(jwtClaims) + + writer.Write([]byte(`hello ` + claims.Username)) + }) +} + +func checkAuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + token := req.Header.Get("Authorization") + tokenSplit := strings.Split(token, " ") + + if token == "" || len(tokenSplit) < 2 { + writeJSONError(res, http.StatusUnauthorized, errors.New("bearer token required")) + return + } + + token = tokenSplit[1] + + claims, _, err := parseToken(token) + if err != nil { + writeJSONError(res, http.StatusUnauthorized, errors.New("invalid token")) + return + } + + ctx := context.WithValue(req.Context(), "claims", *claims) + + req = req.WithContext(ctx) + + next.ServeHTTP(res, req) + }) +} + +func writeJSONError(res http.ResponseWriter, statusCode int, err error) { + res.Header().Set("Content-Type", "application/json") + res.WriteHeader(statusCode) + + enc, _ := json.Marshal(struct { + Error string `json:"error"` + ErrorCode int `json:"error_code"` + }{ + Error: err.Error(), + ErrorCode: statusCode, + }) + + _, _ = res.Write(enc) +} + +type NotFoundHandler struct{} + +func (NotFoundHandler) ServeHTTP(res http.ResponseWriter, req *http.Request) { + res.Header().Set("Content-Type", "application/json") + res.WriteHeader(http.StatusNotFound) + _, _ = res.Write([]byte(`{"error": "not_found","error_code":404}`)) +} + +type MethodNotAllowedHandler struct{} + +func (MethodNotAllowedHandler) ServeHTTP(res http.ResponseWriter, req *http.Request) { + res.Header().Set("Content-Type", "application/json") + res.WriteHeader(http.StatusMethodNotAllowed) + _, _ = res.Write([]byte(`{"error": "method_not_allowed","error_code":405}`)) +} diff --git a/internal/web/api/auth.go b/internal/web/api/auth.go new file mode 100644 index 0000000..bdadde2 --- /dev/null +++ b/internal/web/api/auth.go @@ -0,0 +1,173 @@ +package api + +import ( + "encoding/json" + "errors" + "github.com/Unkn0wnCat/matrix-veles/internal/db" + "github.com/Unkn0wnCat/matrix-veles/internal/db/model" + "github.com/golang-jwt/jwt/v4" + "github.com/spf13/viper" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "net/http" + "time" +) + +type apiAuthRequestBody struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type jwtClaims struct { + Username string `json:"username"` + jwt.RegisteredClaims +} + +func parseToken(tokenString string) (*jwtClaims, *jwt.Token, error) { + claims := jwtClaims{} + jwtSigningKey := []byte(viper.GetString("bot.web.secret")) + + token, err := jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (interface{}, error) { + return jwtSigningKey, nil + }) + if err != nil { + return nil, nil, err + } + + return &claims, token, nil +} + +func apiHandleAuthLogin(res http.ResponseWriter, req *http.Request) { + body := req.Body + + bodyContent := apiAuthRequestBody{} + + err := json.NewDecoder(body).Decode(&bodyContent) + if err != nil { + writeJSONError(res, http.StatusBadRequest, errors.New("malformed body")) + return + } + + user, err := db.GetUserByUsername(bodyContent.Username) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + writeJSONError(res, http.StatusUnauthorized, errors.New("invalid credentials")) + return + } + + writeJSONError(res, http.StatusInternalServerError, errors.New("database error")) + return + } + + err = user.CheckPassword(bodyContent.Password) + if err != nil { + writeJSONError(res, http.StatusUnauthorized, errors.New("invalid credentials")) + return + } + + jwtSigningKey := []byte(viper.GetString("bot.web.secret")) + + claims := jwtClaims{ + Username: user.Username, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 365 * 100)), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: "veles-api", + Subject: user.ID.Hex(), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + ss, err := token.SignedString(jwtSigningKey) + if err != nil { + writeJSONError(res, http.StatusInternalServerError, errors.New("unable to create token")) + return + } + + res.Header().Set("Content-Type", "application/json") + res.WriteHeader(http.StatusOK) + + enc, err := json.Marshal(struct { + Token string `json:"token"` + }{ + Token: ss, + }) + + _, _ = res.Write(enc) +} + +type apiAuthRegisterBody struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func apiHandleAuthRegister(res http.ResponseWriter, req *http.Request) { + body := req.Body + + bodyContent := apiAuthRegisterBody{} + + err := json.NewDecoder(body).Decode(&bodyContent) + if err != nil { + writeJSONError(res, http.StatusBadRequest, errors.New("malformed body")) + return + } + + _, err = db.GetUserByUsername(bodyContent.Username) + if err == nil { + writeJSONError(res, http.StatusBadRequest, errors.New("username taken")) + return + } + if !errors.Is(err, mongo.ErrNoDocuments) { + writeJSONError(res, http.StatusInternalServerError, errors.New("database error")) + return + } + + user := model.DBUser{ + ID: primitive.NewObjectID(), + Username: bodyContent.Username, + Password: &bodyContent.Password, + } + + err = user.HashPassword() + if err != nil { + writeJSONError(res, http.StatusInternalServerError, errors.New("unable to hash password")) + return + } + + err = db.SaveUser(&user) + if err != nil { + writeJSONError(res, http.StatusInternalServerError, errors.New("database error")) + return + } + + jwtSigningKey := viper.GetString("bot.web.secret") + + claims := jwtClaims{ + Username: user.Username, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: "veles-api", + Subject: user.ID.Hex(), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + ss, err := token.SignedString(jwtSigningKey) + if err != nil { + writeJSONError(res, http.StatusInternalServerError, errors.New("unable to create token")) + } + + res.Header().Set("Content-Type", "application/json") + res.WriteHeader(http.StatusOK) + + enc, err := json.Marshal(struct { + Token string `json:"token"` + }{ + Token: ss, + }) + + _, _ = res.Write(enc) +} diff --git a/internal/web/web.go b/internal/web/web.go new file mode 100644 index 0000000..c3c88d2 --- /dev/null +++ b/internal/web/web.go @@ -0,0 +1,35 @@ +package web + +import ( + "github.com/Unkn0wnCat/matrix-veles/internal/web/api" + "github.com/gorilla/mux" + "github.com/spf13/viper" + "log" + "net/http" + "time" +) + +func StartServer() { + r := mux.NewRouter() + r.HandleFunc("/", HomeHandler) + //r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))) + + apiRouter := r.PathPrefix("/api").Subrouter() + api.SetupAPI(apiRouter) + + srv := &http.Server{ + Handler: r, + Addr: viper.GetString("bot.web.listen"), + WriteTimeout: 15 * time.Second, + ReadTimeout: 15 * time.Second, + } + + log.Printf("Now serving web-interface on http://%s", viper.GetString("bot.web.listen")) + + log.Fatal(srv.ListenAndServe()) +} + +func HomeHandler(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(200) + writer.Write([]byte("hello world")) +}