diff --git a/.github/workflows/publish-ghcr.yml b/.github/workflows/publish-ghcr.yml index e001ef0..8e212ac 100644 --- a/.github/workflows/publish-ghcr.yml +++ b/.github/workflows/publish-ghcr.yml @@ -23,5 +23,6 @@ jobs: - name: Build and push images run: | + cd python IMAGE_NAME=ghcr.io/${{ github.repository }} docker buildx build --push -t $IMAGE_NAME:${{ env.SHORT_SHA }} . diff --git a/.github/workflows/publish-latest-ghcr.yml b/.github/workflows/publish-latest-ghcr.yml index 9416e03..ef26fb5 100644 --- a/.github/workflows/publish-latest-ghcr.yml +++ b/.github/workflows/publish-latest-ghcr.yml @@ -20,5 +20,6 @@ jobs: - name: Build and push images run: | + cd python IMAGE_NAME=ghcr.io/${{ github.repository }} docker buildx build --push -t $IMAGE_NAME:latest . diff --git a/.gitignore b/.gitignore index 06709be..7523596 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ __pycache__/ *.json -*.env \ No newline at end of file +*.env + +.idea/ \ No newline at end of file diff --git a/README.md b/README.md index 0a6f5c9..83aca53 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,8 @@ + ![](./branding/server%20banner.svg) # Server Official source code of the Meower server, written in Python. Powered by CloudLink. -## Running -```py -git clone https://github.com/meower-media/server.git --recursive -cd Meower-Server -cd Meower-Server -pip install -r requirements.txt - -cp .env.example .env - -# edit env files - -python3 main.py -``` +the go stuff, in cmd/* and pkg/* has no security features, so be careful!!! -## API docs -See [the autogenerated documentation](https://api.meower.org/docs) and the [Meower documentation](https://docs.meower.org) +this branch is the subject of a major rewrite diff --git a/cmd/events/main.go b/cmd/events/main.go new file mode 100644 index 0000000..ad7f407 --- /dev/null +++ b/cmd/events/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "log" + "os" + + "github.com/getsentry/sentry-go" + "github.com/joho/godotenv" + "github.com/meower-media-co/server/pkg/api/events" +) + +func main() { + // Load dotenv + godotenv.Load() + + // Initialise Sentry + sentry.Init(sentry.ClientOptions{ + Dsn: os.Getenv("EVENTS_SENTRY_DSN"), + }) + + // Get expose address + exposeAddr := os.Getenv("EVENTS_ADDRESS") + if exposeAddr == "" { + exposeAddr = ":3000" + } + + // Create & run server + server := events.NewServer() + err := server.Run(exposeAddr) + if err != nil { + log.Fatalln(err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fc67436 --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module github.com/meower-media-co/server + +go 1.22.5 + +require ( + github.com/getsentry/sentry-go v0.28.1 + github.com/gorilla/websocket v1.5.3 + github.com/joho/godotenv v1.5.1 + github.com/redis/go-redis/v9 v9.6.1 + github.com/vmihailenco/msgpack/v5 v5.3.5 +) + +require ( + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..22ac4ba --- /dev/null +++ b/go.sum @@ -0,0 +1,45 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/getsentry/sentry-go v0.28.1 h1:zzaSm/vHmGllRM6Tpx1492r0YDzauArdBfkJRtY6P5k= +github.com/getsentry/sentry-go v0.28.1/go.mod h1:1fQZ+7l7eeJ3wYi82q5Hg8GqAPgefRq+FP/QhafYVgg= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4= +github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= +github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/api/events/events.go b/pkg/api/events/events.go new file mode 100644 index 0000000..495079e --- /dev/null +++ b/pkg/api/events/events.go @@ -0,0 +1,375 @@ +package events + +import ( + "strconv" + + "github.com/meower-media-co/server/pkg/api/events/models" + "github.com/meower-media-co/server/pkg/api/events/packets" + "github.com/meower-media-co/server/pkg/events" +) + +func sendUpdateUser(s *Server, e *events.UpdateUser) error { + // Construct v0/v1 packets + v0 := &packets.V0UpdateUser{ + Username: e.User.Username, + Avatar: e.User.Avatar, + LegacyAvatar: e.User.LegacyAvatar, + Color: e.User.Color, + Flags: e.User.Flags, + Quote: e.User.Quote, + } + v1 := v0 + + // Create packet to send to clients + p, err := createPacket( + s, + &packets.V0Packet{ + Cmd: "direct", + Val: &packets.V0Packet{ + Mode: "update_profile", + Payload: v0, + }, + }, + &packets.V1Packet{ + Cmd: "update_profile", + Val: v1, + }, + ) + if err != nil { + return err + } + + go func() { + // Send to self + selfSessions := s.sessions //s.users[ps.UserId] + for _, sess := range selfSessions { + sess.send <- p + } + + // Send to related users + relatedSessions := s.relationships[e.User.Id] + for _, sess := range relatedSessions { + sess.send <- p + } + + // Send to chats (somehow) + }() + + return nil +} + +func sendUpdateRelationship(s *Server, e *events.UpdateRelationship) error { + v0p := &packets.V0UpdateRelationship{ + User: models.ConstructUserV0(&e.To), + Username: e.To.Username, + State: e.State, + UpdatedAt: e.UpdatedAt, + } + p, err := createPacket( + s, + &packets.V0Packet{ + Cmd: "direct", + Val: &packets.V0Packet{ + Mode: "update_relationship", + Payload: v0p, + }, + }, + &packets.V1Packet{ + Cmd: "update_relationship", + Val: v0p, + }, + ) + if err != nil { + return err + } + + go func() { + selfSessions := s.sessions //s.users[ps.UserId] + for _, sess := range selfSessions { + sess.send <- p + } + }() + + return nil +} + +func sendTyping(s *Server, e *events.Typing) error { + v0p := &packets.V0Typing{ + ChatId: strconv.FormatInt(e.ChatId, 10), + State: 100, + Username: e.User.Username, + } + v1p := &packets.V1Typing{ + ChatId: strconv.FormatInt(e.ChatId, 10), + User: models.ConstructUserV0(&e.User), + Username: e.User.Username, + } + if e.ChatId == 0 || e.ChatId == 1 { + v0p.ChatId = "livechat" + v1p.ChatId = "livechat" + if e.ChatId == 0 { + v0p.State = 101 + } + } + + p, err := createPacket( + s, + &packets.V0Packet{ + Cmd: "direct", + Val: v0p, + }, + &packets.V1Packet{ + Cmd: "typing", + Val: v1p, + }, + ) + if err != nil { + return err + } + + go func() { + selfSessions := s.sessions //s.users[ps.UserId] + for _, sess := range selfSessions { + sess.send <- p + } + }() + + return nil +} + +func sendCreatePost(s *Server, e *events.CreatePost) error { + // Construct v0/v1 posts + v0Post := models.ConstructPostV0( + &e.Post, + e.Users, + e.ReplyTo, + e.Emotes, + e.Attachments, + ) + + // Construct v0/v1 packets + v0p := &packets.V0Packet{ + Cmd: "direct", + Val: &packets.V0CreatePost{ + V0Post: v0Post, + }, + } + if v0Post.ChatId == "home" { + v0p.Val.(*packets.V0CreatePost).Mode = 1 + } else { + v0p.Val.(*packets.V0CreatePost).State = 2 + } + + // Create packet to send to clients + p, err := createPacket( + s, + v0p, + &packets.V1Packet{ + Cmd: "post", + Val: v0Post, // same as v1 + }, + ) + if err != nil { + return err + } + + go func() { + selfSessions := s.sessions //s.users[ps.UserId] + for _, sess := range selfSessions { + sess.send <- p + } + }() + + return nil +} + +func sendUpdatePost(s *Server, e *events.UpdatePost) error { + // Construct v0/v1 posts + v0Post := models.ConstructPostV0( + &e.Post, + e.Users, + e.ReplyTo, + e.Emotes, + e.Attachments, + ) + + // Create packet to send to clients + p, err := createPacket( + s, + &packets.V0Packet{ + Cmd: "direct", + Val: &packets.V0Packet{ + Mode: "update_post", + Payload: &v0Post, + }, + }, + &packets.V1Packet{ + Cmd: "update_post", + Val: &v0Post, + }, + ) + if err != nil { + return err + } + + go func() { + selfSessions := s.sessions //s.users[ps.UserId] + for _, sess := range selfSessions { + sess.send <- p + } + }() + + return nil +} + +func sendDeletePost(s *Server, e *events.DeletePost) error { + v1p := &packets.V1DeletePost{ + ChatId: strconv.FormatInt(e.ChatId, 10), + PostId: strconv.FormatInt(e.PostId, 10), + } + if e.ChatId == 0 { + v1p.ChatId = "home" + } + + p, err := createPacket( + s, + &packets.V0Packet{ + Cmd: "direct", + Val: &packets.V0DeletePost{ + Mode: "delete", + PostId: strconv.FormatInt(e.PostId, 10), + }, + }, + &packets.V1Packet{ + Cmd: "delete_post", + Val: v1p, + }, + ) + if err != nil { + return err + } + + go func() { + selfSessions := s.sessions //s.users[ps.UserId] + for _, sess := range selfSessions { + sess.send <- p + } + }() + + return nil +} + +func sendBulkDeletePosts(s *Server, e *events.BulkDeletePosts) error { + v1p := &packets.V1BulkDeletePosts{ + ChatId: strconv.FormatInt(e.ChatId, 10), + StartId: strconv.FormatInt(e.StartId, 10), + EndId: strconv.FormatInt(e.EndId, 10), + } + if e.ChatId == 0 { + v1p.ChatId = "home" + } + for _, postId := range e.PostIds { + v1p.PostIds = append(v1p.PostIds, strconv.FormatInt(postId, 10)) + } + + p, err := createPacket( + s, + nil, + &packets.V1Packet{ + Cmd: "bulk_delete_posts", + Val: v1p, + }, + ) + if err != nil { + return err + } + + go func() { + selfSessions := s.sessions //s.users[ps.UserId] + for _, sess := range selfSessions { + sess.send <- p + } + }() + + return nil +} + +func sendPostReactionAdd(s *Server, e *events.PostReactionAdd) error { + v0p := &packets.V0PostReactionAdd{ + ChatId: strconv.FormatInt(e.ChatId, 10), + PostId: strconv.FormatInt(e.PostId, 10), + Emoji: e.Emoji, + User: models.ConstructUserV0(&e.User), + Username: e.User.Username, + } + if e.ChatId == 0 { + v0p.ChatId = "home" + } + + p, err := createPacket( + s, + &packets.V0Packet{ + Cmd: "direct", + Val: &packets.V0Packet{ + Mode: "post_reaction_add", + Payload: v0p, + }, + }, + &packets.V1Packet{ + Cmd: "post_reaction_add", + Val: v0p, + }, + ) + if err != nil { + return err + } + + go func() { + selfSessions := s.sessions //s.users[ps.UserId] + for _, sess := range selfSessions { + sess.send <- p + } + }() + + return nil +} + +func sendPostReactionRemove(s *Server, e *events.PostReactionRemove) error { + v0p := &packets.V0PostReactionRemove{ + ChatId: strconv.FormatInt(e.ChatId, 10), + PostId: strconv.FormatInt(e.PostId, 10), + Emoji: e.Emoji, + User: models.ConstructUserV0(&e.User), + Username: e.User.Username, + } + if e.ChatId == 0 { + v0p.ChatId = "home" + } + + p, err := createPacket( + s, + &packets.V0Packet{ + Cmd: "direct", + Val: &packets.V0Packet{ + Mode: "post_reaction_remove", + Payload: v0p, + }, + }, + &packets.V1Packet{ + Cmd: "post_reaction_remove", + Val: v0p, + }, + ) + if err != nil { + return err + } + + go func() { + selfSessions := s.sessions //s.users[ps.UserId] + for _, sess := range selfSessions { + sess.send <- p + } + }() + + return nil +} diff --git a/pkg/api/events/models/attachment.go b/pkg/api/events/models/attachment.go new file mode 100644 index 0000000..8664e42 --- /dev/null +++ b/pkg/api/events/models/attachment.go @@ -0,0 +1,25 @@ +package models + +import ( + "github.com/meower-media-co/server/pkg/posts" +) + +type V0Attachment struct { + Id string `json:"id" msgpack:"id"` + Filename string `json:"filename" msgpack:"filename"` + Mime string `json:"mime" msgpack:"mime"` + Size int `json:"size" msgpack:"size"` + Width int `json:"width" msgpack:"width"` + Height int `json:"height" msgpack:"height"` +} + +func ConstructAttachmentV0(a *posts.Attachment) *V0Attachment { + return &V0Attachment{ + Id: a.Id, + Filename: a.Filename, + Mime: a.Mime, + Size: a.Size, + Width: a.Width, + Height: a.Height, + } +} diff --git a/pkg/api/events/models/emote.go b/pkg/api/events/models/emote.go new file mode 100644 index 0000000..d93c825 --- /dev/null +++ b/pkg/api/events/models/emote.go @@ -0,0 +1,23 @@ +package models + +import ( + "strconv" + + "github.com/meower-media-co/server/pkg/chats" +) + +type V0Emote struct { + Id string `json:"_id" msgpack:"_id"` + ChatId string `json:"chat_id" msgpack:"chat_id"` + Name string `json:"name" msgpack:"name"` + Animated bool `json:"animated" msgpack:"animated"` +} + +func ConstructEmoteV0(e *chats.Emote) *V0Emote { + return &V0Emote{ + Id: strconv.FormatInt(e.Id, 10), + ChatId: strconv.FormatInt(e.ChatId, 10), + Name: e.Name, + Animated: e.Animated, + } +} diff --git a/pkg/api/events/models/post.go b/pkg/api/events/models/post.go new file mode 100644 index 0000000..71843fe --- /dev/null +++ b/pkg/api/events/models/post.go @@ -0,0 +1,110 @@ +package models + +import ( + "strconv" + + "github.com/meower-media-co/server/pkg/chats" + "github.com/meower-media-co/server/pkg/meowid" + "github.com/meower-media-co/server/pkg/posts" + "github.com/meower-media-co/server/pkg/users" +) + +type V0Post struct { + Id string `json:"_id" msgpack:"_id"` + PostId string `json:"post_id" msgpack:"post_id"` + ChatId string `json:"post_origin" msgpack:"post_origin"` + Type int8 `json:"type" msgpack:"type"` // 1 for regular posts, 2 for inbox posts + Author *V0User `json:"author" msgpack:"author"` + AuthorUsername string `json:"u" msgpack:"u"` + ReplyTo []*V0Post `json:"reply_to" msgpack:"reply_to"` + Timestamp struct { + Unix int64 `json:"e" msgpack:"e"` + } `json:"t" msgpack:"t"` + Content string `json:"p" msgpack:"p"` + Emojis []*V0Emote `json:"emojis" msgpack:"emojis"` + Stickers []*V0Emote `json:"stickers" msgpack:"stickers"` + Attachments []*V0Attachment `json:"attachments" msgpack:"attachments"` + ReactionIndexes []*V0ReactionIndex `json:"reactions" msgpack:"reactions"` + LastEdited *int64 `json:"last_edited,omitempty" msgpack:"last_edited,omitempty"` + Pinned *bool `json:"pinned,omitempty" msgpack:"pinned,omitempty"` + Deleted bool `json:"isDeleted" msgpack:"isDeleted"` + + Nonce string `json:"nonce,omitempty" msgpack:"nonce,omitempty"` +} + +type V1Post = V0Post + +func ConstructPostV0( + p *posts.Post, + users map[int64]*users.User, + replyTo map[int64]*posts.Post, + emotes map[string]*chats.Emote, + attachments map[string]*posts.Attachment, +) *V0Post { + if p == nil { + return nil + } + + v0p := &V0Post{ + Id: strconv.FormatInt(p.Id, 10), + PostId: strconv.FormatInt(p.Id, 10), + ChatId: strconv.FormatInt(p.ChatId, 10), + Type: 1, + ReplyTo: []*V0Post{}, + Timestamp: struct { + Unix int64 "json:\"e\" msgpack:\"e\"" + }{Unix: meowid.Extract(p.Id).Timestamp}, + Content: *p.Content, + Emojis: []*V0Emote{}, + Stickers: []*V0Emote{}, + Attachments: []*V0Attachment{}, + ReactionIndexes: []*V0ReactionIndex{}, + LastEdited: p.LastEdited, + Pinned: p.Pinned, + } + if v0p.ChatId == "0" { + v0p.ChatId = "home" + } else if v0p.ChatId == "1" { + v0p.ChatId = "livechat" + } + if p.AuthorId != nil { + v0p.Author = ConstructUserV0(users[*p.AuthorId]) + v0p.AuthorUsername = v0p.Author.Username + } + if p.ReplyToIds != nil { + for _, replyToId := range *p.ReplyToIds { + v0p.ReplyTo = append( + v0p.ReplyTo, + ConstructPostV0( + replyTo[replyToId], + users, + make(map[int64]*posts.Post, 0), + emotes, + attachments, + ), + ) + } + } + if p.EmojiIds != nil { + for _, emojiId := range *p.EmojiIds { + v0p.Emojis = append(v0p.Emojis, ConstructEmoteV0(emotes[emojiId])) + } + } + if p.StickerIds != nil { + for _, stickerId := range *p.StickerIds { + v0p.Stickers = append(v0p.Stickers, ConstructEmoteV0(emotes[stickerId])) + } + } + if p.AttachmentIds != nil { + for _, attachmentId := range *p.AttachmentIds { + v0p.Attachments = append(v0p.Attachments, ConstructAttachmentV0(attachments[attachmentId])) + } + } + if p.ReactionIndexes != nil { + for _, reactionIndex := range *p.ReactionIndexes { + v0p.ReactionIndexes = append(v0p.ReactionIndexes, ConstructReactionIndex(&reactionIndex)) + } + } + + return v0p +} diff --git a/pkg/api/events/models/reaction_index.go b/pkg/api/events/models/reaction_index.go new file mode 100644 index 0000000..d342a53 --- /dev/null +++ b/pkg/api/events/models/reaction_index.go @@ -0,0 +1,17 @@ +package models + +import "github.com/meower-media-co/server/pkg/posts" + +type V0ReactionIndex struct { + Emoji string `json:"emoji" msgpack:"emoji"` + Count int `json:"count" msgpack:"count"` + UserReacted bool `json:"user_reacted" msgpack:"user_reacted"` +} + +func ConstructReactionIndex(r *posts.ReactionIndex) *V0ReactionIndex { + return &V0ReactionIndex{ + Emoji: r.Emoji, + Count: r.Count, + UserReacted: false, + } +} diff --git a/pkg/api/events/models/user.go b/pkg/api/events/models/user.go new file mode 100644 index 0000000..56cd531 --- /dev/null +++ b/pkg/api/events/models/user.go @@ -0,0 +1,35 @@ +package models + +import ( + "strconv" + + "github.com/meower-media-co/server/pkg/users" +) + +type V0User struct { + Id string `json:"uuid" msgpack:"uuid"` + Username string `json:"_id" msgpack:"_id"` // required for v0 and v1 + Flags *int64 `json:"flags" msgpack:"flags"` + Avatar *string `json:"avatar" msgpack:"avatar"` + LegacyAvatar *int8 `json:"pfp_data" msgpack:"pfp_data"` + Color *string `json:"avatar_color" msgpack:"avatar_color"` + Quote *string `json:"quote,omitempty" msgpack:"quote,omitempty"` +} + +type V1User V0User + +func ConstructUserV0(u *users.User) *V0User { + if u == nil { + u = &users.DeletedUser + } + + return &V0User{ + Id: strconv.FormatInt(u.Id, 10), + Username: u.Username, + Flags: u.Flags, + Avatar: u.Avatar, + LegacyAvatar: u.LegacyAvatar, + Color: u.Color, + Quote: u.Quote, + } +} diff --git a/pkg/api/events/packet.go b/pkg/api/events/packet.go new file mode 100644 index 0000000..7695d10 --- /dev/null +++ b/pkg/api/events/packet.go @@ -0,0 +1,53 @@ +package events + +import ( + "encoding/json" + "strconv" + "time" + + "github.com/meower-media-co/server/pkg/api/events/packets" + "github.com/vmihailenco/msgpack/v5" +) + +type Packet struct { + Nonce int64 + CreatedAt int64 + + V0JsonEncoded []byte + V0MsgpackEncoded []byte + + V1JsonEncoded []byte +} + +func createPacket(server *Server, v0 *packets.V0Packet, v1 *packets.V1Packet) (*Packet, error) { + var p = Packet{ + Nonce: server.getNextNonce(), + CreatedAt: time.Now().UnixMilli(), + } + var err error + + // Add nonce to versioned packets + strNonce := strconv.FormatInt(p.Nonce, 10) + v0.Nonce = strNonce + v1.Nonce = strNonce + + // v0 json + p.V0JsonEncoded, err = json.Marshal(v0) + if err != nil { + return nil, err + } + + // v0 msgpack + p.V0MsgpackEncoded, err = msgpack.Marshal(v0) + if err != nil { + return nil, err + } + + // v1 json + p.V1JsonEncoded, err = json.Marshal(v1) + if err != nil { + return nil, err + } + + return &p, err +} diff --git a/pkg/api/events/packets/bulk_delete_posts.go b/pkg/api/events/packets/bulk_delete_posts.go new file mode 100644 index 0000000..e83c766 --- /dev/null +++ b/pkg/api/events/packets/bulk_delete_posts.go @@ -0,0 +1,8 @@ +package packets + +type V1BulkDeletePosts struct { + ChatId string `json:"chat_id" msgpack:"chat_id"` + StartId string `json:"start_id" msgpack:"start_id"` + EndId string `json:"end_id" msgpack:"end_id"` + PostIds []string `json:"post_ids" msgpack:"post_ids"` +} diff --git a/pkg/api/events/packets/create_post.go b/pkg/api/events/packets/create_post.go new file mode 100644 index 0000000..ad9029b --- /dev/null +++ b/pkg/api/events/packets/create_post.go @@ -0,0 +1,11 @@ +package packets + +import "github.com/meower-media-co/server/pkg/api/events/models" + +type V0CreatePost struct { + Mode int `json:"mode,omitempty" msgpack:"mode,omitempty"` // will be 1 for home posts, otherwise will be absent + State int `json:"state,omitempty" msgpack:"state,omitempty"` // will be 2 for chat posts, otherwise will be absent + *models.V0Post +} + +type V1CreatePost V0CreatePost diff --git a/pkg/api/events/packets/delete_post.go b/pkg/api/events/packets/delete_post.go new file mode 100644 index 0000000..004c4fc --- /dev/null +++ b/pkg/api/events/packets/delete_post.go @@ -0,0 +1,11 @@ +package packets + +type V0DeletePost struct { + Mode string `json:"mode" msgpack:"mode"` // will always be 'delete' + PostId string `json:"id" msgpack:"id"` +} + +type V1DeletePost struct { + ChatId string `json:"chat_id" msgpack:"chat_id"` + PostId string `json:"post_id" msgpack:"post_id"` +} diff --git a/pkg/api/events/packets/hello.go b/pkg/api/events/packets/hello.go new file mode 100644 index 0000000..66bed06 --- /dev/null +++ b/pkg/api/events/packets/hello.go @@ -0,0 +1,8 @@ +package packets + +type V0Hello struct { + SessionId string `json:"session_id" msgpack:"session_id"` + PingInterval int `json:"ping_interval" msgpack:"ping_interval"` +} + +type V1Hello = V0Hello diff --git a/pkg/api/events/packets/packet.go b/pkg/api/events/packets/packet.go new file mode 100644 index 0000000..1b038f6 --- /dev/null +++ b/pkg/api/events/packets/packet.go @@ -0,0 +1,17 @@ +package packets + +type V0Packet struct { + Cmd string `json:"cmd,omitempty" msgpack:"cmd,omitempty"` + Mode interface{} `json:"mode,omitempty" msgpack:"mode,omitempty"` + Val interface{} `json:"val,omitempty" msgpack:"val,omitempty"` + Payload interface{} `json:"payload,omitempty" msgpack:"payload,omitempty"` + Listener string `json:"listener,omitempty" msgpack:"listener,omitempty"` + Nonce string `json:"nonce,omitempty" msgpack:"nonce,omitempty"` +} + +type V1Packet struct { + Cmd string `json:"cmd"` + Val interface{} `json:"val"` + Listener string `json:"listener,omitempty"` + Nonce string `json:"nonce,omitempty"` +} diff --git a/pkg/api/events/packets/post_reaction_add.go b/pkg/api/events/packets/post_reaction_add.go new file mode 100644 index 0000000..b2ad098 --- /dev/null +++ b/pkg/api/events/packets/post_reaction_add.go @@ -0,0 +1,13 @@ +package packets + +import "github.com/meower-media-co/server/pkg/api/events/models" + +type V0PostReactionAdd struct { + ChatId string `json:"chat_id" msgpack:"chat_id"` + PostId string `json:"post_id" msgpack:"post_id"` + Emoji string `json:"emoji" msgpack:"emoji"` + User *models.V0User `json:"user" msgpack:"user"` + Username string `json:"username" msgpack:"username"` +} + +type V1PostReactionAdd = V0PostReactionAdd diff --git a/pkg/api/events/packets/post_reaction_remove.go b/pkg/api/events/packets/post_reaction_remove.go new file mode 100644 index 0000000..b8a9a97 --- /dev/null +++ b/pkg/api/events/packets/post_reaction_remove.go @@ -0,0 +1,13 @@ +package packets + +import "github.com/meower-media-co/server/pkg/api/events/models" + +type V0PostReactionRemove struct { + ChatId string `json:"chat_id" msgpack:"chat_id"` + PostId string `json:"post_id" msgpack:"post_id"` + Emoji string `json:"emoji" msgpack:"emoji"` + User *models.V0User `json:"user" msgpack:"user"` + Username string `json:"username" msgpack:"username"` +} + +type V1PostReactionRemove = V0PostReactionRemove diff --git a/pkg/api/events/packets/typing.go b/pkg/api/events/packets/typing.go new file mode 100644 index 0000000..ca23d97 --- /dev/null +++ b/pkg/api/events/packets/typing.go @@ -0,0 +1,15 @@ +package packets + +import "github.com/meower-media-co/server/pkg/api/events/models" + +type V0Typing struct { + ChatId string `json:"chatid" msgpack:"chatid"` + State int8 `json:"state" msgpack:"state"` // 100 for chats, 101 in 'livechat' for home + Username string `json:"u" msgpack:"u"` +} + +type V1Typing struct { + ChatId string `json:"chat_id" msgpack:"chat_id"` + User *models.V0User `json:"user" msgpack:"user"` + Username string `json:"username" msgpack:"username"` +} diff --git a/pkg/api/events/packets/update_post.go b/pkg/api/events/packets/update_post.go new file mode 100644 index 0000000..36dcc2b --- /dev/null +++ b/pkg/api/events/packets/update_post.go @@ -0,0 +1,7 @@ +package packets + +import "github.com/meower-media-co/server/pkg/api/events/models" + +type V0UpdatePost = models.V0Post + +type V1UpdatePost = V0UpdatePost diff --git a/pkg/api/events/packets/update_relationship.go b/pkg/api/events/packets/update_relationship.go new file mode 100644 index 0000000..01bb2df --- /dev/null +++ b/pkg/api/events/packets/update_relationship.go @@ -0,0 +1,12 @@ +package packets + +import "github.com/meower-media-co/server/pkg/api/events/models" + +type V0UpdateRelationship struct { + User *models.V0User `json:"user" msgpack:"user"` + Username string `json:"username" msgpack:"username"` + State int8 `json:"state" msgpack:"state"` + UpdatedAt int64 `json:"updated_at" msgpack:"updated_at"` +} + +type V1UpdateRelationship = V0UpdateRelationship diff --git a/pkg/api/events/packets/update_user.go b/pkg/api/events/packets/update_user.go new file mode 100644 index 0000000..30c6aa0 --- /dev/null +++ b/pkg/api/events/packets/update_user.go @@ -0,0 +1,7 @@ +package packets + +import "github.com/meower-media-co/server/pkg/api/events/models" + +type V0UpdateUser = models.V0User + +type V1UpdateUser = V0UpdateUser diff --git a/pkg/api/events/server.go b/pkg/api/events/server.go new file mode 100644 index 0000000..021ac5e --- /dev/null +++ b/pkg/api/events/server.go @@ -0,0 +1,237 @@ +package events + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "strconv" + "sync" + + "github.com/gorilla/websocket" + "github.com/meower-media-co/server/pkg/events" + "github.com/redis/go-redis/v9" + "github.com/vmihailenco/msgpack/v5" +) + +type Server struct { + httpMux *http.ServeMux + + sessions map[int64]*Session + users map[int64][]*Session + relationships map[int64][]*Session + chats map[int64][]*Session + + nextNonce int64 + nonceMutex sync.Mutex +} + +func NewServer() *Server { + // Create WebSocket upgrader + upgrader := websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { return true }, + EnableCompression: true, + } + + // Create server + s := Server{ + httpMux: http.NewServeMux(), + + sessions: make(map[int64]*Session), + users: make(map[int64][]*Session), + relationships: make(map[int64][]*Session), + chats: make(map[int64][]*Session), + + nextNonce: 0, + nonceMutex: sync.Mutex{}, + } + s.httpMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // Get current session or create new session + var session *Session + if r.URL.Query().Has("sid") && r.URL.Query().Has("nonce") { + sid, _ := strconv.ParseInt(r.URL.Query().Get("sid"), 10, 64) + session = s.sessions[sid] + if session == nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Session not found.")) + return + } + } else { + session = newSession(&s) + } + + // Upgrade connection + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + + // Register connection + err = session.registerConn(conn, 0, 0) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Failed registering connection to session.")) + return + } + + // Re-send missed packets + if r.URL.Query().Has("sid") && r.URL.Query().Has("nonce") { + lastNonce, _ := strconv.ParseInt(r.URL.Query().Get("nonce"), 10, 64) + for _, packet := range session.packets { + if packet.Nonce > lastNonce { + session.writeToConn(packet) + } + } + } + }) + + return &s +} + +func (s *Server) getNextNonce() int64 { + s.nonceMutex.Lock() + defer s.nonceMutex.Unlock() + nonce := s.nextNonce + s.nextNonce++ + return nonce +} + +func (s *Server) pubSub() error { + // Create client + opt, err := redis.ParseURL(os.Getenv("REDIS_URL")) + if err != nil { + return err + } + rdb := redis.NewClient(opt) + + // Create ctx + ctx := context.Background() + + // Create pub/sub channel + pubsub := rdb.Subscribe(ctx, "events") + + // Listen to incoming pub/sub events + go func() { + for msg := range pubsub.Channel() { + // Parse event + payload := []byte(msg.Payload) + eventType := payload[0] + payload = payload[1:] + + // Construct and send event + switch eventType { + case events.OpUpdateUser: + var evData events.UpdateUser + if err := msgpack.Unmarshal(payload, &evData); err != nil { + log.Println(err) + continue + } + if err := sendUpdateUser(s, &evData); err != nil { + log.Println(err) + continue + } + + case events.OpUpdateRelationship: + var evData events.UpdateRelationship + if err := msgpack.Unmarshal(payload, &evData); err != nil { + log.Println(err) + continue + } + if err := sendUpdateRelationship(s, &evData); err != nil { + log.Println(err) + continue + } + + case events.OpTyping: + var evData events.Typing + if err := msgpack.Unmarshal(payload, &evData); err != nil { + log.Println(err) + continue + } + if err := sendTyping(s, &evData); err != nil { + log.Println(err) + continue + } + + case events.OpCreatePost: + var evData events.CreatePost + if err := msgpack.Unmarshal(payload, &evData); err != nil { + log.Println(err) + continue + } + if err := sendCreatePost(s, &evData); err != nil { + log.Println(err) + continue + } + case events.OpUpdatePost: + var evData events.UpdatePost + if err := msgpack.Unmarshal(payload, &evData); err != nil { + log.Println(err) + continue + } + if err := sendUpdatePost(s, &evData); err != nil { + log.Println(err) + continue + } + case events.OpDeletePost: + var evData events.DeletePost + if err := msgpack.Unmarshal(payload, &evData); err != nil { + log.Println(err) + continue + } + if err := sendDeletePost(s, &evData); err != nil { + log.Println(err) + continue + } + case events.OpBulkDeletePosts: + var evData events.BulkDeletePosts + if err := msgpack.Unmarshal(payload, &evData); err != nil { + log.Println(err) + continue + } + if err := sendBulkDeletePosts(s, &evData); err != nil { + log.Println(err) + continue + } + + case events.OpPostReactionAdd: + var evData events.PostReactionAdd + if err := msgpack.Unmarshal(payload, &evData); err != nil { + log.Println(err) + continue + } + if err := sendPostReactionAdd(s, &evData); err != nil { + log.Println(err) + continue + } + case events.OpPostReactionRemove: + var evData events.PostReactionRemove + if err := msgpack.Unmarshal(payload, &evData); err != nil { + log.Println(err) + continue + } + if err := sendPostReactionRemove(s, &evData); err != nil { + log.Println(err) + continue + } + } + } + }() + + return nil +} + +func (s *Server) Run(exposeAddr string) error { + // Start pub/sub + err := s.pubSub() + if err != nil { + return err + } + + // Start HTTP server + fmt.Println("Serving events HTTP on", exposeAddr) + return http.ListenAndServe(exposeAddr, s.httpMux) +} diff --git a/pkg/api/events/session.go b/pkg/api/events/session.go new file mode 100644 index 0000000..5068941 --- /dev/null +++ b/pkg/api/events/session.go @@ -0,0 +1,206 @@ +package events + +import ( + "fmt" + "strconv" + "time" + + "github.com/gorilla/websocket" + "github.com/meower-media-co/server/pkg/api/events/packets" +) + +type Session struct { + id int64 + server *Server + + userId int64 + relationships map[int64]bool + chats map[int64]bool + + send chan *Packet + packets []*Packet + lastSeenNonce int64 + + conn *websocket.Conn + protoVersion int8 + protoFormat int8 // 0: json, 1: msgpack (future use) + disconnectedAt int64 + + ended bool +} + +const pingInterval = 45_000 // 45 seconds + +func newSession(server *Server) *Session { + // Create & register session + s := Session{ + id: server.getNextNonce(), + server: server, + + relationships: make(map[int64]bool), + chats: make(map[int64]bool), + + send: make(chan *Packet, 256), + packets: []*Packet{}, + } + s.server.sessions[s.id] = &s + + // Write thread + go func() { + for packet := range s.send { + // Make sure to not re-send packets + if packet.Nonce <= s.lastSeenNonce { + continue + } else { + s.lastSeenNonce = packet.Nonce + } + + // Add to packets history + s.packets = append(s.packets, packet) + + // Write message to conn if one exists + if s.conn != nil { + s.writeToConn(packet) + } + } + }() + + // Background thread + go func() { + for { + time.Sleep(time.Millisecond * pingInterval) + + // Ping + + // Check for session timeout & remove old packet history + if s.ended { + break + } else if s.conn == nil { // end session if there has been no conn for more than the ping interval + if s.disconnectedAt < time.Now().Add(-(time.Millisecond * pingInterval)).UnixMilli() { + s.endSession() + break + } + } else { // remove packets from history that are more than the ping interval + ts45SecsAgo := time.Now().Add(-(time.Millisecond * pingInterval)).UnixMilli() + itemsToRemove := 0 + for _, packet := range s.packets { + if packet.CreatedAt < ts45SecsAgo { + itemsToRemove++ + } + } + s.packets = s.packets[itemsToRemove:] + } + } + }() + + return &s +} + +func (s *Session) registerConn(conn *websocket.Conn, protoVersion int8, protoFormat int8) error { + // Close current connection if one exists + if s.conn != nil { + s.conn.WriteMessage(websocket.CloseAbnormalClosure, []byte{}) + err := s.conn.Close() + if err != nil { + return err + } + } + + // Set conn and protocol + s.conn = conn + s.protoVersion = protoVersion + s.protoFormat = protoFormat + + // Read incoming messages until connection ends + go func() { + for { + // Get next message + _, msg, err := conn.ReadMessage() + if err != nil { + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + s.endSession() + } else { + conn.Close() + s.conn = nil + s.disconnectedAt = time.Now().Unix() + } + break + } + fmt.Println(msg) + } + }() + + // Send hello + hello := &packets.V0Hello{ + SessionId: strconv.FormatInt(s.id, 10), + PingInterval: pingInterval, + } + p, _ := createPacket( + s.server, + &packets.V0Packet{ + Cmd: "hello", + Val: hello, + }, + &packets.V1Packet{ + Cmd: "hello", + Val: hello, + }, + ) + s.send <- p + + return nil +} + +func (s *Session) writeToConn(packet *Packet) { + if s.conn == nil { + return + } + + var err error + + // v0 - json + if s.protoVersion == 0 && s.protoFormat == 0 && packet.V0JsonEncoded != nil { + err = s.conn.WriteMessage(websocket.TextMessage, packet.V0JsonEncoded) + } + + // v0 - msgpack + if s.protoVersion == 0 && s.protoFormat == 1 && packet.V0MsgpackEncoded != nil { + err = s.conn.WriteMessage(websocket.BinaryMessage, packet.V0MsgpackEncoded) + } + + if err != nil { + s.conn.Close() + } +} + +func (s *Session) regRelationship(userId int64) { + s.relationships[userId] = true + //s.server.relationships[s.userId] = userId +} + +func (s *Session) endSession() error { + // Make sure session hasn't already ended + if s.ended { + return nil + } + + // Set ended state + s.ended = true + + // De-register + delete(s.server.sessions, s.id) + + // Close send channel & wipe vars + close(s.send) + //s.channels = nil + s.packets = nil + + // Close connection if one exists + if s.conn != nil { + s.conn.WriteMessage(websocket.CloseAbnormalClosure, []byte{}) + s.conn.Close() + s.conn = nil + } + + return nil +} diff --git a/pkg/chats/chat.go b/pkg/chats/chat.go new file mode 100644 index 0000000..d113c6b --- /dev/null +++ b/pkg/chats/chat.go @@ -0,0 +1,7 @@ +package chats + +type Chat struct { + Id int64 `msgpack:"id"` + Type int8 `msgpack:"type"` + Nickname string `msgpack:"nickname"` +} diff --git a/pkg/chats/emote.go b/pkg/chats/emote.go new file mode 100644 index 0000000..224739c --- /dev/null +++ b/pkg/chats/emote.go @@ -0,0 +1,9 @@ +package chats + +type Emote struct { + Id int64 `msgpack:"id"` + ChatId int64 `msgpack:"chat_id"` + Name string `msgpack:"name"` + Animated bool `msgpack:"animated"` + CreatorId int64 `msgpack:"creator_id,omitempty"` +} diff --git a/pkg/events/bulk_delete_posts.go b/pkg/events/bulk_delete_posts.go new file mode 100644 index 0000000..7a75de6 --- /dev/null +++ b/pkg/events/bulk_delete_posts.go @@ -0,0 +1,8 @@ +package events + +type BulkDeletePosts struct { + ChatId int64 `msgpack:"chat_id"` + StartId int64 `msgpack:"start_id"` + EndId int64 `msgpack:"end_id"` + PostIds []int64 `msgpack:"post_ids"` +} diff --git a/pkg/events/create_post.go b/pkg/events/create_post.go new file mode 100644 index 0000000..9b4f5c3 --- /dev/null +++ b/pkg/events/create_post.go @@ -0,0 +1,24 @@ +package events + +import ( + "github.com/meower-media-co/server/pkg/chats" + "github.com/meower-media-co/server/pkg/meowid" + "github.com/meower-media-co/server/pkg/posts" + "github.com/meower-media-co/server/pkg/users" +) + +type CreatePost struct { + Post posts.Post `msgpack:"post"` + ReplyTo map[int64]*posts.Post `msgpack:"reply_to"` + Users map[int64]*users.User `msgpack:"users"` + Emotes map[string]*chats.Emote `msgpack:"emotes"` + Attachments map[string]*posts.Attachment `msgpack:"attachments"` + + // will only be added if the post was sent in a DM + // if the DM is not open for the client, we will open it for them + // before sending the post + DMToId *meowid.MeowID `msgpack:"dm_to"` + DMChat *interface{} `msgpack:"dm_chat"` + + Nonce string `msgpack:"nonce,omitempty"` +} diff --git a/pkg/events/create_user.go b/pkg/events/create_user.go new file mode 100644 index 0000000..f879127 --- /dev/null +++ b/pkg/events/create_user.go @@ -0,0 +1,7 @@ +package events + +import "github.com/meower-media-co/server/pkg/users" + +type CreateUser struct { + User users.User `msgpack:"user"` +} diff --git a/pkg/events/delete_post.go b/pkg/events/delete_post.go new file mode 100644 index 0000000..e0f675f --- /dev/null +++ b/pkg/events/delete_post.go @@ -0,0 +1,6 @@ +package events + +type DeletePost struct { + ChatId int64 `msgpack:"chat_id"` + PostId int64 `msgpack:"post_id"` +} diff --git a/pkg/events/delete_user.go b/pkg/events/delete_user.go new file mode 100644 index 0000000..794a546 --- /dev/null +++ b/pkg/events/delete_user.go @@ -0,0 +1,9 @@ +package events + +import ( + "github.com/meower-media-co/server/pkg/meowid" +) + +type DeleteUser struct { + UserId meowid.MeowID `msgpack:"user_id"` +} diff --git a/pkg/events/op_codes.go b/pkg/events/op_codes.go new file mode 100644 index 0000000..cc8f101 --- /dev/null +++ b/pkg/events/op_codes.go @@ -0,0 +1,40 @@ +package events + +// temporary implementation symbols +// '// *' means it's implemented in the events package, but isn't going to be used in the events API +// '// //' means it's implemented in the events package and events API +// no comment means it's yet to be implemented + +const ( + OpCreateUser uint8 = 0 // * + OpUpdateUser uint8 = 1 // // + OpDeleteUser uint8 = 2 // * + + OpUpdateUserSettings uint8 = 3 + + OpRevokeSession uint8 = 4 + + OpUpdateRelationship uint8 = 5 // // + + OpCreateChat uint8 = 6 + OpUpdateChat uint8 = 7 + OpDeleteChat uint8 = 8 + + OpCreateChatMember uint8 = 9 + OpUpdateChatMember uint8 = 10 + OpDeleteChatMember uint8 = 11 + + OpCreateChatEmote uint8 = 12 + OpUpdateChatEmote uint8 = 13 + OpDeleteChatEmote uint8 = 14 + + OpTyping uint8 = 15 // // + + OpCreatePost uint8 = 16 // // + OpUpdatePost uint8 = 17 // // + OpDeletePost uint8 = 18 // // + OpBulkDeletePosts uint8 = 19 // // + + OpPostReactionAdd uint8 = 20 // // + OpPostReactionRemove uint8 = 21 // // +) diff --git a/pkg/events/post_reaction_add.go b/pkg/events/post_reaction_add.go new file mode 100644 index 0000000..1adac7d --- /dev/null +++ b/pkg/events/post_reaction_add.go @@ -0,0 +1,10 @@ +package events + +import "github.com/meower-media-co/server/pkg/users" + +type PostReactionAdd struct { + ChatId int64 `msgpack:"chat_id"` + PostId int64 `msgpack:"post_id"` + Emoji string `msgpack:"emoji"` + User users.User `msgpack:"user"` +} diff --git a/pkg/events/post_reaction_remove.go b/pkg/events/post_reaction_remove.go new file mode 100644 index 0000000..93214c3 --- /dev/null +++ b/pkg/events/post_reaction_remove.go @@ -0,0 +1,10 @@ +package events + +import "github.com/meower-media-co/server/pkg/users" + +type PostReactionRemove struct { + ChatId int64 `msgpack:"chat_id"` + PostId int64 `msgpack:"post_id"` + Emoji string `msgpack:"emoji"` + User users.User `msgpack:"user"` +} diff --git a/pkg/events/typing.go b/pkg/events/typing.go new file mode 100644 index 0000000..71296b4 --- /dev/null +++ b/pkg/events/typing.go @@ -0,0 +1,8 @@ +package events + +import "github.com/meower-media-co/server/pkg/users" + +type Typing struct { + ChatId int64 `msgpack:"chat_id"` + User users.User `msgpack:"user"` +} diff --git a/pkg/events/update_post.go b/pkg/events/update_post.go new file mode 100644 index 0000000..69e6bf7 --- /dev/null +++ b/pkg/events/update_post.go @@ -0,0 +1,15 @@ +package events + +import ( + "github.com/meower-media-co/server/pkg/chats" + "github.com/meower-media-co/server/pkg/posts" + "github.com/meower-media-co/server/pkg/users" +) + +type UpdatePost struct { + Post posts.Post `msgpack:"post"` + ReplyTo map[int64]*posts.Post `msgpack:"reply_to"` + Users map[int64]*users.User `msgpack:"users"` + Emotes map[string]*chats.Emote `msgpack:"emotes"` + Attachments map[string]*posts.Attachment `msgpack:"attachments"` +} diff --git a/pkg/events/update_relationship.go b/pkg/events/update_relationship.go new file mode 100644 index 0000000..f5a534d --- /dev/null +++ b/pkg/events/update_relationship.go @@ -0,0 +1,10 @@ +package events + +import "github.com/meower-media-co/server/pkg/users" + +type UpdateRelationship struct { + From users.User `msgpack:"from"` + To users.User `msgpack:"to"` + State int8 `msgpack:"state"` + UpdatedAt int64 `msgpack:"updated_at"` +} diff --git a/pkg/events/update_user.go b/pkg/events/update_user.go new file mode 100644 index 0000000..3aeaa83 --- /dev/null +++ b/pkg/events/update_user.go @@ -0,0 +1,7 @@ +package events + +import "github.com/meower-media-co/server/pkg/users" + +type UpdateUser struct { + User users.User `msgpack:"user"` +} diff --git a/pkg/meowid/meowid.go b/pkg/meowid/meowid.go new file mode 100644 index 0000000..5e355b2 --- /dev/null +++ b/pkg/meowid/meowid.go @@ -0,0 +1,98 @@ +package meowid + +import ( + "log" + "math" + "os" + "strconv" + "sync" + "time" +) + +// MeowID Format: +// Timestamp (42-bits) +// Node ID (11-bits) +// Increment (11-bits) + +type MeowID = int64 + +const MeowerEpoch int64 = 1577836800000 // 2020-01-01 12am GMT + +const ( + TimestampBits = 41 + TimestampMask = (1 << TimestampBits) - 1 + + NodeIdBits = 11 + NodeIdMask = (1 << NodeIdBits) - 1 + + IncrementBits = 11 +) + +var NodeId int +var MaxIncrement = math.Pow(2, IncrementBits) - 1 + +var idIncrementLock = sync.Mutex{} +var idIncrementTs int64 = 0 +var idIncrement int64 = 0 + +func init() { + var err error + NodeId, err = strconv.Atoi(os.Getenv("NODE_ID")) + if err != nil { + log.Fatalln(err) + } +} + +func GenId() int64 { + // Get timestamp + ts := time.Now().UnixMilli() + + // Get increment + idIncrementLock.Lock() + defer idIncrementLock.Unlock() + if idIncrementTs != ts { + idIncrementTs = ts + idIncrement = 0 + } else if idIncrement >= int64(math.Pow(2, IncrementBits))-1 { + for time.Now().UnixMilli() == ts { + continue + } + return GenId() + } else { + idIncrement += 1 + } + + // Construct ID + id := (ts - MeowerEpoch) << (NodeIdBits + IncrementBits) + id |= int64(NodeId) << IncrementBits + id |= idIncrement + + return id +} + +// WARNING: This may result in conflicts because it generates the 1st possible +// ID for the given timestamp. +func GenIdForTs(ts int64) int64 { + // Construct ID + id := (ts - MeowerEpoch) << (NodeIdBits + IncrementBits) + id |= 0 << IncrementBits + id |= 0 + + return id +} + +func Extract(id int64) struct { + Timestamp int64 + NodeId int64 + Increment int64 +} { + return struct { + Timestamp int64 + NodeId int64 + Increment int64 + }{ + Timestamp: ((id >> (64 - TimestampBits - 1)) & TimestampMask) + MeowerEpoch, + NodeId: (id >> (64 - TimestampBits - NodeIdBits - 1)) & NodeIdMask, + Increment: id & NodeIdMask, + } +} diff --git a/pkg/posts/attachment.go b/pkg/posts/attachment.go new file mode 100644 index 0000000..5f65c26 --- /dev/null +++ b/pkg/posts/attachment.go @@ -0,0 +1,10 @@ +package posts + +type Attachment struct { + Id string `msgpack:"id"` + Mime string `msgpack:"mime"` + Filename string `msgpack:"filename"` + Size int `msgpack:"size"` + Width int `msgpack:"width"` + Height int `msgpack:"height"` +} diff --git a/pkg/posts/post.go b/pkg/posts/post.go new file mode 100644 index 0000000..d8f059e --- /dev/null +++ b/pkg/posts/post.go @@ -0,0 +1,17 @@ +package posts + +import "github.com/meower-media-co/server/pkg/meowid" + +type Post struct { + Id meowid.MeowID `msgpack:"id"` + ChatId meowid.MeowID `msgpack:"chat_id"` // 0: home, 1: livechat, 2: inbox + AuthorId *meowid.MeowID `msgpack:"author_id"` + ReplyToIds *[]meowid.MeowID `msgpack:"reply_to_ids"` + Content *string `msgpack:"content"` + EmojiIds *[]string `msgpack:"emoji_ids"` + StickerIds *[]string `msgpack:"sticker_ids"` + AttachmentIds *[]string `msgpack:"attachment_ids"` + ReactionIndexes *[]ReactionIndex `msgpack:"reactions"` + LastEdited *int64 `msgpack:"last_edited"` + Pinned *bool `msgpack:"pinned"` +} diff --git a/pkg/posts/reaction.go b/pkg/posts/reaction.go new file mode 100644 index 0000000..86aafb4 --- /dev/null +++ b/pkg/posts/reaction.go @@ -0,0 +1,7 @@ +package posts + +type Reaction struct { + PostId int64 `msgpack:"post_id"` + Emoji string `msgpack:"emoji"` + UserId int64 `msgpack:"user_id"` +} diff --git a/pkg/posts/reaction_index.go b/pkg/posts/reaction_index.go new file mode 100644 index 0000000..d485e67 --- /dev/null +++ b/pkg/posts/reaction_index.go @@ -0,0 +1,6 @@ +package posts + +type ReactionIndex struct { + Emoji string `msgpack:"emoji"` + Count int `msgpack:"count"` +} diff --git a/pkg/rdb/rdb.go b/pkg/rdb/rdb.go new file mode 100644 index 0000000..7d8d92d --- /dev/null +++ b/pkg/rdb/rdb.go @@ -0,0 +1,28 @@ +package rdb + +import ( + "context" + "log" + "os" + + "github.com/redis/go-redis/v9" +) + +var Client *redis.Client + +func init() { + // Get Redis options + rdbOpts, err := redis.ParseURL(os.Getenv("REDIS_URI")) + if err != nil { + log.Fatalln(err) + } + + // Create Redis client + Client = redis.NewClient(rdbOpts) + + // Ping Redis cluster + status := Client.Ping(context.Background()) + if status.Err() != nil { + log.Fatalln(err) + } +} diff --git a/pkg/users/deleted_user.go b/pkg/users/deleted_user.go new file mode 100644 index 0000000..d7bb0bc --- /dev/null +++ b/pkg/users/deleted_user.go @@ -0,0 +1,18 @@ +package users + +var deletedFlags int64 = 0 +var deletedAvatar string = "" +var deletedLegacyAvatar int8 = 0 +var deletedColor string = "" +var deletedQuote string = "" + +var DeletedUser User = User{ + Id: 1, + Username: "Deleted", + + Flags: &deletedFlags, + Avatar: &deletedAvatar, + LegacyAvatar: &deletedLegacyAvatar, + Color: &deletedColor, + Quote: &deletedQuote, +} diff --git a/pkg/users/user.go b/pkg/users/user.go new file mode 100644 index 0000000..625f127 --- /dev/null +++ b/pkg/users/user.go @@ -0,0 +1,12 @@ +package users + +type User struct { + Id int64 `msgpack:"id"` + Username string `msgpack:"username"` // required for v0 and v1 events + + Flags *int64 `msgpack:"flags"` + Avatar *string `msgpack:"avatar"` + LegacyAvatar *int8 `msgpack:"legacy_avatar"` + Color *string `msgpack:"color"` + Quote *string `msgpack:"quote"` +} diff --git a/cloudlink.py b/python/cloudlink.py similarity index 100% rename from cloudlink.py rename to python/cloudlink.py diff --git a/database.py b/python/database.py similarity index 84% rename from database.py rename to python/database.py index c1d8e14..0065c9a 100644 --- a/database.py +++ b/python/database.py @@ -4,9 +4,10 @@ import secrets from radix import Radix +from meowid import gen_id_injected, MEOWER_EPOCH from utils import log -CURRENT_DB_VERSION = 9 +CURRENT_DB_VERSION = 10 # Create Redis connection log("Connecting to Redis...") @@ -306,6 +307,37 @@ def get_total_pages(collection: str, query: dict, page_size: int = 25) -> int: "mfa_recovery_code": user["mfa_recovery_code"][:10] }}) + + log("[Migrator] Adding MeowID to posts") + updates: list[pymongo.UpdateOne] = [] + for post in db.get_collection("posts").find({"meowid": {"$exists": False}}, projection={"_id": 1, "t.e": 1}): + updates.append(pymongo.UpdateOne({"_id": post["_id"]}, {"$set": {"meowid": gen_id_injected(post["t"]["e"])}})) + if len(updates): + db.get_collection("posts").bulk_write(updates) + + log("[Migrator] Adding MeowID to chats") + updates: list[pymongo.UpdateOne] = [] + for chat in db.get_collection("chats").find({"meowid": {"$exists": False}}, projection={"_id": 1, "created": 1}): + time = chat.get("created", 0) + if time is None: + time = (MEOWER_EPOCH // 1000) + updates.append(pymongo.UpdateOne({"_id": chat["_id"]}, {"$set": {"meowid": gen_id_injected(time)}})) + if len(updates): + db.get_collection("chats").bulk_write(updates) + + log("[Migrator] Adding MeowID to usersv0") + updates: list[pymongo.UpdateOne] = [] + for user in db.get_collection("usersv0").find({"meowid": {"$exists": False}}, projection={"_id": 1, "created": 1}): + time = user.get("created", 0) + if time is None: + time = (MEOWER_EPOCH // 1000) + updates.append(pymongo.UpdateOne({"_id": user["_id"]}, {"$set": {"meowid": gen_id_injected(time)}})) + if len(updates): + db.get_collection("usersv0").bulk_write(updates) + db.get_collection("user_settings").bulk_write(updates) + + + db.config.update_one({"_id": "migration"}, {"$set": {"database": CURRENT_DB_VERSION}}) log(f"[Migrator] Finished Migrating DB to version {CURRENT_DB_VERSION}") diff --git a/dockerfile b/python/dockerfile similarity index 100% rename from dockerfile rename to python/dockerfile diff --git a/python/events.py b/python/events.py new file mode 100644 index 0000000..1a0fb5f --- /dev/null +++ b/python/events.py @@ -0,0 +1,150 @@ +""" +This module connects the API to the websocket server. +""" + +from typing import Any + +import msgpack + +from database import rdb, db +from supporter import Supporter + +OpCreateUser = 0 +OpUpdateUser = 1 +OpDeleteUser = 2 +OpUpdateUserSettings = 3 + +OpRevokeSession = 4 + +OpUpdateRelationship = 5 + +OpCreateChat = 6 +OpUpdateChat = 7 +OpDeleteChat = 8 + +OpCreateChatMember = 9 +OpUpdateChatMember = 10 +OpDeleteChatMember = 11 + +OpCreateChatEmote = 12 +OpUpdateChatEmote = 13 +OpDeleteChatEmote = 14 + +OpTyping = 15 + +OpCreatePost = 16 +OpUpdatePost = 17 +OpDeletePost = 18 +OpBulkDeletePosts = 19 + +OpPostReactionAdd = 20 +OpPostReactionRemove = 21 + +class Events: + def __init__(self): + # noinspection PyTypeChecker + self.supporter: Supporter = None + + def add_supporter(self, supporter: Supporter): + self.supporter = supporter + + def parse_post_meowid(self, post: dict[str, Any], include_replies: bool = True): + post = list(self.supporter.parse_posts_v0([post], include_replies=include_replies, include_revisions=False))[0] + + match post["post_origin"]: + case "home": + chat_id = 0 + case "livechat": + chat_id = 1 + case "inbox": + chat_id = 2 + case _: + chat_id = db.get_collection("chats").find_one({"_id": post["post_origin"]}, projection={"meowid": 1})[ + "meowid"] + + replys = [] + if include_replies: + replys = [reply["meowid"] for reply in post["reply_to"]] + + return { + "id": post["meowid"], + "chat_id": chat_id, + "author_id": post["author"]["meowid"], + "reply_to_ids": replys, + "emoji_ids": [emoji["id"] for emoji in post["emojis"]], + "sticker_ids": post["stickers"], + "attachments": post["attachments"], + "content": post["p"], + "reactions": [{ + "emoji": reaction["emoji"], + "count": reaction["count"] + } for reaction in post["reactions"]], + "last_edited": post.get("edited_at", 0), + "pinned": post["pinned"] + } + + @staticmethod + def parse_user_meowid(partial_user: dict[str, Any]): + quote = db.get_collection("usersv0").find_one({"_id": partial_user["_id"]}, projection={"quote": 1})["quote"] + return { + "id": partial_user["meowid"], + "username": partial_user["_id"], + "flags": partial_user["flags"], + "avatar": partial_user["avatar"], + "legacy_avatar": partial_user["pfp_data"], + "color": partial_user["avatar_color"], + "quote": quote + } + + def send_post_event(self, original_post: dict[str, Any]): + post = self.parse_post_meowid(original_post, include_replies=True) + + users = [self.parse_user_meowid(post["author"])] + + replies = {} + for reply in post["reply_to_ids"]: + replies[reply] = self.parse_post_meowid(db.get_collection("posts").find_one({"meowid": reply}), + include_replies=False) + users.append(self.parse_user_meowid(replies[reply]["author"])) + + emotes = {} + for emoji in post["emoji_ids"]: + emotes[emoji["_id"]] = { + "id": emoji["_id"], + "chat_id": db.get_collection("chats").find_one({"_id": emoji["chat_id"]}, projection={"meowid": 1})[ + "meowid"], + "name": emoji["name"], + "animated": emoji["animated"], + } + + data = { + "post": post, + "reply_to": replies, + "emotes": emotes, + "attachments": original_post["attachments"], + "author": users, + } + + is_dm = db.get_collection("chats").find_one({"_id": original_post["post_origin"], "owner": None}, + projection={"meowid": 1}) + if is_dm: + data["dm_to"] = db.get_collection("users") \ + .find_one({"_id": original_post["author"]["_id"]}, projection={"meowid": 1}) \ + ["meowid"] + + data["dm_chat"] = None # unspecifed + + if "nonce" in original_post: + data["nonce"] = original_post["nonce"] + + self.send_event(OpCreatePost, data) + + @staticmethod + def send_event(event: int, data: dict[str, any]): + payload = bytearray(msgpack.packb(data)) + payload.insert(0, event) + + rdb.publish("events", payload) + + +events = Events() diff --git a/grpc_auth/auth_service_pb2.py b/python/grpc_auth/auth_service_pb2.py similarity index 100% rename from grpc_auth/auth_service_pb2.py rename to python/grpc_auth/auth_service_pb2.py diff --git a/grpc_auth/auth_service_pb2.pyi b/python/grpc_auth/auth_service_pb2.pyi similarity index 100% rename from grpc_auth/auth_service_pb2.pyi rename to python/grpc_auth/auth_service_pb2.pyi diff --git a/grpc_auth/auth_service_pb2_grpc.py b/python/grpc_auth/auth_service_pb2_grpc.py similarity index 100% rename from grpc_auth/auth_service_pb2_grpc.py rename to python/grpc_auth/auth_service_pb2_grpc.py diff --git a/grpc_auth/service.py b/python/grpc_auth/service.py similarity index 100% rename from grpc_auth/service.py rename to python/grpc_auth/service.py diff --git a/grpc_uploads/client.py b/python/grpc_uploads/client.py similarity index 100% rename from grpc_uploads/client.py rename to python/grpc_uploads/client.py diff --git a/grpc_uploads/uploads_service_pb2.py b/python/grpc_uploads/uploads_service_pb2.py similarity index 100% rename from grpc_uploads/uploads_service_pb2.py rename to python/grpc_uploads/uploads_service_pb2.py diff --git a/grpc_uploads/uploads_service_pb2.pyi b/python/grpc_uploads/uploads_service_pb2.pyi similarity index 100% rename from grpc_uploads/uploads_service_pb2.pyi rename to python/grpc_uploads/uploads_service_pb2.pyi diff --git a/grpc_uploads/uploads_service_pb2_grpc.py b/python/grpc_uploads/uploads_service_pb2_grpc.py similarity index 100% rename from grpc_uploads/uploads_service_pb2_grpc.py rename to python/grpc_uploads/uploads_service_pb2_grpc.py diff --git a/main.py b/python/main.py similarity index 90% rename from main.py rename to python/main.py index b453bb1..87cc33f 100644 --- a/main.py +++ b/python/main.py @@ -1,5 +1,7 @@ # Load .env file from dotenv import load_dotenv + + load_dotenv() import asyncio @@ -13,6 +15,7 @@ from security import background_tasks_loop from grpc_auth import service as grpc_auth from rest_api import app as rest_api +from events import events if __name__ == "__main__": @@ -23,6 +26,8 @@ supporter = Supporter(cl) cl.supporter = supporter + events.add_supporter(supporter) + # Start background tasks loop Thread(target=background_tasks_loop, daemon=True).start() diff --git a/python/meowid.py b/python/meowid.py new file mode 100644 index 0000000..9ce3604 --- /dev/null +++ b/python/meowid.py @@ -0,0 +1,72 @@ +import os +import asyncio +import time + + +def limit_to_64_bits(value): + # Apply a 64-bit mask + return value & 0xFFFFFFFFFFFFFFFF + + +NODE_ID = int(os.environ["NODE_ID"]) + +MEOWER_EPOCH = 1577836800000 # 2020-01-01 12am GMT + +TIMESTAMP_BITS = 41 +TIMESTAMP_MASK = (1 << TIMESTAMP_BITS) - 1 + +NODE_ID_BITS = 11 +NODE_ID_MASK = (1 << NODE_ID_BITS) - 1 + +INCREMENT_BITS = 11 + +# 64 bytes +idIncrementTs: int = 0 +idIncrement: int = 0 + +lock = asyncio.Lock() + + +def get_ms() -> int: + return limit_to_64_bits(round(time.time() * 1000)) + + +async def gen_id() -> int: + global idIncrementTs + global idIncrement + + ts = get_ms() + async with lock: + if idIncrementTs != ts: + idIncrementTs = ts + idIncrement = 0 + elif idIncrement < ((2 ** INCREMENT_BITS) - 1): + while get_ms() == ts: + continue + return await gen_id() + else: + idIncrement += 1 + + id = (ts - MEOWER_EPOCH) << (NODE_ID_BITS + INCREMENT_BITS) + id |= (NODE_ID & NODE_ID_MASK) << INCREMENT_BITS + id |= idIncrement & ((1 << INCREMENT_BITS) - 1) + return id + + +def gen_id_injected(ts: int) -> int: + """ts is in seconds""" + ts = limit_to_64_bits(round(ts * 1000)) + global idIncrement + idIncrement += 1 + id = (ts - MEOWER_EPOCH) << (NODE_ID_BITS + INCREMENT_BITS) + id |= (NODE_ID & NODE_ID_MASK) << INCREMENT_BITS + id |= idIncrement & ((1 << INCREMENT_BITS) - 1) + return id + + +def extract(id: int): + timestamp = ((id >> (64 - TIMESTAMP_BITS - 1)) & TIMESTAMP_MASK) + MEOWER_EPOCH + node_id = (id >> (64 - TIMESTAMP_BITS - NODE_ID_BITS - 1) & NODE_ID_MASK) + increment = id & NODE_ID_MASK + + return timestamp, node_id, increment diff --git a/requirements.txt b/python/requirements.txt similarity index 100% rename from requirements.txt rename to python/requirements.txt diff --git a/rest_api/__init__.py b/python/rest_api/__init__.py similarity index 100% rename from rest_api/__init__.py rename to python/rest_api/__init__.py diff --git a/rest_api/admin.py b/python/rest_api/admin.py similarity index 100% rename from rest_api/admin.py rename to python/rest_api/admin.py diff --git a/rest_api/v0/__init__.py b/python/rest_api/v0/__init__.py similarity index 100% rename from rest_api/v0/__init__.py rename to python/rest_api/v0/__init__.py diff --git a/rest_api/v0/auth.py b/python/rest_api/v0/auth.py similarity index 100% rename from rest_api/v0/auth.py rename to python/rest_api/v0/auth.py diff --git a/python/rest_api/v0/bots.py b/python/rest_api/v0/bots.py new file mode 100644 index 0000000..f14457b --- /dev/null +++ b/python/rest_api/v0/bots.py @@ -0,0 +1,121 @@ +import os +import uuid +from typing import TYPE_CHECKING + +import requests +from quart import Blueprint, current_app as app, request, abort +from quart_schema import validate_request +from pydantic import BaseModel + +import security +import secrets + +from database import db + +if TYPE_CHECKING: + class Reqest: + user: str + flags: int + permissions: int + + request: Reqest + + from cloudlink import CloudlinkServer + from supporter import Supporter + class App: + supporter: "Supporter" + cl: "CloudlinkServer" + + + app: App + +bots_bp = Blueprint("bots", __name__, url_prefix="/bots") + +class CreateBot(BaseModel): + name: str + description: str + captcha: str + +class SecureRequests(BaseModel): + mfa_code: int + +@bots_bp.post("/") +@validate_request(CreateBot) +async def create_bot(data: CreateBot): + if not request.user: + abort(401) + + if os.getenv("CAPTCHA_SECRET") and not (hasattr(request, "bypass_captcha") and request.bypass_captcha): + if not requests.post("https://api.hcaptcha.com/siteverify", data={ + "secret": os.getenv("CAPTCHA_SECRET"), + "response": data.captcha, + }).json()["success"]: + return {"error": True, "type": "invalidCaptcha"}, 403 + + if not (security.ratelimited(f"bot:create:{request.user}")): + abort(429) + + + if any([ + db.users.find_one({"lower_username": data.name.lower()}), + db.bots.find_one({"name": data.name}) + ]): + return {"error": True, "type": "nameTaken"}, 409 + + token = secrets.token_urlsafe(32) + + bot = { + "_id": str(uuid.uuid4()), + "token": security.hash_password(token), + "name": data.name, + "description": data.description, + "owner": request.user, + "avatar": { + "default": 1, + "custom": None + } + } + + db.bots.insert_one(bot) + security.ratelimit(f"bot:create:{request.user}", 1, 60) + + bot["token"] = token + bot["error"] = False + + return bot, 200 + + +@bots_bp.get("/") +async def get_bots(): + if not request.user: + abort(401) + + bots = list(db.bots.find({"owner": request.user}, projection={"_id": 1, "name": 1, "avatar": 1})) + return {"error": False, "bots": bots}, 200 + +@bots_bp.get("/") +async def get_bot(bot_id: str): + if not request.user: + abort(401) + + bot = db.bots.find_one({"_id": bot_id, "owner": request.user}, projection={"token": 0}) + if not bot: + abort(404) + + return {"error": False, "bot": bot}, 200 + +@bots_bp.delete("/") +@validate_request(SecureRequests) +async def delete_bot(bot_id: str, data: SecureRequests): + if not request.user: + abort(401) + + bot = db.bots.find_one({"_id": bot_id, "owner": request.user}) + if not bot: + abort(404) + + if not security.check_mfa(request.user, data.mfa_code): + return {"error": True, "type": "invalidMfa"}, 403 + + db.bots.delete_one({"_id": bot_id}) + return {"error": False}, 200 \ No newline at end of file diff --git a/rest_api/v0/chats.py b/python/rest_api/v0/chats.py similarity index 96% rename from rest_api/v0/chats.py rename to python/rest_api/v0/chats.py index be1f795..c583914 100644 --- a/rest_api/v0/chats.py +++ b/python/rest_api/v0/chats.py @@ -6,6 +6,7 @@ import security from database import db, get_total_pages +from meowid import gen_id from uploads import claim_file, delete_file from utils import log @@ -96,6 +97,7 @@ async def create_chat(data: ChatBody): data.allow_pinning = False chat = { "_id": str(uuid.uuid4()), + "meowid": gen_id(), "type": 0, "nickname": data.nickname, "icon": data.icon, diff --git a/rest_api/v0/home.py b/python/rest_api/v0/home.py similarity index 100% rename from rest_api/v0/home.py rename to python/rest_api/v0/home.py diff --git a/rest_api/v0/inbox.py b/python/rest_api/v0/inbox.py similarity index 100% rename from rest_api/v0/inbox.py rename to python/rest_api/v0/inbox.py diff --git a/rest_api/v0/me.py b/python/rest_api/v0/me.py similarity index 100% rename from rest_api/v0/me.py rename to python/rest_api/v0/me.py diff --git a/rest_api/v0/posts.py b/python/rest_api/v0/posts.py similarity index 100% rename from rest_api/v0/posts.py rename to python/rest_api/v0/posts.py diff --git a/rest_api/v0/search.py b/python/rest_api/v0/search.py similarity index 100% rename from rest_api/v0/search.py rename to python/rest_api/v0/search.py diff --git a/rest_api/v0/users.py b/python/rest_api/v0/users.py similarity index 100% rename from rest_api/v0/users.py rename to python/rest_api/v0/users.py diff --git a/security.py b/python/security.py similarity index 96% rename from security.py rename to python/security.py index 6f5db22..e55eff0 100644 --- a/security.py +++ b/python/security.py @@ -3,6 +3,7 @@ import time, requests, os, uuid, secrets, bcrypt, msgpack from database import db, rdb +from meowid import gen_id from utils import log from uploads import clear_files @@ -127,6 +128,7 @@ def create_account(username: str, password: str, ip: str): # Create user db.usersv0.insert_one({ "_id": username, + "meowid": gen_id(), "lower_username": username.lower(), "uuid": str(uuid.uuid4()), "created": int(time.time()), diff --git a/supporter.py b/python/supporter.py similarity index 95% rename from supporter.py rename to python/supporter.py index bd220fa..e8eb36c 100644 --- a/supporter.py +++ b/python/supporter.py @@ -1,9 +1,11 @@ +import hashlib from threading import Thread from typing import Optional, Iterable, Any import uuid, time, msgpack, pymongo, re, copy from cloudlink import CloudlinkServer from database import db, rdb +from meowid import gen_id from uploads import FileDetails """ @@ -14,6 +16,8 @@ FILE_ID_REGEX = "[a-zA-Z0-9]{24}" CUSTOM_EMOJI_REGEX = f"<:({FILE_ID_REGEX})>" + + class Supporter: def __init__(self, cl: CloudlinkServer): # CL server @@ -84,6 +88,7 @@ def create_post( # Construct post object post = { "_id": post_id, + "meowid": gen_id(), "post_origin": origin, "u": author, "t": {"e": int(time.time())}, @@ -105,12 +110,16 @@ def create_post( if nonce: post["nonce"] = nonce + + # Send live packet if origin == "inbox": self.cl.send_event("inbox_message", copy.copy(post), usernames=(None if author == "Server" else [author])) else: self.cl.send_event("post", copy.copy(post), usernames=(None if origin in ["home", "livechat"] else chat_members)) + self.send_post_event(post) + # Update other database items if origin == "inbox": if author == "Server": @@ -192,7 +201,8 @@ def parse_posts_v0( "flags": 1, "pfp_data": 1, "avatar": 1, - "avatar_color": 1 + "avatar_color": 1, + "meowid": 1 })}) # Replies @@ -238,3 +248,4 @@ def parse_posts_v0( }) return posts + diff --git a/uploads.py b/python/uploads.py similarity index 100% rename from uploads.py rename to python/uploads.py diff --git a/utils.py b/python/utils.py similarity index 100% rename from utils.py rename to python/utils.py