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 28eee56..1f50f75 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ __pycache__/ -*.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/SECURITY.md b/SECURITY.md index 33fcbf3..94fb312 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,4 +12,4 @@ As of Feb. 7th, 2023, the currently deployed server version is built upon Cloudl If you believe that your data on any Meower Media products/services has been compromised, please contact an administrator of Meower Media directly, or contact support@meower.org immediately. From which, Meower Media will review your report in a timely fashion, and determine suitable course of action. ## Reporting a security vulnerability -If you believe you have discovered a security vulnerability, please report it to Meower Media through coordinated disclosure. To report a security vulnerability, [please do so here](https://github.com/meower-media-co/Meower-Server/security/advisories/new). If you have questions about whether an issue is a security vulnerability, please contact Meower Media at support@meower.org. +If you believe you have discovered a security vulnerability, please report it to Meower Media through coordinated disclosure. To report a security vulnerability, [please do so here](https://github.com/meower-media/Meower-Server/security/advisories/new). If you have questions about whether an issue is a security vulnerability, please contact Meower Media at support@meower.org. diff --git a/cmd/events/main.go b/cmd/events/main.go new file mode 100644 index 0000000..bef8e8e --- /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/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/main.py b/cmd/legacy/main.py similarity index 68% rename from main.py rename to cmd/legacy/main.py index a2a1955..3d613cb 100644 --- a/main.py +++ b/cmd/legacy/main.py @@ -1,48 +1,49 @@ -# Load .env file -from dotenv import load_dotenv -load_dotenv() - -import asyncio -import os -import uvicorn -import sentry_sdk - -from threading import Thread - -from cloudlink import CloudlinkServer -from supporter import Supporter -from security import background_tasks_loop -from grpc_auth import service as grpc_auth -from rest_api import app as rest_api - - -if __name__ == "__main__": - # Initialise Sentry (uses SENTRY_DSN env var) - sentry_sdk.init() - - # Create Cloudlink server - cl = CloudlinkServer() - - # Create Supporter class - supporter = Supporter(cl) - cl.supporter = supporter - - # Start background tasks loop - Thread(target=background_tasks_loop, daemon=True).start() - - # Start gRPC services - Thread(target=grpc_auth.serve, daemon=True).start() - - # Initialise REST API - rest_api.cl = cl - rest_api.supporter = supporter - - # Start REST API - Thread(target=uvicorn.run, args=(rest_api,), kwargs={ - "host": os.getenv("API_HOST", "0.0.0.0"), - "port": int(os.getenv("API_PORT", 3001)), - "root_path": os.getenv("API_ROOT", "") - }, daemon=True).start() - - # Start Cloudlink server - asyncio.run(cl.run(host=os.getenv("CL3_HOST", "0.0.0.0"), port=int(os.getenv("CL3_PORT", 3000)))) +# Load .env file +from dotenv import load_dotenv +load_dotenv() + +import os, sys, asyncio, uvicorn, sentry_sdk +from threading import Thread + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../pkg/legacy'))) + +from cloudlink import CloudlinkServer +from supporter import Supporter +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__": + # Initialise Sentry (uses SENTRY_DSN env var) + sentry_sdk.init() + + # Create Cloudlink server + cl = CloudlinkServer() + + # Create Supporter class + supporter = Supporter(cl) + cl.supporter = supporter + + events.add_supporter(supporter) + + # Start background tasks loop + Thread(target=background_tasks_loop, daemon=True).start() + + # Start gRPC services + Thread(target=grpc_auth.serve, daemon=True).start() + + # Initialise REST API + rest_api.cl = cl + rest_api.supporter = supporter + + # Start REST API + #Thread(target=uvicorn.run, args=(rest_api,), kwargs={ + # "host": os.getenv("API_HOST", "0.0.0.0"), + # "port": int(os.getenv("API_PORT", 3001)), + # "root_path": os.getenv("API_ROOT", "") + #}, daemon=True).start() + + # Start Cloudlink server + asyncio.run(cl.run(host=os.getenv("CL3_HOST", "0.0.0.0"), port=int(os.getenv("CL3_PORT", 3000)))) diff --git a/cmd/rest/main.go b/cmd/rest/main.go new file mode 100644 index 0000000..32db57d --- /dev/null +++ b/cmd/rest/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "log" + "net/http" + "os" + "time" + + "github.com/getsentry/sentry-go" + "github.com/joho/godotenv" + "github.com/meower-media/server/pkg/api/rest" + "github.com/meower-media/server/pkg/db" + "github.com/meower-media/server/pkg/emails" + "github.com/meower-media/server/pkg/meowid" + "github.com/meower-media/server/pkg/rdb" + "github.com/meower-media/server/pkg/users" +) + +func main() { + // Load dotenv + godotenv.Load() + + // Init Sentry + if err := sentry.Init(sentry.ClientOptions{ + Dsn: os.Getenv("SENTRY_DSN"), + }); err != nil { + panic(err) + } + + // Init MeowID + if err := meowid.Init(os.Getenv("NODE_ID")); err != nil { + panic(err) + } + + // Init MongoDB + if err := db.Init(os.Getenv("MONGO_URI"), os.Getenv("MONGO_DB")); err != nil { + panic(err) + } + + // Init Redis + if err := rdb.Init(os.Getenv("REDIS_URI")); err != nil { + panic(err) + } + + // Init token signing keys + if err := users.InitTokenSigningKeys(); err != nil { + panic(err) + } + + // Send test email + emails.SendEmail("verify", "Tnix", "test@tnix.dev", "abc123") + + // Serve HTTP router + port := os.Getenv("HTTP_PORT") + if port == "" { + port = "3000" + } + log.Println("Serving HTTP server on :" + port) + http.ListenAndServe(":"+port, rest.Router()) + + // Wait for Sentry events to flush + sentry.Flush(time.Second * 5) +} diff --git a/errors.py b/errors.py deleted file mode 100644 index 92f4d9b..0000000 --- a/errors.py +++ /dev/null @@ -1,7 +0,0 @@ -class InvalidTokenSignature(Exception): pass - -class AccSessionTokenExpired(Exception): pass - -class AccSessionNotFound(Exception): pass - -class EmailTicketExpired(Exception): pass \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e7201a1 --- /dev/null +++ b/go.mod @@ -0,0 +1,41 @@ +module github.com/meower-media/server + +go 1.22.5 + +require ( + github.com/getsentry/sentry-go v0.28.1 + github.com/go-chi/chi/v5 v5.1.0 + github.com/go-playground/validator/v10 v10.11.1 + github.com/gorilla/websocket v1.5.3 + github.com/joho/godotenv v1.5.1 + github.com/pquerna/otp v1.4.0 + github.com/redis/go-redis/v9 v9.6.1 + github.com/rs/cors v1.11.1 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + github.com/vmihailenco/msgpack/v5 v5.3.5 + github.com/yl2chen/cidranger v1.0.2 + go.mongodb.org/mongo-driver v1.16.1 + golang.org/x/crypto v0.22.0 + gopkg.in/mail.v2 v2.3.1 +) + +require ( + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-playground/locales v0.14.0 // indirect + github.com/go-playground/universal-translator v0.18.0 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/klauspost/compress v1.17.7 // indirect + github.com/leodido/go-urn v1.2.1 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f254778 --- /dev/null +++ b/go.sum @@ -0,0 +1,138 @@ +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +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-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +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/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU= +github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= +github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho= +github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= +github.com/go-playground/validator/v10 v10.11.1 h1:prmOlTVv+YjZjmRmNSF3VmspqJIxJWXmqUsHwfTRRkQ= +github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/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/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= +github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= +github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +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/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +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/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= +github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +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/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.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= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU= +github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver v1.16.1 h1:rIVLL3q0IHM39dvE+z2ulZLp9ENZKThVfuvN/IiN4l8= +go.mongodb.org/mongo-driver v1.16.1/go.mod h1:oB6AhJQvFQL4LEHyXi6aJzQJtBiTQHiAd83l0GdFaiw= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= +gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/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..86b2320 --- /dev/null +++ b/pkg/api/events/events.go @@ -0,0 +1,375 @@ +package events + +import ( + "strconv" + + "github.com/meower-media/server/pkg/api/events/models" + "github.com/meower-media/server/pkg/api/events/packets" + "github.com/meower-media/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..3079173 --- /dev/null +++ b/pkg/api/events/models/attachment.go @@ -0,0 +1,25 @@ +package models + +import ( + "github.com/meower-media/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..95db8cf --- /dev/null +++ b/pkg/api/events/models/emote.go @@ -0,0 +1,23 @@ +package models + +import ( + "strconv" + + "github.com/meower-media/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..11555fe --- /dev/null +++ b/pkg/api/events/models/post.go @@ -0,0 +1,110 @@ +package models + +import ( + "strconv" + + "github.com/meower-media/server/pkg/chats" + "github.com/meower-media/server/pkg/meowid" + "github.com/meower-media/server/pkg/posts" + "github.com/meower-media/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..85549f1 --- /dev/null +++ b/pkg/api/events/models/reaction_index.go @@ -0,0 +1,17 @@ +package models + +import "github.com/meower-media/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..dcda086 --- /dev/null +++ b/pkg/api/events/models/user.go @@ -0,0 +1,35 @@ +package models + +import ( + "strconv" + + "github.com/meower-media/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..c80d410 --- /dev/null +++ b/pkg/api/events/packet.go @@ -0,0 +1,53 @@ +package events + +import ( + "encoding/json" + "strconv" + "time" + + "github.com/meower-media/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..163d2fe --- /dev/null +++ b/pkg/api/events/packets/create_post.go @@ -0,0 +1,11 @@ +package packets + +import "github.com/meower-media/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..6338a99 --- /dev/null +++ b/pkg/api/events/packets/post_reaction_add.go @@ -0,0 +1,13 @@ +package packets + +import "github.com/meower-media/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..a45b92c --- /dev/null +++ b/pkg/api/events/packets/post_reaction_remove.go @@ -0,0 +1,13 @@ +package packets + +import "github.com/meower-media/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..d14d366 --- /dev/null +++ b/pkg/api/events/packets/typing.go @@ -0,0 +1,15 @@ +package packets + +import "github.com/meower-media/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..a22a04c --- /dev/null +++ b/pkg/api/events/packets/update_post.go @@ -0,0 +1,7 @@ +package packets + +import "github.com/meower-media/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..cd359b6 --- /dev/null +++ b/pkg/api/events/packets/update_relationship.go @@ -0,0 +1,12 @@ +package packets + +import "github.com/meower-media/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..116753f --- /dev/null +++ b/pkg/api/events/packets/update_user.go @@ -0,0 +1,7 @@ +package packets + +import "github.com/meower-media/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..429bec4 --- /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/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..538716c --- /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/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/api/rest/server.go b/pkg/api/rest/server.go new file mode 100644 index 0000000..46709b2 --- /dev/null +++ b/pkg/api/rest/server.go @@ -0,0 +1,44 @@ +package rest + +import ( + "net" + "net/http" + "os" + + "github.com/go-chi/chi/v5" + v0_rest "github.com/meower-media/server/pkg/api/rest/v0" + "github.com/rs/cors" +) + +var realIPHeader = os.Getenv("REAL_IP_HEADER") + +func Router() *chi.Mux { + r := chi.NewRouter() + + // CORS middleware + r.Use(cors.New(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"OPTIONS", "GET", "POST", "PATCH", "PUT", "DELETE"}, + AllowedHeaders: []string{"*"}, + AllowCredentials: true, + }).Handler) + + // IP address middleware + r.Use(func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if realIPHeader != "" { + r.RemoteAddr = r.Header.Get(realIPHeader) + } else { + r.RemoteAddr, _, _ = net.SplitHostPort(r.RemoteAddr) + } + h.ServeHTTP(w, r) + }) + }) + + // Mount routers + r.Mount("/", v0_rest.Router()) // default + r.Mount("//", v0_rest.Router()) // Meower Svelte sometimes puts 2 slashes at the start and chi doesn't like that ._. + r.Mount("/v0", v0_rest.Router()) + + return r +} diff --git a/pkg/api/rest/v0/auth.go b/pkg/api/rest/v0/auth.go new file mode 100644 index 0000000..5ba8cb7 --- /dev/null +++ b/pkg/api/rest/v0/auth.go @@ -0,0 +1,316 @@ +package v0_rest + +import ( + "log" + "net/http" + "regexp" + "strings" + + "github.com/getsentry/sentry-go" + "github.com/go-chi/chi/v5" + "github.com/meower-media/server/pkg/networks" + "github.com/meower-media/server/pkg/structs" + "github.com/meower-media/server/pkg/users" +) + +var totpRegex = regexp.MustCompile(`[0-9]{6}$`) + +func AuthRouter() *chi.Mux { + r := chi.NewRouter() + + // IP block check + r.Use(func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + blocked, err := networks.IsBlocked(r.RemoteAddr) + if err != nil { + sentry.CaptureException(err) + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } else if blocked { + returnErr(w, http.StatusForbidden, ErrIPBlocked, nil) + return + } + + h.ServeHTTP(w, r) + }) + }) + + r.Post("/login", login) + r.Post("/register", register) + + return r +} + +func login(w http.ResponseWriter, r *http.Request) { + // Decode body + var body LoginReq + if !decodeBody(w, r, &body) { + return + } + + // IP Ratelimit + if ratelimited("login", "ip", r.RemoteAddr) { + returnErr(w, http.StatusTooManyRequests, ErrRatelimited, nil) + return + } + ratelimit(w, "login", "ip", r.RemoteAddr, 30, 900) + + // Get account and user + var account users.Account + var user users.User + var err error + if strings.Contains(body.Username, "@") { + // Get account + account, err = users.GetAccount(user.Id) + if err == users.ErrAccountNotFound { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, map[string]string{ + "username": "Incorrect username/password.", + "password": "Incorrect username/password.", + }) + return + } else if err != nil { + log.Println(err) + sentry.CaptureException(err) + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Get user + user, err = users.GetUserByUsername(body.Username) + if err != nil { + log.Println(err) + sentry.CaptureException(err) + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + } else { + // Get user + user, err = users.GetUserByUsername(body.Username) + if err == users.ErrUserNotFound { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, map[string]string{ + "username": "Incorrect username/password.", + "password": "Incorrect username/password.", + }) + return + } else if err != nil { + log.Println(err) + sentry.CaptureException(err) + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Get account + account, err = users.GetAccount(user.Id) + if err != nil { + log.Println(err) + sentry.CaptureException(err) + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + } + + // Make sure user isn't deleted + if user.HasFlag(users.FlagDeleted) { + returnErr(w, http.StatusUnauthorized, ErrAccountDeleted, nil) + return + } + + // Make sure account isn't locked + if user.HasFlag(users.FlagLocked) { + returnErr(w, http.StatusUnauthorized, ErrAccountLocked, nil) + return + } + + // Extract the TOTP code if it's at the end of the password + body.TotpCode = totpRegex.FindString(body.Password) + if len(account.Authenticators) > 0 && body.TotpCode != "" && account.CheckTotp(body.TotpCode) { + body.Password = totpRegex.ReplaceAllString(body.Password, "") + } + + // Check token + r.Header.Add("token", body.Password) + authedUser := getAuthedUser(r, nil) + if authedUser != nil && authedUser.Id == user.Id { + // Get sessions + session, err := users.GetAccSessionByToken(body.Password) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Get token + token, err := session.Token() + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + returnData(w, http.StatusOK, AuthResp{ + Account: struct { + structs.V0User + structs.V0UserSettings + }{ + V0User: user.V0(false, true), + //V0UserSettings: settings, + }, + Session: session.V0(), + Token: token, + }) + return + } + + // Check password + if err := account.CheckPassword(body.Password); err != nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, map[string]string{ + "username": "Incorrect username/password.", + "password": "Incorrect username/password.", + }) + return + } + + // Check MFA + if len(account.Authenticators) > 0 { + if body.TotpCode != "" { + if !account.CheckTotp(body.TotpCode) { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, map[string]string{ + "totp_code": "Incorrect TOTP code.", + }) + return + } + } else if body.RecoveryCode != "" { + if body.RecoveryCode == account.RecoveryCode { + + } else { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, map[string]string{ + "mfa_recovery_code": "Incorrect recovery code code.", + }) + return + } + } else { + returnData(w, http.StatusUnauthorized, ErrResp{ + Error: true, + Type: ErrMFARequired.Error(), + MFAMethods: account.MfaMethods(), + }) + return + } + } + + // Create session + session, err := users.CreateAccSession(account.Id, r.RemoteAddr, r.Header.Get("User-Agent")) + if err != nil { + log.Println(err) + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Get session token + token, err := session.Token() + if err != nil { + log.Println(err) + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Get settings + /* + settings := structs.V0DefaultUserSettings + err = user.GetSettings(0, &settings) + if err != nil { + log.Println(err) + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + */ + + returnData(w, http.StatusOK, AuthResp{ + Account: struct { + structs.V0User + structs.V0UserSettings + }{ + V0User: user.V0(false, true), + //V0UserSettings: settings, + }, + Session: session.V0(), + Token: token, + }) +} + +func register(w http.ResponseWriter, r *http.Request) { + // Decode body + var body RegisterReq + if !decodeBody(w, r, &body) { + return + } + + // Check IP ratelimit + if ratelimited("register_fail", "ip", r.RemoteAddr) || ratelimited("register_success", "ip", r.RemoteAddr) { + returnErr(w, http.StatusTooManyRequests, ErrRatelimited, nil) + return + } + + // Check captcha + captchaSuccess, err := checkCaptcha(body.Captcha) + if err != nil { + ratelimit(w, "register_fail", "ip", r.RemoteAddr, 5, 30) + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } else if !captchaSuccess { + ratelimit(w, "register_fail", "ip", r.RemoteAddr, 5, 30) + returnErr(w, http.StatusForbidden, ErrInvalidCaptcha, map[string]string{ + "captcha": "Invalid captcha response.", + }) + return + } + + // Create account + account, user, err := users.CreateAccount(body.Username, body.Password) + if err != nil { + ratelimit(w, "register_fail", "ip", r.RemoteAddr, 5, 30) + if err == users.ErrUsernameTaken { + returnErr(w, http.StatusConflict, ErrUsernameExists, map[string]string{ + "username": "Username already taken.", + }) + } else { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + } + return + } + + // Success ratelimit + ratelimit(w, "register_success", "ip", r.RemoteAddr, 3, 900) + + // Create session + session, err := users.CreateAccSession(account.Id, r.RemoteAddr, r.Header.Get("User-Agent")) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Get session token + token, err := session.Token() + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Get settings + settings := structs.V0DefaultUserSettings + err = user.GetSettings(0, &settings) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + returnData(w, http.StatusOK, AuthResp{ + Account: struct { + structs.V0User + structs.V0UserSettings + }{ + V0User: user.V0(false, true), + V0UserSettings: settings, + }, + Session: session.V0(), + Token: token, + }) +} diff --git a/pkg/api/rest/v0/chat_emotes.go b/pkg/api/rest/v0/chat_emotes.go new file mode 100644 index 0000000..c253cf5 --- /dev/null +++ b/pkg/api/rest/v0/chat_emotes.go @@ -0,0 +1,260 @@ +package v0_rest + +import ( + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/meower-media/server/pkg/chats" + "github.com/meower-media/server/pkg/structs" +) + +func ChatEmotesRouter() *chi.Mux { + r := chi.NewRouter() + + r.Get("/", getChatEmotes) + + r.Get("/{emoteId}", getChatEmote) + r.Put("/{emoteId}", createChatEmote) + r.Patch("/{emoteId}", updateChatEmote) + r.Delete("/{emoteId}", deleteChatEmote) + + return r +} + +func getChatEmotes(w http.ResponseWriter, r *http.Request) { + // Get chat ID + chatId, err := strconv.ParseInt(chi.URLParam(r, "chatId"), 10, 64) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Get authed user + authedUser := getAuthedUser(r, nil) + if authedUser == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + // Get chat + chat, err := chats.GetChat(chatId) + if err != nil { + returnErr(w, http.StatusNotFound, ErrNotFound, nil) + return + } + + // Get emotes + emotes, err := chat.GetEmotes( + map[string]int8{ + "emojis": chats.ChatEmoteTypeEmoji, + "stickers": chats.ChatEmoteTypeSticker, + }[chi.URLParam(r, "emoteType")], + ) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Parse emotes + v0Emotes := []structs.V0ChatEmote{} + for _, emote := range emotes { + v0Emotes = append(v0Emotes, emote.V0()) + } + + returnData(w, http.StatusOK, ListResp{ + Autoget: v0Emotes, + Page: 1, + Pages: 1, + }) +} + +func getChatEmote(w http.ResponseWriter, r *http.Request) { + // Get chat ID + chatId, err := strconv.ParseInt(chi.URLParam(r, "chatId"), 10, 64) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Get authed user + authedUser := getAuthedUser(r, nil) + if authedUser == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + // Get chat + chat, err := chats.GetChat(chatId) + if err != nil { + returnErr(w, http.StatusNotFound, ErrNotFound, nil) + return + } + + // Get emote + emote, err := chat.GetEmote( + map[string]int8{ + "emojis": chats.ChatEmoteTypeEmoji, + "stickers": chats.ChatEmoteTypeSticker, + }[chi.URLParam(r, "emoteType")], + chi.URLParam(r, "emoteId"), + ) + if err != nil { + if err == chats.ErrEmoteNotFound { + returnErr(w, http.StatusNotFound, ErrNotFound, nil) + } else { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + } + return + } + + returnData(w, http.StatusOK, emote.V0()) +} + +func createChatEmote(w http.ResponseWriter, r *http.Request) { + // Get chat ID + chatId, err := strconv.ParseInt(chi.URLParam(r, "chatId"), 10, 64) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Get authed user + authedUser := getAuthedUser(r, nil) + if authedUser == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + // Get chat + chat, err := chats.GetChat(chatId) + if err != nil { + returnErr(w, http.StatusNotFound, ErrNotFound, nil) + return + } + + // Decode body + var body CreateChatEmoteReq + if !decodeBody(w, r, &body) { + return + } + + // Create chat emote + emote, err := chat.CreateEmote( + map[string]int8{ + "emojis": chats.ChatEmoteTypeEmoji, + "stickers": chats.ChatEmoteTypeSticker, + }[chi.URLParam(r, "emoteType")], + chi.URLParam(r, "emoteId"), + body.Name, + ) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + returnData(w, http.StatusOK, emote.V0()) +} + +func updateChatEmote(w http.ResponseWriter, r *http.Request) { + // Get chat ID + chatId, err := strconv.ParseInt(chi.URLParam(r, "chatId"), 10, 64) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Get authed user + authedUser := getAuthedUser(r, nil) + if authedUser == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + // Get chat + chat, err := chats.GetChat(chatId) + if err != nil { + returnErr(w, http.StatusNotFound, ErrNotFound, nil) + return + } + + // Get emote + emote, err := chat.GetEmote( + map[string]int8{ + "emojis": chats.ChatEmoteTypeEmoji, + "stickers": chats.ChatEmoteTypeSticker, + }[chi.URLParam(r, "emoteType")], + chi.URLParam(r, "emoteId"), + ) + if err != nil { + if err == chats.ErrEmoteNotFound { + returnErr(w, http.StatusNotFound, ErrNotFound, nil) + } else { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + } + return + } + + // Decode body + var body CreateChatEmoteReq + if !decodeBody(w, r, &body) { + return + } + + // Update chat emote + err = emote.Update(body.Name) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + returnData(w, http.StatusOK, emote.V0()) +} + +func deleteChatEmote(w http.ResponseWriter, r *http.Request) { + // Get chat ID + chatId, err := strconv.ParseInt(chi.URLParam(r, "chatId"), 10, 64) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Get authed user + authedUser := getAuthedUser(r, nil) + if authedUser == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + // Get chat + chat, err := chats.GetChat(chatId) + if err != nil { + returnErr(w, http.StatusNotFound, ErrNotFound, nil) + return + } + + // Get emote + emote, err := chat.GetEmote( + map[string]int8{ + "emojis": chats.ChatEmoteTypeEmoji, + "stickers": chats.ChatEmoteTypeSticker, + }[chi.URLParam(r, "emoteType")], + chi.URLParam(r, "emoteId"), + ) + if err != nil { + if err == chats.ErrEmoteNotFound { + returnErr(w, http.StatusNotFound, ErrNotFound, nil) + } else { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + } + return + } + + // Delete chat emote + if err := emote.Delete(); err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + returnData(w, http.StatusOK, BaseResp{}) +} diff --git a/pkg/api/rest/v0/chat_members.go b/pkg/api/rest/v0/chat_members.go new file mode 100644 index 0000000..83494b1 --- /dev/null +++ b/pkg/api/rest/v0/chat_members.go @@ -0,0 +1,55 @@ +package v0_rest + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/meower-media/server/pkg/chats" + "github.com/meower-media/server/pkg/users" +) + +func ChatMembersRouter() *chi.Mux { + r := chi.NewRouter() + + r.Get("/", nil) + r.Post("/bulk-remove", nil) + + r.Get("/{username}", nil) + r.Put("/{username}", createMember) + r.Patch("/{username}", nil) + r.Delete("/{username}", nil) + + // deprecated + r.Post("/{username}/transfer", nil) + + return r +} + +func createMember(w http.ResponseWriter, r *http.Request) { + var key CtxKey = "chat" + chat := r.Context().Value(key).(*chats.Chat) + + reqedUser, err := getUserByUrlParam(r, "username") + if err != nil { + if err == users.ErrUserNotFound { + returnErr(w, http.StatusNotFound, ErrNotFound, nil) + } else { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + } + return + } + + _, err = chat.CreateMember( + reqedUser.Id, + false, + false, + 2, + false, + ) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + returnData(w, http.StatusOK, BaseResp{}) +} diff --git a/pkg/api/rest/v0/chat_posts.go b/pkg/api/rest/v0/chat_posts.go new file mode 100644 index 0000000..818aeb0 --- /dev/null +++ b/pkg/api/rest/v0/chat_posts.go @@ -0,0 +1,403 @@ +package v0_rest + +import ( + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/meower-media/server/pkg/chats" + "github.com/meower-media/server/pkg/meowid" + "github.com/meower-media/server/pkg/posts" + "github.com/meower-media/server/pkg/safety" + "github.com/meower-media/server/pkg/structs" +) + +func ChatPostsRouter() *chi.Mux { + r := chi.NewRouter() + + r.Get("/", getChatPosts) + r.Post("/", createChatPost) + r.Get("/pins", getChatPosts) + r.Get("/search", nil) + r.Post("/bulk-delete", nil) + + // The following endpoints should get the chat ID from the database + // using the post ID. As the request may be coming from a path that doesn't + // include the chat ID. + r.Route("/{postId}", func(r chi.Router) { + r.Get("/", getChatPost) + r.Patch("/", updateChatPost) + r.Delete("/", deleteChatPost) + + r.Delete("/attachments/{attachmentId}", removeChatPostAttachment) + + r.Post("/pin", pinChatPost) + r.Delete("/pin", unpinChatPost) + + r.Post("/report", reportChatPost) + + r.Route("/reactions/{emoji}", func(r chi.Router) { + r.Get("/", nil) + r.Post("/", nil) + r.Delete("/{username}", nil) + }) + }) + + return r +} + +func getChatPosts(w http.ResponseWriter, r *http.Request) { + // Get user and chat + var key CtxKey = "user" + //user := r.Context().Value(key).(users.User) + key = "chat" + chat := r.Context().Value(key).(*chats.Chat) + + // Get pagination opts + paginationOpts := PaginationOpts{Request: r} + + // Get posts + posts, err := posts.GetPosts(chat.Id, strings.HasSuffix(r.URL.Path, "/pins"), paginationOpts) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Parse posts + v0posts := []structs.V0Post{} + var requesterUserId *meowid.MeowID + for _, p := range posts { + v0posts = append(v0posts, p.V0(true, requesterUserId)) + } + + // Get pages + pages := paginationOpts.Page() + if len(posts) != 0 { + pages++ + } + + returnData(w, http.StatusOK, ListResp{ + Autoget: v0posts, + Page: paginationOpts.Page(), + Pages: pages, + }) +} + +func createChatPost(w http.ResponseWriter, r *http.Request) { + // Get chat ID + var chatId meowid.MeowID + if r.URL.Path != "/home" && r.URL.Path != "/v0/home" { + var err error + chatId, err = strconv.ParseInt(chi.URLParam(r, "chatId"), 10, 64) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + } + + // Get authed user + authedUser := getAuthedUser(r, nil) + if authedUser == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + // Decode body + var body CreatePostReq + if !decodeBody(w, r, &body) { + return + } + + // Parse reply to post IDs + replyToPostIds := []meowid.MeowID{} + for _, strPostId := range body.ReplyToPostIds { + postId, err := strconv.ParseInt(strPostId, 10, 64) + if err != nil { + return + } + replyToPostIds = append(replyToPostIds, postId) + } + + // Create post + post, err := posts.CreatePost( + chatId, + authedUser.Id, + replyToPostIds, + body.Content, + body.StickerIds, + body.AttachmentIds, + body.Nonce, + ) + if err != nil { + return + } + + returnData(w, http.StatusOK, post.V0(true, &authedUser.Id)) +} + +func reportChatPost(w http.ResponseWriter, r *http.Request) { + // Decode body + var body CreateReportReq + if !decodeBody(w, r, &body) { + return + } + if body.Reason == "" { + body.Reason = "No reason provided" + } + + // Get authed user + authedUser := getAuthedUser(r, nil) + if authedUser == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + // Get post ID + postId, err := strconv.ParseInt(chi.URLParam(r, "postId"), 10, 64) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Create report + report, err := safety.CreateReport("post", postId, authedUser.Id, body.Reason, body.Comment) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + returnData(w, http.StatusOK, report.V0()) +} + +func getChatPost(w http.ResponseWriter, r *http.Request) { + // Get authed user + authedUser := getAuthedUser(r, nil) + if authedUser == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + // Get post ID + postId, err := strconv.ParseInt(chi.URLParam(r, "postId"), 10, 64) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Get post + post, err := posts.GetPost(postId) + if err != nil { + if err == posts.ErrPostNotFound { + returnErr(w, http.StatusNotFound, ErrInternal, nil) + } else { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + } + return + } + + returnData(w, http.StatusOK, post.V0(true, &authedUser.Id)) +} + +func updateChatPost(w http.ResponseWriter, r *http.Request) { + // Get authed user + authedUser := getAuthedUser(r, nil) + if authedUser == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + // Get post ID + postId, err := strconv.ParseInt(chi.URLParam(r, "postId"), 10, 64) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Get post + post, err := posts.GetPost(postId) + if err != nil { + if err == posts.ErrPostNotFound { + returnErr(w, http.StatusNotFound, ErrInternal, nil) + } else { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + } + return + } + + // Validate ownership + if post.AuthorId != authedUser.Id { + returnErr(w, http.StatusForbidden, ErrMissingPermissions, nil) + return + } + + // Decode body + var body CreatePostReq + if !decodeBody(w, r, &body) { + return + } + + // Update post + if err := post.UpdateContent(body.Content); err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + returnData(w, http.StatusOK, post.V0(true, &authedUser.Id)) +} + +func deleteChatPost(w http.ResponseWriter, r *http.Request) { + // Get authed user + authedUser := getAuthedUser(r, nil) + if authedUser == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + // Get post ID + postId, err := strconv.ParseInt(chi.URLParam(r, "postId"), 10, 64) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Get post + post, err := posts.GetPost(postId) + if err != nil { + if err == posts.ErrPostNotFound { + returnErr(w, http.StatusNotFound, ErrInternal, nil) + } else { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + } + return + } + + // Validate ownership + if post.AuthorId != authedUser.Id { + returnErr(w, http.StatusForbidden, ErrMissingPermissions, nil) + return + } + + // Delete post + if err := post.Delete(true); err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + returnData(w, http.StatusOK, BaseResp{}) +} + +func removeChatPostAttachment(w http.ResponseWriter, r *http.Request) { + // Get authed user + authedUser := getAuthedUser(r, nil) + if authedUser == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + // Get post ID + postId, err := strconv.ParseInt(chi.URLParam(r, "postId"), 10, 64) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Get post + post, err := posts.GetPost(postId) + if err != nil { + if err == posts.ErrPostNotFound { + returnErr(w, http.StatusNotFound, ErrInternal, nil) + } else { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + } + return + } + + // Validate ownership + if post.AuthorId != authedUser.Id { + returnErr(w, http.StatusForbidden, ErrMissingPermissions, nil) + return + } + + // Remove attachment + if err := post.RemoveAttachment(chi.URLParam(r, "attachmentId")); err != nil { + if err == posts.ErrAttachmentNotFound { + returnErr(w, http.StatusNotFound, ErrNotFound, nil) + } else { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + } + return + } + + returnData(w, http.StatusOK, post.V0(true, &authedUser.Id)) +} + +func pinChatPost(w http.ResponseWriter, r *http.Request) { + // Get authed user + authedUser := getAuthedUser(r, nil) + if authedUser == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + // Get post ID + postId, err := strconv.ParseInt(chi.URLParam(r, "postId"), 10, 64) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Get post + post, err := posts.GetPost(postId) + if err != nil { + if err == posts.ErrPostNotFound { + returnErr(w, http.StatusNotFound, ErrInternal, nil) + } else { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + } + return + } + + // Pin post + if err := post.SetPinnedState(true); err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + returnData(w, http.StatusOK, BaseResp{}) +} + +func unpinChatPost(w http.ResponseWriter, r *http.Request) { + // Get authed user + authedUser := getAuthedUser(r, nil) + if authedUser == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + // Get post ID + postId, err := strconv.ParseInt(chi.URLParam(r, "postId"), 10, 64) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Get post + post, err := posts.GetPost(postId) + if err != nil { + if err == posts.ErrPostNotFound { + returnErr(w, http.StatusNotFound, ErrInternal, nil) + } else { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + } + return + } + + // Unpin post + if err := post.SetPinnedState(false); err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + returnData(w, http.StatusOK, BaseResp{}) +} diff --git a/pkg/api/rest/v0/chats.go b/pkg/api/rest/v0/chats.go new file mode 100644 index 0000000..654eec6 --- /dev/null +++ b/pkg/api/rest/v0/chats.go @@ -0,0 +1,186 @@ +package v0_rest + +import ( + "context" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/meower-media/server/pkg/chats" + "github.com/meower-media/server/pkg/structs" + "github.com/meower-media/server/pkg/users" +) + +func ChatsRouter() *chi.Mux { + r := chi.NewRouter() + + r.Get("/", getChats) + r.Post("/", createGroupChat) + + r.Route("/{chatId}", func(r chi.Router) { + r.Use(func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Get authed user + user := getAuthedUser(r, nil) + if user == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + var key CtxKey = "user" + ctx = context.WithValue(ctx, key, &user) + + // Get chat ID + chatId, err := strconv.ParseInt(chi.URLParam(r, "chatId"), 10, 64) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Get chat + chat, err := chats.GetChat(chatId) + if err != nil { + returnErr(w, http.StatusNotFound, ErrNotFound, nil) + return + } + key = "chat" + ctx = context.WithValue(ctx, key, &chat) + + // Get member + member, err := chat.GetMember(user.Id) + if err != nil { + returnErr(w, http.StatusNotFound, ErrNotFound, nil) + return + } + key = "member" + ctx = context.WithValue(ctx, key, &member) + + h.ServeHTTP(w, r.WithContext(ctx)) + }) + }) + + r.Get("/", getChat) + r.Patch("/", nil) + r.Delete("/", leaveChat) + r.Post("/typing", emitTyping) + r.Post("/delete", deleteChat) + r.Mount("/members", ChatMembersRouter()) + r.Mount("/{emoteType:emojis|stickers}", ChatEmotesRouter()) + r.Mount("/posts", ChatPostsRouter()) + + r.Get("/pins", getChatPosts) // deprecated + }) + + return r +} + +func getChats(w http.ResponseWriter, r *http.Request) { + user := getAuthedUser(r, nil) + if user == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + chats, err := chats.GetActiveChats(user.Id) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Parse chats + v0chats := []structs.V0Chat{} + for _, c := range chats { + v0chats = append(v0chats, c.V0(*user)) + } + + returnData(w, http.StatusOK, ListResp{ + Autoget: v0chats, + Page: 1, + Pages: 1, + }) +} + +func createGroupChat(w http.ResponseWriter, r *http.Request) { + // Get authed user + user := getAuthedUser(r, nil) + if user == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + // Decode body + var body CreateGroupChatReq + if !decodeBody(w, r, &body) { + return + } + + // Create group chat + chat, err := chats.CreateGroupChat(body.Nickname, "", "") + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Create chat membership + _, err = chat.CreateMember(user.Id, false, true, 2, true) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + returnData(w, http.StatusOK, chat.V0(*user)) +} + +func getChat(w http.ResponseWriter, r *http.Request) { + var key CtxKey = "user" + user := r.Context().Value(key).(*users.User) + + key = "chat" + chat := r.Context().Value(key).(*chats.Chat) + + returnData(w, http.StatusOK, chat.V0(*user)) +} + +func leaveChat(w http.ResponseWriter, r *http.Request) { + var key CtxKey = "member" + member := r.Context().Value(key).(*chats.Member) + + var err error + if member.DM { + err = member.SetActiveStatus(false) + } else { + err = member.Delete() + } + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + returnData(w, http.StatusOK, BaseResp{}) +} + +func emitTyping(w http.ResponseWriter, r *http.Request) { + var key CtxKey = "member" + if err := r.Context().Value(key).(*chats.Member).EmitTyping(); err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + returnData(w, http.StatusOK, BaseResp{}) +} + +func deleteChat(w http.ResponseWriter, r *http.Request) { + var key CtxKey = "member" + if !r.Context().Value(key).(*chats.Member).Admin { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + key = "chat" + if err := r.Context().Value(key).(*chats.Chat).Delete(); err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + returnData(w, http.StatusOK, BaseResp{}) +} diff --git a/pkg/api/rest/v0/errors.go b/pkg/api/rest/v0/errors.go new file mode 100644 index 0000000..73bf9fe --- /dev/null +++ b/pkg/api/rest/v0/errors.go @@ -0,0 +1,19 @@ +package v0_rest + +import "errors" + +var ( + ErrBadRequest = errors.New("badRequest") // 400 + ErrUnauthorized = errors.New("Unauthorized") // 401 + ErrAccountDeleted = errors.New("accountDeleted") // 401 + ErrAccountLocked = errors.New("accountLocked") // 401 + ErrInvalidTOTPCode = errors.New("invalidTOTPCode") // 401 + ErrMFARequired = errors.New("mfaRequired") // 403 + ErrIPBlocked = errors.New("ipBlocked") // 403 + ErrInvalidCaptcha = errors.New("invalidCaptcha") // 403 + ErrMissingPermissions = errors.New("missingPermissions") // 403 + ErrNotFound = errors.New("notFound") // 404 + ErrUsernameExists = errors.New("usernameExists") // 409 + ErrRatelimited = errors.New("tooManyRequests") // 429 + ErrInternal = errors.New("Internal") // 500 +) diff --git a/pkg/api/rest/v0/me.go b/pkg/api/rest/v0/me.go new file mode 100644 index 0000000..8f4ce7f --- /dev/null +++ b/pkg/api/rest/v0/me.go @@ -0,0 +1,383 @@ +package v0_rest + +import ( + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/meower-media/server/pkg/structs" + "github.com/meower-media/server/pkg/users" + "github.com/meower-media/server/pkg/utils" + "github.com/pquerna/otp/totp" +) + +func MeRouter() *chi.Mux { + r := chi.NewRouter() + + r.Get("/", getMe) + r.Patch("/config", updateConfig) + r.Get("/relationships", getRelationships) + r.Patch("/email", nil) + r.Delete("/email", nil) + r.Patch("/password", changePassword) + r.Route("/authenticators", func(r chi.Router) { + r.Get("/", getAuthenticators) + r.Post("/", addAuthenticator) + r.Patch("/{authenticatorId}", updateAuthenticator) + r.Delete("/{authenticatorId}", removeAuthenticator) + r.Get("/totp-secret", getNewTotpSecret) + }) + r.Post("/reset-mfa-recovery-code", resetRecoveryCode) + r.Delete("/tokens", nil) + r.Route("/data", func(r chi.Router) { + r.Get("/areas", nil) + r.Get("/requests", nil) + r.Post("/requests", nil) + }) + + return r +} + +func getMe(w http.ResponseWriter, r *http.Request) { + user := getAuthedUser(r, nil) + if user == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + settings := structs.V0DefaultUserSettings + if err := user.GetSettings(0, &settings); err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + returnData(w, http.StatusOK, MeResp{ + V0User: user.V0(false, true), + V0UserSettings: settings, + }) +} + +func updateConfig(w http.ResponseWriter, r *http.Request) { + // Decode body + var body UpdateConfigReq + if !decodeBody(w, r, &body) { + return + } + + // Get authed user + user := getAuthedUser(r, nil) + if user == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + // Update user settings + /* + err := user.UpdateSettings(&body.UserSettingsV0) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + */ + + returnData(w, http.StatusOK, BaseResp{}) +} + +func getRelationships(w http.ResponseWriter, r *http.Request) { + // Get authed user + user := getAuthedUser(r, nil) + if user == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + // Get relationships + relationships, err := user.GetAllRelationships() + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Parse relationships + v0relationships := []structs.V0Relationship{} + for _, relationship := range relationships { + v0r, err := relationship.V0() + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + v0relationships = append(v0relationships, v0r) + } + + returnData(w, http.StatusOK, ListResp{ + Autoget: v0relationships, + Page: 1, + Pages: 1, + }) +} + +func changePassword(w http.ResponseWriter, r *http.Request) { + // Get authed user + user := getAuthedUser(r, nil) + if user == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + // Decode body + var body ChangePasswordReq + if !decodeBody(w, r, &body) { + return + } + + // Get account + account, err := users.GetAccount(user.Id) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Check old password + if err := account.CheckPassword(body.OldPassword); err != nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, map[string]string{ + "old": "Incorrect password.", + }) + return + } + + // Change password + if err := account.ChangePassword(body.NewPassword); err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + returnData(w, http.StatusOK, BaseResp{}) +} + +func getAuthenticators(w http.ResponseWriter, r *http.Request) { + // Get authed user + user := getAuthedUser(r, nil) + if user == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + // Get account + account, err := users.GetAccount(user.Id) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Parse authenticators + v0Authenticators := []*structs.V0Authenticator{} + for _, authenticator := range account.Authenticators { + v0Authenticators = append(v0Authenticators, authenticator.V0()) + } + + returnData(w, http.StatusOK, ListResp{ + Autoget: v0Authenticators, + Page: 1, + Pages: 1, + }) +} + +func addAuthenticator(w http.ResponseWriter, r *http.Request) { + // Get authed user + user := getAuthedUser(r, nil) + if user == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + // Get account + account, err := users.GetAccount(user.Id) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Decode body + var body AddAuthenticatorReq + if !decodeBody(w, r, &body) { + return + } + + // Check TOTP code + tempAuthenticator := users.Authenticator{TotpSecret: body.TotpSecret} + if !tempAuthenticator.CheckTotp(body.TotpCode) { + returnErr(w, http.StatusUnauthorized, ErrInvalidTOTPCode, map[string]string{ + "totp_code": "Invalid TOTP code.", + }) + return + } + + // Check password + if err := account.CheckPassword(body.Password); err != nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, map[string]string{ + "password": "Incorrect password.", + }) + return + } + + // Add authenticator + var authenticator *users.Authenticator + if body.Type == "totp" { + authenticator, err = account.AddTotpAuthenticator(body.Nickname, body.TotpSecret) + } + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + returnData(w, http.StatusOK, NewMfaResp{ + Authenticator: authenticator.V0(), + RecoveryCode: &account.RecoveryCode, + }) +} + +func updateAuthenticator(w http.ResponseWriter, r *http.Request) { + // Get authed user + user := getAuthedUser(r, nil) + if user == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + // Get account + account, err := users.GetAccount(user.Id) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Decode body + var body UpdateAuthenticatorReq + if !decodeBody(w, r, &body) { + return + } + + // Update authenticator + authenticatorId, _ := strconv.ParseInt(chi.URLParam(r, "authenticatorId"), 10, 64) + authenticator, err := account.ChangeAuthenticatorNickname(authenticatorId, body.Nickname) + if err != nil { + if err == users.ErrAuthenticatorNotFound { + returnErr(w, http.StatusNotFound, ErrNotFound, nil) + } else { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + } + return + } + + returnData(w, http.StatusOK, UpdatedAuthenticatorResp{ + V0Authenticator: authenticator.V0(), + }) +} + +func removeAuthenticator(w http.ResponseWriter, r *http.Request) { + // Get authed user + user := getAuthedUser(r, nil) + if user == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + // Get account + account, err := users.GetAccount(user.Id) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Decode body + var body AccountVerificationReq + if !decodeBody(w, r, &body) { + return + } + + // Check password + if err := account.CheckPassword(body.Password); err != nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, map[string]string{ + "password": "Incorrect password.", + }) + return + } + + // Remove authenticator + authenticatorId, _ := strconv.ParseInt(chi.URLParam(r, "authenticatorId"), 10, 64) + if err := account.RemoveAuthenticator(authenticatorId); err != nil { + if err == users.ErrAuthenticatorNotFound { + returnErr(w, http.StatusNotFound, ErrNotFound, nil) + } else { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + } + return + } + + returnData(w, http.StatusOK, BaseResp{}) +} + +func getNewTotpSecret(w http.ResponseWriter, r *http.Request) { + // Get authed user + user := getAuthedUser(r, nil) + if user == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + // Generate new TOTP secret + totpSecret, err := totp.Generate(totp.GenerateOpts{ + Issuer: "Meower", + AccountName: user.Username, + }) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + returnData(w, http.StatusOK, NewTotpSecretResp{ + Secret: totpSecret.Secret(), + ProvisioningUri: totpSecret.URL(), + QRCodeSVG: utils.GenerateSVGQRCode(totpSecret.URL()), + }) +} + +func resetRecoveryCode(w http.ResponseWriter, r *http.Request) { + // Get authed user + user := getAuthedUser(r, nil) + if user == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + // Decode body + var body AccountVerificationReq + if !decodeBody(w, r, &body) { + return + } + + // Get account + account, err := users.GetAccount(user.Id) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + // Check password + if err := account.CheckPassword(body.Password); err != nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, map[string]string{ + "password": "Incorrect password.", + }) + return + } + + // Reset recovery code + if err := account.ResetRecoveryCode(); err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + returnData(w, http.StatusOK, NewMfaResp{ + RecoveryCode: &account.RecoveryCode, + }) +} diff --git a/pkg/api/rest/v0/pagination.go b/pkg/api/rest/v0/pagination.go new file mode 100644 index 0000000..23cde2f --- /dev/null +++ b/pkg/api/rest/v0/pagination.go @@ -0,0 +1,65 @@ +package v0_rest + +import ( + "net/http" + "strconv" + + "github.com/meower-media/server/pkg/meowid" +) + +const defaultPaginationLimit = 25 + +type PaginationOpts struct { + Request *http.Request +} + +func (p PaginationOpts) BeforeId() *meowid.MeowID { + beforeId, err := strconv.ParseInt(p.Request.URL.Query().Get("before"), 10, 64) + if err == nil { + return &beforeId + } + + return nil +} + +func (p PaginationOpts) AfterId() *meowid.MeowID { + afterId, err := strconv.ParseInt(p.Request.URL.Query().Get("after"), 10, 64) + if err == nil { + return &afterId + } + + return nil +} + +func (p PaginationOpts) Skip() int64 { + page, err := strconv.ParseInt(p.Request.URL.Query().Get("page"), 10, 64) + if err == nil { + skip := (page - 1) * 25 + return skip + } + + return 0 +} + +func (p PaginationOpts) Limit() int64 { + limit, err := strconv.ParseInt(p.Request.URL.Query().Get("limit"), 10, 64) + if err == nil { + // limit the limit to 100 + if limit > 100 { + return 100 + } + + return limit + } + + return defaultPaginationLimit +} + +func (p PaginationOpts) Page() int64 { + page, err := strconv.ParseInt(p.Request.URL.Query().Get("page"), 10, 64) + if err == nil { + return page + } + + return 1 +} diff --git a/pkg/api/rest/v0/req_structs.go b/pkg/api/rest/v0/req_structs.go new file mode 100644 index 0000000..18197d0 --- /dev/null +++ b/pkg/api/rest/v0/req_structs.go @@ -0,0 +1,88 @@ +package v0_rest + +import ( + "github.com/meower-media/server/pkg/structs" +) + +type LoginReq struct { + Username string `json:"username"` + Password string `json:"password"` + TotpCode string `json:"totp_code"` + RecoveryCode string `json:"mfa_recovery_code"` +} + +type RegisterReq struct { + Username string `json:"username"` + Password string `json:"password"` + Captcha string `json:"captcha" validate:"max=5000"` +} + +type RecoverReq struct { + Email string `json:"email"` +} + +type UpdateConfigReq struct { + IconId *string `json:"avatar" validate:""` + LegacyIcon *int8 `json:"pfp_data" validate:""` + Color *string `json:"avatar_color" validate:""` + Quote *string `json:"quote" validate:""` + + structs.V0UserSettings +} + +type AccountVerificationReq struct { + Password string `json:"password" validate:"required"` +} + +type ChangePasswordReq struct { + OldPassword string `json:"old" validate:"required"` + NewPassword string `json:"new" validate:"required"` +} + +type ChangeEmailBody struct { + AccountVerificationReq + + NewEmail string `json:"email" validate:"required,min=1,max=255"` + Captcha string `json:"captcha" validate:"max=5000"` +} + +type AddAuthenticatorReq struct { + AccountVerificationReq + + Type string `json:"type" validate:"required"` + Nickname string `json:"nickname" validate:"max=32"` + + TotpSecret string `json:"totp_secret" validate:"max=64"` + TotpCode string `json:"totp_code" validate:"min=6,max=6"` +} + +type UpdateAuthenticatorReq struct { + Nickname string `json:"nickname" validate:"max=32"` +} + +type UpdateRelationshipReq struct { + State *int8 `json:"state" validate:"required"` +} + +type CreateGroupChatReq struct { + Nickname string `json:"nickname" validate:"required,min=1,max=32"` +} + +type CreateChatEmoteReq struct { + Name string `json:"name" validate:"required,min=1,max=32"` +} + +type CreatePostReq struct { + Content string `json:"content" validate:"max=4000"` + StickerIds []string `json:"stickers"` + AttachmentIds []string `json:"attachments"` + + ReplyToPostIds []string `json:"reply_to"` + + Nonce string `json:"nonce" validate:"max=64"` +} + +type CreateReportReq struct { + Reason string `json:"reason" validate:"max=2000"` + Comment string `json:"comment" validate:"max=2000"` +} diff --git a/pkg/api/rest/v0/resp_structs.go b/pkg/api/rest/v0/resp_structs.go new file mode 100644 index 0000000..ea09a33 --- /dev/null +++ b/pkg/api/rest/v0/resp_structs.go @@ -0,0 +1,84 @@ +package v0_rest + +import ( + structs "github.com/meower-media/server/pkg/structs" +) + +type BaseResp struct { + Error bool `json:"error"` +} + +type ErrResp struct { + Error bool `json:"error"` + Type string `json:"type"` + Fields map[string]string `json:"fields,omitempty"` + + // very special field only for logging in + MFAMethods []string `json:"mfa_methods,omitempty"` +} + +type ListResp struct { + Error bool `json:"error"` + Autoget interface{} `json:"autoget"` + Page int64 `json:"page#"` + Pages int64 `json:"pages"` +} + +type WelcomeResp struct { + Error bool `json:"error"` + Captcha CaptchaResp `json:"captcha"` +} + +type CaptchaResp struct { + Enabled bool `json:"enabled"` + Sitekey string `json:"sitekey"` +} + +type StatusResp struct { + RegistrationEnabled bool `json:"registrationEnabled"` + RepairMode bool `json:"isRepairMode"` + IPBlocked bool `json:"ipBlocked"` + + IPRegBlocked bool `json:"ipRegistrationBlocked"` // deprecated (should always be false) + ScratchDeprecated bool `json:"scratchDeprecated"` // should always be true +} + +type StatisticsResp struct { + UserCount int64 `json:"users"` + ChatCount int64 `json:"chats"` + PostCount int64 `json:"posts"` +} + +type AuthResp struct { + Error bool `json:"error"` + Account struct { + structs.V0User + structs.V0UserSettings + } `json:"account"` + Session structs.V0Session `json:"session"` + Token string `json:"token"` +} + +type MeResp struct { + Error bool `json:"error"` + structs.V0User + structs.V0UserSettings +} + +type NewTotpSecretResp struct { + Error bool `json:"error"` + Secret string `json:"secret"` + ProvisioningUri string `json:"provisioning_uri"` + QRCodeSVG string `json:"qr_code_svg"` +} + +type NewMfaResp struct { + Error bool `json:"error"` + Authenticator *structs.V0Authenticator `json:"authenticator,omitempty"` + RecoveryCode *string `json:"mfa_recovery_code,omitempty"` +} + +type UpdatedAuthenticatorResp struct { + Error bool `json:"error"` + *structs.V0Authenticator +} diff --git a/pkg/api/rest/v0/root.go b/pkg/api/rest/v0/root.go new file mode 100644 index 0000000..5fe92a2 --- /dev/null +++ b/pkg/api/rest/v0/root.go @@ -0,0 +1,79 @@ +package v0_rest + +import ( + "context" + "net/http" + "os" + + "github.com/go-chi/chi/v5" + "github.com/meower-media/server/pkg/db" + "github.com/meower-media/server/pkg/networks" + "github.com/meower-media/server/pkg/rdb" +) + +func RootRouter() *chi.Mux { + r := chi.NewRouter() + + r.Get("/", root) + r.Get("/status", getStatus) + r.Get("/statistics", getStatistics) + r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {}) // Favicon, my ass. We need no favicon for an API. + + return r +} + +func root(w http.ResponseWriter, r *http.Request) { + returnData(w, http.StatusOK, WelcomeResp{ + Error: false, + Captcha: CaptchaResp{ + Enabled: os.Getenv("CAPTCHA_SECRET") != "", + Sitekey: os.Getenv("CAPTCHA_SITEKEY"), + }, + }) +} + +func getStatus(w http.ResponseWriter, r *http.Request) { + regsDisabled, err := rdb.Client.Exists(context.TODO(), "regsdisabled").Result() + if err != nil { + return + } + + repairMode, err := rdb.Client.Exists(context.TODO(), "repairmode").Result() + if err != nil { + return + } + + blocked, err := networks.IsBlocked(r.RemoteAddr) + if err != nil { + return + } + + returnData(w, http.StatusOK, StatusResp{ + RegistrationEnabled: regsDisabled == 1, + RepairMode: repairMode == 1, + IPBlocked: blocked, + }) +} + +func getStatistics(w http.ResponseWriter, _ *http.Request) { + userCount, err := db.Users.EstimatedDocumentCount(context.TODO()) + if err != nil { + return + } + + chatCount, err := db.Chats.EstimatedDocumentCount(context.TODO()) + if err != nil { + return + } + + postCount, err := db.Posts.EstimatedDocumentCount(context.TODO()) + if err != nil { + return + } + + returnData(w, http.StatusOK, StatisticsResp{ + UserCount: userCount, + ChatCount: chatCount, + PostCount: postCount, + }) +} diff --git a/pkg/api/rest/v0/router.go b/pkg/api/rest/v0/router.go new file mode 100644 index 0000000..6923be0 --- /dev/null +++ b/pkg/api/rest/v0/router.go @@ -0,0 +1,47 @@ +package v0_rest + +import ( + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" +) + +func Router() *chi.Mux { + r := chi.NewRouter() + + r.Mount("/", RootRouter()) + r.Mount("/auth", AuthRouter()) + r.Mount("/me", MeRouter()) + r.Mount("/chats", ChatsRouter()) + r.Mount("/users/{username}", UsersRouter()) + + // old endpoints + r.Get("/home", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, fmt.Sprint("/chats/0/posts", "?", r.URL.RawQuery), http.StatusPermanentRedirect) + }) + r.Post("/home/typing", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/chats/0/typing", http.StatusPermanentRedirect) + }) + + /*r.Route("/posts", func(r chi.Router) { + r.Get("/", nil) + r.Patch("/", nil) + r.Delete("/", nil) + r.Route("/{chatId}", func(r chi.Router) { + r.Get("/", getChatPosts) + r.Post("/", createChatPost) + }) + r.Route("/{postId}", func(r chi.Router) { + r.Delete("/attachments/{attachmentId}", nil) + r.Post("/pin", nil) + r.Delete("/pin", nil) + r.Post("/report", reportChatPost) + r.Get("/reactions/{emoji}", nil) + r.Post("/reactions/{emoji}", nil) + r.Delete("/reactions/{emoji}/{username}", nil) + }) + })*/ + + return r +} diff --git a/pkg/api/rest/v0/users.go b/pkg/api/rest/v0/users.go new file mode 100644 index 0000000..9525f22 --- /dev/null +++ b/pkg/api/rest/v0/users.go @@ -0,0 +1,211 @@ +package v0_rest + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/meower-media/server/pkg/chats" + "github.com/meower-media/server/pkg/meowid" + "github.com/meower-media/server/pkg/posts" + "github.com/meower-media/server/pkg/safety" + "github.com/meower-media/server/pkg/structs" + "github.com/meower-media/server/pkg/users" +) + +func UsersRouter() *chi.Mux { + r := chi.NewRouter() + + r.Get("/", getUser) + r.Get("/posts", getUserPosts) + r.Get("/relationship", getRelationship) + r.Patch("/relationship", updateRelationship) + r.Get("/dm", getDMChat) + r.Post("/report", reportUser) + + return r +} + +func getUser(w http.ResponseWriter, r *http.Request) { + reqedUser, err := getUserByUrlParam(r, "username") + if err != nil { + if err == users.ErrUserNotFound { + returnErr(w, http.StatusNotFound, ErrNotFound, nil) + } else { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + } + return + } + + returnData(w, http.StatusOK, reqedUser.V0(false, false)) +} + +func getUserPosts(w http.ResponseWriter, r *http.Request) { + reqedUser, err := getUserByUrlParam(r, "username") + if err != nil { + if err == users.ErrUserNotFound { + returnErr(w, http.StatusNotFound, ErrNotFound, nil) + } else { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + } + return + } + + var requesterId meowid.MeowID + authedUser := getAuthedUser(r, nil) + if authedUser != nil { + requesterId = authedUser.Id + } + + // Get pagination opts + paginationOpts := PaginationOpts{Request: r} + + // Get posts + posts, err := posts.GetPosts(reqedUser.Id, false, paginationOpts) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + v0posts := []structs.V0Post{} + for _, p := range posts { + v0posts = append(v0posts, p.V0(true, &requesterId)) + } + + returnData(w, http.StatusOK, ListResp{ + Autoget: v0posts, + }) +} + +func getRelationship(w http.ResponseWriter, r *http.Request) { + reqedUser, err := getUserByUrlParam(r, "username") + if err != nil { + if err == users.ErrUserNotFound { + returnErr(w, http.StatusNotFound, ErrNotFound, nil) + } else { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + } + return + } + + authedUser := getAuthedUser(r, nil) + if authedUser == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + relationship, err := authedUser.GetRelationship(reqedUser.Id) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + relationshipV0, err := relationship.V0() + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + returnData(w, http.StatusOK, relationshipV0) +} + +func updateRelationship(w http.ResponseWriter, r *http.Request) { + var body UpdateRelationshipReq + if !decodeBody(w, r, &body) { + return + } + + reqedUser, err := getUserByUrlParam(r, "username") + if err != nil { + if err == users.ErrUserNotFound { + returnErr(w, http.StatusNotFound, ErrNotFound, nil) + } else { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + } + return + } + + authedUser := getAuthedUser(r, nil) + if authedUser == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + relationship, err := authedUser.GetRelationship(reqedUser.Id) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + if err := relationship.Update(*body.State); err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + relationshipV0, err := relationship.V0() + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + returnData(w, http.StatusOK, relationshipV0) +} + +func getDMChat(w http.ResponseWriter, r *http.Request) { + reqedUser, err := getUserByUrlParam(r, "username") + if err != nil { + if err == users.ErrUserNotFound { + returnErr(w, http.StatusNotFound, ErrNotFound, nil) + } else { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + } + return + } + + authedUser := getAuthedUser(r, nil) + if authedUser == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + chat, err := chats.GetDM(authedUser.Id, reqedUser.Id) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + returnData(w, http.StatusOK, chat.V0(*authedUser)) +} + +func reportUser(w http.ResponseWriter, r *http.Request) { + var body CreateReportReq + if !decodeBody(w, r, &body) { + return + } + if body.Reason == "" { + body.Reason = "No reason provided" + } + + reqedUser, err := getUserByUrlParam(r, "username") + if err != nil { + if err == users.ErrUserNotFound { + returnErr(w, http.StatusNotFound, ErrNotFound, nil) + } else { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + } + return + } + + authedUser := getAuthedUser(r, nil) + if authedUser == nil { + returnErr(w, http.StatusUnauthorized, ErrUnauthorized, nil) + return + } + + report, err := safety.CreateReport("user", reqedUser.Id, authedUser.Id, body.Reason, body.Comment) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + return + } + + returnData(w, http.StatusOK, report.V0()) +} diff --git a/pkg/api/rest/v0/utils.go b/pkg/api/rest/v0/utils.go new file mode 100644 index 0000000..39c1864 --- /dev/null +++ b/pkg/api/rest/v0/utils.go @@ -0,0 +1,217 @@ +package v0_rest + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "log" + "net/http" + "os" + "reflect" + "strconv" + "strings" + "time" + + "github.com/getsentry/sentry-go" + "github.com/go-chi/chi/v5" + "github.com/go-playground/validator/v10" + "github.com/meower-media/server/pkg/rdb" + "github.com/meower-media/server/pkg/users" + "github.com/redis/go-redis/v9" + "golang.org/x/crypto/sha3" +) + +const CaptchaVerifyUrl = "https://api.hcaptcha.com/siteverify" + +var validate = validator.New() + +type CtxKey string + +type AuthOpts struct { + CheckRestriction bool + SkipBanCheck bool +} + +func decodeBody(w http.ResponseWriter, r *http.Request, v interface{}) bool { + // Decode body + contentType := r.Header.Get("Content-Type") + if contentType == "application/json" || contentType == "" { // default + err := json.NewDecoder(r.Body).Decode(v) + if err != nil { + returnErr(w, http.StatusBadRequest, ErrBadRequest, nil) + return false + } + } else { + returnErr(w, http.StatusBadRequest, ErrBadRequest, nil) + return false + } + + // Get struct type + structType := reflect.TypeOf(v) + if structType.Kind() == reflect.Ptr { + structType = structType.Elem() + } + + // Validate + err := validate.Struct(v) + if err != nil { + errFields := make(map[string]string, len(err.(validator.ValidationErrors))) + for _, err := range err.(validator.ValidationErrors) { + field, _ := structType.FieldByName(err.StructField()) + errFields[field.Tag.Get("json")] = err.Error() + } + returnErr(w, http.StatusBadRequest, ErrBadRequest, errFields) + return false + } + + return true +} + +func returnData(w http.ResponseWriter, code int, data interface{}) { + marshaled, err := json.Marshal(data) + if err != nil { + returnErr(w, http.StatusInternalServerError, ErrInternal, nil) + } else { + w.WriteHeader(code) + w.Write(marshaled) + } +} + +func returnErr(w http.ResponseWriter, code int, errType error, fields map[string]string) { + marshaled, err := json.Marshal(ErrResp{ + Error: true, + Type: errType.Error(), + Fields: fields, + }) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("An error occurred while sending the error response.")) + } else { + w.WriteHeader(code) + w.Write(marshaled) + } +} + +// Update a ratelimit for a resource (bucket) based on a scope and identifier. +// +// Only 1 ratelimit should be set before returning a response. +// Otherwise, the ratelimit headers might accidentally be overwritten. +// +// The bucket should be the action, such as 'login'. +// The scope should be one of the following: ip, user. +// The identifier should be the IP address or user ID. +func ratelimit(w http.ResponseWriter, bucket string, scope string, id string, limit int, seconds int) error { + // Get ratelimit hash + ratelimitHash := getRatelimitHash(bucket, scope, id) + + // Get remaining limit and TTL + var newRemaining int + var newTTL time.Duration + remaining, err := rdb.Client.Get(context.TODO(), ratelimitHash).Int() + if err == redis.Nil { + newRemaining = limit - 1 + newTTL = time.Duration(seconds) * time.Second + } else { + newRemaining = remaining - 1 + newTTL = rdb.Client.TTL(context.TODO(), ratelimitHash).Val() + } + + // Set new limit + if err := rdb.Client.Set(context.TODO(), ratelimitHash, newRemaining, newTTL).Err(); err != nil { + return err + } + + // Set response headers + w.Header().Add("X-Rtl-Bucket", bucket) + w.Header().Add("X-Rtl-Scope", scope) + w.Header().Add("X-Rtl-Remaining", strconv.FormatInt(int64(newRemaining), 10)) + w.Header().Add("X-Rtl-Reset", strconv.FormatInt(time.Now().Add(newTTL).UnixMilli(), 10)) + + return nil +} + +func ratelimited(bucket string, scope string, id string) bool { + ratelimitHash := getRatelimitHash(bucket, scope, id) + remaining, err := rdb.Client.Get(context.TODO(), ratelimitHash).Int() + if err == redis.Nil || remaining > 0 { + return false + } else { + return true + } +} + +func getRatelimitHash(bucket string, scope string, id string) string { + h := sha3.NewShake256() + h.Write([]byte("rtl")) + h.Write([]byte(bucket)) + h.Write([]byte(scope)) + h.Write([]byte(id)) + + return base64.URLEncoding.EncodeToString(h.Sum(nil)) +} + +func checkCaptcha(response string) (bool, error) { + hCaptchaSecret := os.Getenv("HCAPTCHA_SECRET") + if hCaptchaSecret == "" { + log.Println("Skipping captcha check as there is no hCaptcha secret set.") + return true, nil + } + + marshaledReq, err := json.Marshal(map[string]string{ + "secret": hCaptchaSecret, + "response": response, + }) + if err != nil { + return false, err + } + + resp, err := http.Post(CaptchaVerifyUrl, "application/json", bytes.NewReader(marshaledReq)) + if err != nil { + return false, err + } + defer resp.Body.Close() + + var unmarshaledResp struct { + Success bool `json:"success"` + } + err = json.NewDecoder(resp.Body).Decode(&unmarshaledResp) + return unmarshaledResp.Success, err +} + +func getAuthedUser(r *http.Request, opts *AuthOpts) *users.User { + if opts == nil { + opts = &AuthOpts{} + } + + session, err := users.GetAccSessionByToken(r.Header.Get("token")) + if err != nil { + log.Println(1, err) + if err != users.ErrTokenExpired && err != users.ErrSessionNotFound { + sentry.CaptureException(err) + } + return nil + } + + user, err := users.GetUser(session.UserId) + if err != nil { + log.Println(err) + sentry.CaptureException(err) + return nil + } + + return &user +} + +func getUserByUrlParam(r *http.Request, urlParam string) (users.User, error) { + var user users.User + var err error + username := chi.URLParam(r, urlParam) + if strings.HasPrefix(username, "$") { + userId, _ := strconv.ParseInt(strings.Replace(username, "$", "", 1), 10, 64) + user, err = users.GetUser(userId) + } else { + user, err = users.GetUserByUsername(username) + } + return user, err +} diff --git a/pkg/chats/chat.go b/pkg/chats/chat.go new file mode 100644 index 0000000..c83bcf9 --- /dev/null +++ b/pkg/chats/chat.go @@ -0,0 +1,314 @@ +package chats + +import ( + "context" + "strconv" + "time" + + "github.com/getsentry/sentry-go" + "github.com/meower-media/server/pkg/db" + "github.com/meower-media/server/pkg/meowid" + "github.com/meower-media/server/pkg/structs" + "github.com/meower-media/server/pkg/users" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + ChatDefaultIdHome = 0 + + ChatTypeGroup = 0 + ChatTypeDirect = 1 +) + +type Chat struct { + Id meowid.MeowID `bson:"_id" msgpack:"id"` + Type int8 `bson:"type" msgpack:"type"` + + Nickname *string `bson:"nickname,omitempty" msgpack:"nickname,omitempty"` + IconId *string `bson:"icon_id,omitempty" msgpack:"icon_id,omitempty"` + Color *string `bson:"color,omitempty" msgpack:"color,omitempty"` + + // used for DMs + DirectRecipientIds *[]meowid.MeowID `bson:"direct_recipients,omitempty" msgpack:"direct_recipients,omitempty"` + + MemberCount int64 `bson:"member_count,omitempty" msgpack:"member_count,omitempty"` + + LastPostId meowid.MeowID `bson:"last_post_id,omitempty" msgpack:"last_post_id,omitempty"` + + // this should be made a permission chat admins can grant to members + AllowPinning bool `bson:"allow_pinning,omitempty" msgpack:"allow_pinning,omitempty"` +} + +func CreateGroupChat(nickname string, iconId string, color string) (Chat, error) { + chat := Chat{ + Id: meowid.GenId(), + Type: ChatTypeGroup, + Nickname: &nickname, + IconId: &iconId, + Color: &color, + } + _, err := db.Chats.InsertOne(context.TODO(), &chat) + return chat, err +} + +func GetChat(chatId meowid.MeowID) (Chat, error) { + var chat Chat + return chat, db.Chats.FindOne( + context.TODO(), + bson.M{"_id": chatId}, + ).Decode(&chat) +} + +func GetDM(userId1 meowid.MeowID, userId2 meowid.MeowID) (Chat, error) { + var chat Chat + q := bson.M{"direct_recipients": []meowid.MeowID{userId1, userId2}} + if err := db.Chats.FindOne(context.TODO(), q).Decode(&chat); err != nil { + if err == mongo.ErrNoDocuments { + chat = Chat{ + Id: meowid.GenId(), + Type: 1, + DirectRecipientIds: &[]meowid.MeowID{userId1, userId2}, + AllowPinning: true, + } + if _, err := db.Chats.InsertOne(context.TODO(), chat); err != nil { + return chat, err + } + + if _, err := chat.AddMember(userId1, true, false, false); err != nil { + return chat, err + } + if userId1 != userId2 { // if it's a self-DM, we will get an error with trying to add the same user + if _, err := chat.AddMember(userId2, true, false, false); err != nil { + return chat, err + } + } + } else { + return chat, err + } + } + return chat, nil +} + +func GetActiveChats(userId meowid.MeowID) ([]Chat, error) { + // Get chat IDs (limited to the latest 200 acked chats) + chatIds := []meowid.MeowID{} + opts := options.Find() + opts.Projection = bson.M{"_id.chat": 1} + opts.Sort = bson.M{"last_acked_post": -1} + limit := int64(200) + opts.Limit = &limit + cur, err := db.ChatMembers.Find(context.TODO(), bson.M{"_id.user": userId, "active": true}, opts) + if err != nil { + return nil, err + } + for cur.Next(context.TODO()) { + var m Member + if err := cur.Decode(&m); err != nil { + return nil, err + } + chatIds = append(chatIds, m.Id.ChatId) + } + + // Get chats + var chats []Chat + cur, err = db.Chats.Find(context.TODO(), bson.M{"_id": bson.M{"$in": chatIds}}) + if err != nil { + return nil, err + } + if err := cur.All(context.TODO(), &chats); err != nil { + return nil, err + } + + return chats, nil +} + +func (c *Chat) V0(requester users.User) structs.V0Chat { + // Get direct recipient + var directRecipient users.User + var directRecipientV0 *structs.V0User + var err error + if c.DirectRecipientIds != nil { + for _, userId := range *c.DirectRecipientIds { + if userId == requester.Id { + continue + } + + directRecipient, err = users.GetUser(userId) + if err != nil { + sentry.CaptureException(err) + } else { + userV0 := directRecipient.V0(true, false) + directRecipientV0 = &userV0 + } + break + } + } + + // Get member usernames + var memberUsernames []string + if c.Type == 0 { + memberUsernames = []string{} + } else if c.Type == 1 { // DMs + requester, err := users.GetUser(requester.Id) + if err != nil { + sentry.CaptureException(err) + } + memberUsernames = []string{directRecipient.Username, requester.Username} + } + + return structs.V0Chat{ + Id: strconv.FormatInt(c.Id, 10), + Type: c.Type, + + Nickname: c.Nickname, + IconId: c.IconId, + Color: c.Color, + + DirectRecipient: directRecipientV0, + + MemberUsernames: memberUsernames, + + CreatedAt: meowid.Extract(c.Id).Timestamp / 1000, + LastPostId: strconv.FormatInt(c.LastPostId, 10), + LastActiveAt: meowid.Extract(c.LastPostId).Timestamp / 1000, + + AllowPinning: c.AllowPinning, + } +} + +func (c *Chat) EditChatIcon(iconId *string, color *string) error { + if iconId != nil && *iconId != *c.IconId { + c.IconId = iconId + } + if color != nil { + c.Color = color + } + if _, err := db.Chats.UpdateByID( + context.TODO(), + c.Id, + bson.M{"icon": c.IconId, "color": c.Color}, + ); err != nil { + return err + } + if err := EmitUpdateChatEvent(c.Id, &UpdateChatEvent{ + IconId: iconId, + Color: color, + }); err != nil { + return err + } + return nil +} + +func (c *Chat) AddMember(userId meowid.MeowID, dm bool, active bool, admin bool) (Member, error) { + m := Member{ + Id: MemberIdCompound{ + ChatId: c.Id, + UserId: userId, + }, + JoinedAt: time.Now().UnixMilli(), + DM: dm, + Active: active, + Admin: admin, + NotificationSettings: NotificationSettings{ + Mode: 2, + Push: true, + }, + } + _, err := db.ChatMembers.InsertOne(context.TODO(), m) + return m, err +} + +// This should be executed within a background Goroutine +func (c *Chat) UpdateMemberCount() { + // Get member count + memberCount, err := db.ChatMembers.CountDocuments( + context.Background(), + bson.M{"_id.chat": c.Id}, + ) + if err != nil { + sentry.CaptureException(err) + } + + // Delete chat if no members remain or if only 1 member remains in a DM + if (memberCount == 0) || (memberCount == 1 && c.Type == ChatTypeDirect) { + if err := c.Delete(); err != nil { + sentry.CaptureException(err) + } + return + } + + // Update chat member count + if _, err := db.Chats.UpdateByID( + context.Background(), + c.Id, + bson.M{"$set": bson.M{"member_count": memberCount}}, + ); err != nil { + sentry.CaptureException(err) + } +} + +// This should be executed within a background Goroutine +func (c *Chat) UpdateLastPostId(postId meowid.MeowID) { + c.LastPostId = postId + if _, err := db.Chats.UpdateOne( + context.TODO(), + bson.M{"_id": c.Id}, + bson.M{"last_post_id": c.LastPostId}, + ); err != nil { + sentry.CaptureException(err) + } + + // reset active state on DM + if c.Type == 1 { + if _, err := db.ChatMembers.UpdateMany( + context.TODO(), + bson.M{"_id.chat": c.Id, "active": false}, + bson.M{"active": true}, + ); err != nil { + sentry.CaptureException(err) + } + } +} + +func (c *Chat) Delete() error { + // Delete members + if _, err := db.ChatMembers.DeleteMany( + context.TODO(), + bson.M{"_id.chat": c.Id}, + ); err != nil { + return err + } + + // Emit event + if err := EmitDeleteChatEvent(c.Id); err != nil { + return err + } + + // Delete chat + if _, err := db.Chats.DeleteOne( + context.TODO(), + bson.M{"_id": c.Id}, + ); err != nil { + return err + } + + // Delete emojis + go func() { + emojis, _ := c.GetEmotes(ChatEmoteTypeEmoji) + for _, emoji := range emojis { + emoji.Delete() + } + }() + + // Delete stickers + go func() { + stickers, _ := c.GetEmotes(ChatEmoteTypeSticker) + for _, sticker := range stickers { + sticker.Delete() + } + }() + + return nil +} diff --git a/pkg/chats/emote.go b/pkg/chats/emote.go new file mode 100644 index 0000000..dec029e --- /dev/null +++ b/pkg/chats/emote.go @@ -0,0 +1,93 @@ +package chats + +import ( + "context" + + "github.com/meower-media/server/pkg/db" + "github.com/meower-media/server/pkg/files" + "github.com/meower-media/server/pkg/structs" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" +) + +const ( + ChatEmoteTypeEmoji = 0 + ChatEmoteTypeSticker = 1 +) + +type Emote struct { + Id string `bson:"_id" msgpack:"id"` + ChatId int64 `bson:"chat" msgpack:"chat_id"` + Type int8 `bson:"type" msgpack:"type"` // 0: emoji, 1: sticker + Name string `bson:"name" msgpack:"name"` + Animated bool `bson:"animated" msgpack:"animated"` +} + +func (c *Chat) CreateEmote(emoteType int8, id string, name string) (Emote, error) { + var e Emote + + // Claim emote file + f, err := files.GetFile(id) + if err != nil { + return e, err + } + if err := f.Claim(); err != nil { + return e, err + } + + // Create emote + e = Emote{ + Id: id, + ChatId: c.Id, + Type: emoteType, + Name: name, + Animated: f.Mime == "image/gif", + } + if _, err := db.ChatEmotes.InsertOne(context.TODO(), &e); err != nil { + return e, err + } + + // Emit event + if err := EmitCreateEmoteEvent(&e); err != nil { + return e, err + } + + return e, err +} + +func (c *Chat) GetEmotes(emoteType int8) ([]Emote, error) { + var emotes []Emote + cur, err := db.ChatEmotes.Find(context.TODO(), bson.M{"chat": c.Id, "type": emoteType}) + if err != nil { + return emotes, err + } + return emotes, cur.All(context.TODO(), &emotes) +} + +func (c *Chat) GetEmote(emoteType int8, id string) (Emote, error) { + var e Emote + err := db.ChatEmotes.FindOne(context.TODO(), bson.M{"_id": id, "type": emoteType}).Decode(&e) + if err == mongo.ErrNoDocuments { + return e, ErrEmoteNotFound + } + return e, err +} + +func (e *Emote) V0() structs.V0ChatEmote { + return structs.V0ChatEmote{ + Id: e.Id, + Name: e.Name, + Animated: e.Animated, + } +} + +func (e *Emote) Update(name string) error { + e.Name = name + _, err := db.ChatEmotes.UpdateByID(context.TODO(), e.Id, bson.M{"name": name}) + return err +} + +func (e *Emote) Delete() error { + _, err := db.ChatEmotes.DeleteOne(context.TODO(), bson.M{"_id": e.Id}) + return err +} diff --git a/pkg/chats/errors.go b/pkg/chats/errors.go new file mode 100644 index 0000000..16b9a52 --- /dev/null +++ b/pkg/chats/errors.go @@ -0,0 +1,9 @@ +package chats + +import "errors" + +var ( + ErrMemberNotFound = errors.New("chat member not found") + ErrMemberAlreadyExists = errors.New("chat member already exists") + ErrEmoteNotFound = errors.New("chat emote not found") +) diff --git a/pkg/chats/events.go b/pkg/chats/events.go new file mode 100644 index 0000000..f2e02e2 --- /dev/null +++ b/pkg/chats/events.go @@ -0,0 +1,108 @@ +package chats + +import ( + "context" + "fmt" + + "github.com/meower-media/server/pkg/meowid" + "github.com/meower-media/server/pkg/rdb" + "github.com/meower-media/server/pkg/utils" + "github.com/vmihailenco/msgpack/v5" +) + +type UpdateChatEvent struct { + IconId *string `msgpack:"icon,omitempty"` + Color *string `msgpack:"color,omitempty"` +} + +func EmitUpdateChatEvent(chatId meowid.MeowID, d *UpdateChatEvent) error { + // Marshal packet + marshaledPacket, err := msgpack.Marshal(d) + if err != nil { + return err + } + marshaledPacket = append(marshaledPacket, utils.EvOpUpdateChat) + + // Send packet + return rdb.Client.Publish(context.TODO(), fmt.Sprint("c", chatId), marshaledPacket).Err() +} + +func EmitDeleteChatEvent(chatId meowid.MeowID) error { + return rdb.Client.Publish( + context.TODO(), + fmt.Sprint("c", chatId), + []byte{utils.EvOpDeleteChat}, + ).Err() +} + +func EmitCreateMemberEvent(m *Member) error { + // Marshal packet + marshaledPacket, err := msgpack.Marshal(m) + if err != nil { + return err + } + marshaledPacket = append(marshaledPacket, utils.EvOpCreateChatMember) + + // Send packet + return rdb.Client.Publish(context.TODO(), fmt.Sprint("c", m.Id.ChatId), marshaledPacket).Err() +} + +func EmitDeleteMemberEvent(chatId meowid.MeowID, userId meowid.MeowID) error { + // Marshal packet + marshaledPacket, err := msgpack.Marshal(userId) + if err != nil { + return err + } + marshaledPacket = append(marshaledPacket, utils.EvOpDeleteChatMember) + + // Send packet + return rdb.Client.Publish(context.TODO(), fmt.Sprint("c", chatId), marshaledPacket).Err() +} + +func EmitCreateEmoteEvent(e *Emote) error { + // Marshal packet + marshaledPacket, err := msgpack.Marshal(e) + if err != nil { + return err + } + marshaledPacket = append(marshaledPacket, utils.EvOpCreateChatEmote) + + // Send packet + return rdb.Client.Publish(context.TODO(), fmt.Sprint("c", e.ChatId), marshaledPacket).Err() +} + +func EmitUpdateEmoteEvent(e *Emote) error { + // Marshal packet + marshaledPacket, err := msgpack.Marshal(e) + if err != nil { + return err + } + marshaledPacket = append(marshaledPacket, utils.EvOpUpdateChatEmote) + + // Send packet + return rdb.Client.Publish(context.TODO(), fmt.Sprint("c", e.ChatId), marshaledPacket).Err() +} + +func EmitDeleteEmoteEvent(chatId meowid.MeowID, emoteId meowid.MeowID) error { + // Marshal packet + marshaledPacket, err := msgpack.Marshal(emoteId) + if err != nil { + return err + } + marshaledPacket = append(marshaledPacket, utils.EvOpDeleteChatEmote) + + // Send packet + return rdb.Client.Publish(context.TODO(), fmt.Sprint("c", chatId), marshaledPacket).Err() +} + +func EmitTypingEvent(chatId meowid.MeowID, userId meowid.MeowID) error { + // Marshal packet + marshaledPacket, err := msgpack.Marshal(userId) + if err != nil { + return err + } + marshaledPacket = append(marshaledPacket, utils.EvOpTyping) + + // Send packet + return rdb.Client.Publish(context.TODO(), fmt.Sprint("c", chatId), marshaledPacket).Err() +} diff --git a/pkg/chats/member.go b/pkg/chats/member.go new file mode 100644 index 0000000..131d1b8 --- /dev/null +++ b/pkg/chats/member.go @@ -0,0 +1,152 @@ +package chats + +import ( + "context" + "time" + + "github.com/getsentry/sentry-go" + "github.com/meower-media/server/pkg/db" + "github.com/meower-media/server/pkg/meowid" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type Member struct { + Id MemberIdCompound `bson:"_id" msgpack:"id"` + JoinedAt int64 `bson:"joined_at" msgpack:"joined_at"` + DM bool `bson:"dm" msgpack:"dm"` // whether this membership belongs to a DM chat + Active bool `bson:"active,omitempty" msgpack:"active,omitempty"` // only used for DMs + Admin bool `bson:"admin,omitempty" msgpack:"admin,omitempty"` + NotificationSettings NotificationSettings `bson:"notification_settings" msgpack:"notification_settings"` + LastAckedPostId meowid.MeowID `bson:"last_acked_post" msgpack:"last_acked_post"` +} + +type MemberIdCompound struct { + ChatId meowid.MeowID `bson:"chat" msgpack:"chat"` + UserId meowid.MeowID `bson:"user" msgpack:"user"` +} + +func GetChatMemberships(userId meowid.MeowID) ([]Member, error) { + // Get latest DM memberships + var dmChatMemberships []Member + f := bson.M{"_id.user": userId, "dm": true, "active": true} + opts := options.Find() + opts.SetSort(bson.M{"last_acked_post": -1}) + opts.SetLimit(100) + cur, err := db.ChatMembers.Find(context.TODO(), f, opts) + if err != nil { + return nil, err + } + if err := cur.All(context.TODO(), &dmChatMemberships); err != nil { + return nil, err + } + + // Get all group memberships + var groupChatMemberships []Member + f = bson.M{"_id.user": userId, "dm": false} + cur, err = db.ChatMembers.Find(context.TODO(), f) + if err != nil { + return nil, err + } + if err := cur.All(context.TODO(), &groupChatMemberships); err != nil { + return nil, err + } + + return append(dmChatMemberships, groupChatMemberships...), nil +} + +func (c *Chat) GetMember(userId meowid.MeowID) (Member, error) { + var member Member + err := db.ChatMembers.FindOne( + context.TODO(), + bson.M{"_id": bson.M{"chat": c.Id, "user": userId}}, + ).Decode(&member) + if err == mongo.ErrNoDocuments { + return member, ErrMemberNotFound + } + return member, err +} + +func (c *Chat) CreateMember( + userId meowid.MeowID, + dm bool, + admin bool, + notificationsMode int8, + notificationsPush bool, +) (Member, error) { + // Create membership + m := Member{ + Id: MemberIdCompound{ + ChatId: c.Id, + UserId: userId, + }, + JoinedAt: time.Now().UnixMilli(), + DM: dm, + Active: !dm, + Admin: admin, + NotificationSettings: NotificationSettings{ + Mode: notificationsMode, + Push: notificationsPush, + }, + LastAckedPostId: c.LastPostId, + } + if _, err := db.ChatMembers.InsertOne(context.TODO(), &m); err != nil { + return m, err + } + + // Emit event + if err := EmitCreateMemberEvent(&m); err != nil { + return m, err + } + + // Update member count on chat + go c.UpdateMemberCount() + + return m, nil +} + +func (m *Member) EmitTyping() error { + return EmitTypingEvent(m.Id.ChatId, m.Id.UserId) +} + +func (m *Member) SetActiveStatus(active bool) error { + // Set active status + m.Active = active + if _, err := db.ChatMembers.UpdateByID( + context.TODO(), + m.Id, + bson.M{"$set": bson.M{"active": active}}, + ); err != nil { + return err + } + + return nil +} + +func (m *Member) Delete() error { + // Delete chat member + if _, err := db.ChatMembers.DeleteOne( + context.TODO(), + bson.M{"_id": bson.M{"chat": m.Id.ChatId, "user": m.Id.UserId}}, + ); err != nil { + return err + } + + // Emit event + if err := EmitDeleteMemberEvent(m.Id.ChatId, m.Id.UserId); err != nil { + return err + } + + // Update member count on chat + go func() { + chat, err := GetChat(m.Id.ChatId) + if err != nil { + sentry.CaptureException(err) + return + } + chat.UpdateMemberCount() + }() + + return nil +} diff --git a/pkg/chats/notification_settings.go b/pkg/chats/notification_settings.go new file mode 100644 index 0000000..d5960cd --- /dev/null +++ b/pkg/chats/notification_settings.go @@ -0,0 +1,7 @@ +package chats + +type NotificationSettings struct { + Mode int8 `bson:"mode,omitempty" msgpack:"mode,omitempty"` // 2: all, 1: mentions, 0: none + Push bool `bson:"push,omitempty" msgpack:"push,omitempty"` + MutedUntil int64 `bson:"muted_until,omitempty" msgpack:"muted_until,omitempty"` // -1 for permanent mute +} diff --git a/pkg/db/db.go b/pkg/db/db.go new file mode 100644 index 0000000..5002138 --- /dev/null +++ b/pkg/db/db.go @@ -0,0 +1,73 @@ +package db + +import ( + "context" + "os" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var Client *mongo.Client +var Database *mongo.Database + +var ( + Config *mongo.Collection + Accounts *mongo.Collection + Users *mongo.Collection + UserSettings *mongo.Collection + AccSessions *mongo.Collection + Netblock *mongo.Collection + Relationships *mongo.Collection + Chats *mongo.Collection + ChatMembers *mongo.Collection + ChatEmotes *mongo.Collection + Posts *mongo.Collection + PostReactions *mongo.Collection + Reports *mongo.Collection + ReportSnapshots *mongo.Collection + Strikes *mongo.Collection + Files *mongo.Collection +) + +func Init(uri string, db string) error { + var err error + + // Connect to MongoDB + serverAPI := options.ServerAPI(options.ServerAPIVersion1) + opts := options.Client().ApplyURI(uri).SetServerAPIOptions(serverAPI) + Client, err = mongo.Connect(context.TODO(), opts) + if err != nil { + return err + } + + // Ping MongoDB + var result bson.M + if err := Client.Database("admin").RunCommand(context.TODO(), bson.D{{Key: "ping", Value: 1}}).Decode(&result); err != nil { + return err + } + + // Set database + Database = Client.Database(os.Getenv("MONGO_DB")) + + // Set collections + Config = Database.Collection("config") + Accounts = Database.Collection("accounts") + Users = Database.Collection("users") + UserSettings = Database.Collection("user_settings") + AccSessions = Database.Collection("acc_sessions") + Netblock = Database.Collection("netblock") + Relationships = Database.Collection("relationships") + Chats = Database.Collection("chats") + ChatMembers = Database.Collection("chat_members") + ChatEmotes = Database.Collection("chat_emotes") + Posts = Database.Collection("posts") + PostReactions = Database.Collection("post_reactions") + Reports = Database.Collection("reports") + ReportSnapshots = Database.Collection("report_snapshots") + Strikes = Database.Collection("strikes") + Files = Database.Collection("files") + + return nil +} diff --git a/pkg/emails/emails.go b/pkg/emails/emails.go new file mode 100644 index 0000000..cad2621 --- /dev/null +++ b/pkg/emails/emails.go @@ -0,0 +1,98 @@ +package emails + +import ( + "bytes" + "fmt" + htmlTmpl "html/template" + "log" + "os" + "strconv" + txtTmpl "text/template" + + "github.com/getsentry/sentry-go" + gomail "gopkg.in/mail.v2" +) + +var emailSubjects = map[string]string{ + "verify": "Verify your email address", + "recover": "Reset your password", + "security_alert": "Security alert", + "locked": "Your account has been locked", +} + +type EmailTmplVars struct { + PlatformName string + PlatformLogo string + PlatformBrand string + PlatformFrontend string + PlatformSupport string + + FromName string + FromAddress string + + Subject string + ToName string + ToAddress string + Token string +} + +func SendEmail(tmplName, toName, toAddress, token string) { + go func() { + // Create message vars + vars := EmailTmplVars{ + PlatformName: os.Getenv("EMAIL_PLATFORM_NAME"), + PlatformLogo: os.Getenv("EMAIL_PLATFORM_LOGO"), + PlatformBrand: os.Getenv("EMAIL_PLATFORM_BRAND"), + PlatformFrontend: os.Getenv("EMAIL_PLATFORM_FRONTEND"), + PlatformSupport: os.Getenv("EMAIL_PLATFORM_SUPPORT"), + + FromName: os.Getenv("EMAIL_FROM_NAME"), + FromAddress: os.Getenv("EMAIL_FROM_ADDRESS"), + + Subject: emailSubjects[tmplName], + ToName: toName, + ToAddress: toAddress, + Token: token, + } + + // Create message + m := gomail.NewMessage() + m.SetHeader("From", fmt.Sprintf("%s <%s>", vars.FromName, vars.FromAddress)) + m.SetHeader("To", fmt.Sprintf("%s <%s>", vars.ToName, vars.ToAddress)) + m.SetHeader("Subject", vars.Subject) + + // Render templates + var txtTmplBuf, htmlTmplBuf bytes.Buffer + _, err := txtTmpl.ParseFiles("pkg/emails/templates/base.txt", fmt.Sprintf("pkg/emails/templates/%s.txt", tmplName)) + if err != nil { + log.Fatalln(err) + sentry.CaptureException(err) + return + } + //tt.Execute(&txtTmplBuf, &vars) + ht, err := htmlTmpl.ParseFiles("pkg/emails/templates/base.html", fmt.Sprintf("pkg/emails/templates/%s.html", tmplName)) + if err != nil { + log.Fatalln(err) + sentry.CaptureException(err) + return + } + ht.Execute(&htmlTmplBuf, &vars) + + // Set message body + m.SetBody("text/plain", txtTmplBuf.String()) + m.AddAlternative("text/html", htmlTmplBuf.String()) + + // Send message + host := os.Getenv("EMAIL_SMTP_HOST") + port, _ := strconv.Atoi(os.Getenv("EMAIL_SMTP_PORT")) + if err := gomail.NewDialer( + host, + port, + os.Getenv("EMAIL_SMTP_USERNAME"), + os.Getenv("EMAIL_SMTP_PASSWORD"), + ).DialAndSend(m); err != nil { + log.Fatalln(err) + sentry.CaptureException(err) + } + }() +} diff --git a/pkg/emails/templates/base.html b/pkg/emails/templates/base.html new file mode 100644 index 0000000..473d552 --- /dev/null +++ b/pkg/emails/templates/base.html @@ -0,0 +1,25 @@ + + + +
+
+ {{.PlatformName}} Logo +
+ + + + + + + + + + {{template "body"}} + + + + +
{{.Subject}}
Hey {{.ToName}}!
- {{.PlatformBrand}}
+
+ + \ No newline at end of file diff --git a/pkg/emails/templates/base.txt b/pkg/emails/templates/base.txt new file mode 100644 index 0000000..86c779f --- /dev/null +++ b/pkg/emails/templates/base.txt @@ -0,0 +1,5 @@ +Hey {{.ToName}}! + +{{template "body" .}} + +- {{.PlatformBrand}} \ No newline at end of file diff --git a/pkg/emails/templates/verify.html b/pkg/emails/templates/verify.html new file mode 100644 index 0000000..10fafea --- /dev/null +++ b/pkg/emails/templates/verify.html @@ -0,0 +1,25 @@ +{{define "body"}} + + To confirm adding your email address ({{.ToAddress}}) to your {{.PlatformName}} account, please click the button below. + + + + If this wasn't you, please ignore this email, no further action is required. + + + + This link will expire in 30 minutes. + + + + + + Verify Email Address + + + +{{end}} \ No newline at end of file diff --git a/pkg/emails/templates/verify.txt b/pkg/emails/templates/verify.txt new file mode 100644 index 0000000..1257590 --- /dev/null +++ b/pkg/emails/templates/verify.txt @@ -0,0 +1,5 @@ +{{define "body"}} +To confirm adding your email address ({{.ToAddress}}) to your {{.PlatformName}} account, please follow this link (this link will expire in 30 minutes): {{.PlatformFrontend}}/emails/verify#{{.Token}} + +If this wasn't you, please ignore this email, no further action is required. +{{end}} \ No newline at end of file diff --git a/pkg/events/post_reaction_add.go b/pkg/events/post_reaction_add.go new file mode 100644 index 0000000..6e5d17f --- /dev/null +++ b/pkg/events/post_reaction_add.go @@ -0,0 +1,10 @@ +package events + +import "github.com/meower-media/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..ba0c672 --- /dev/null +++ b/pkg/events/post_reaction_remove.go @@ -0,0 +1,10 @@ +package events + +import "github.com/meower-media/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/update_post.go b/pkg/events/update_post.go new file mode 100644 index 0000000..76ffc7d --- /dev/null +++ b/pkg/events/update_post.go @@ -0,0 +1,15 @@ +package events + +import ( + "github.com/meower-media/server/pkg/chats" + "github.com/meower-media/server/pkg/posts" + "github.com/meower-media/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..b417acc --- /dev/null +++ b/pkg/events/update_relationship.go @@ -0,0 +1,10 @@ +package events + +import "github.com/meower-media/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/files/errors.go b/pkg/files/errors.go new file mode 100644 index 0000000..5452a41 --- /dev/null +++ b/pkg/files/errors.go @@ -0,0 +1,5 @@ +package files + +import "errors" + +var ErrFileAlreadyClaimed = errors.New("file already claimed") diff --git a/pkg/files/file.go b/pkg/files/file.go new file mode 100644 index 0000000..9ea7b4d --- /dev/null +++ b/pkg/files/file.go @@ -0,0 +1,41 @@ +package files + +import ( + "context" + + "github.com/meower-media/server/pkg/db" + "github.com/meower-media/server/pkg/meowid" + "go.mongodb.org/mongo-driver/bson" +) + +type File struct { + Id string `bson:"_id" msgpack:"id"` + Bucket string `bson:"bucket" msgpack:"bucket"` + Hash string `bson:"hash" msgpack:"hash"` + Mime string `bson:"mime" msgpack:"mime"` + Filename string `bson:"filename,omitempty" msgpack:"filename,omitempty"` + Width int `bson:"width,omitempty" msgpack:"width,omitempty"` + Height int `bson:"height,omitempty" msgpack:"height,omitempty"` + UploadRegion string `bson:"upload_region" msgpack:"upload_region"` + UploaderId meowid.MeowID `bson:"uploader" msgpack:"uploader"` + UploadedAt int64 `bson:"uploaded_at" msgpack:"uploaded_at"` + Claimed bool `bson:"claimed,omitempty" msgpack:"claimed,omitempty"` +} + +func GetFile(id string) (File, error) { + var f File + err := db.Files.FindOne(context.TODO(), bson.M{"_id": id}).Decode(&f) + return f, err +} + +func (f *File) Claim() error { + if f.Claimed { + return ErrFileAlreadyClaimed + } + _, err := db.Files.UpdateOne( + context.TODO(), + bson.M{"_id": f.Id}, + bson.M{"$set": bson.M{"claimed": true}}, + ) + return err +} diff --git a/pkg/legacy/accounts.py b/pkg/legacy/accounts.py new file mode 100644 index 0000000..af09992 --- /dev/null +++ b/pkg/legacy/accounts.py @@ -0,0 +1,196 @@ +from typing import Optional, TypedDict, Literal +from base64 import urlsafe_b64encode +from hashlib import sha256 +from threading import Thread +import time, bcrypt, secrets, re, pyotp, qrcode, qrcode.image.svg, os, msgpack + +from database import db, rdb +from meowid import gen_id +from sessions import create_token +import errors, security + + +# I hate this. But, thanks https://stackoverflow.com/a/201378 +EMAIL_REGEX = r"""(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])""" +BCRYPT_SALT_ROUNDS = 14 +TOTP_REGEX = "[0-9]{6}" + + +class AccountDB(TypedDict): + _id: int + + email: Optional[str] + normalized_email_hash: Optional[str] + + password_type: Literal["bcrypt"] + password_hash: bytes + + recovery_code: str + + authenticators: list["AuthenticatorDB"] + + last_auth_at: int + + +class AuthenticatorDB(TypedDict): + id: int + type: Literal["totp"] + nickname: str + totp_secret: Optional[str] + registered_at: int + + +class Account: + def __init__(self, data: AccountDB): + self.id = data["_id"] + self.email = data.get("email") + self.normalized_email_hash = data.get("normalized_email_hash") + self.password_type = data["password_type"] + self.password_hash = data["password_hash"] + self.recovery_code = data["recovery_code"] + self.authenticators = data.get("authenticators", []) + self.last_auth_at = data["last_auth_at"] + + @classmethod + def create(cls: "Account", user_id: int, password: str) -> "Account": + data: AccountDB = { + "_id": user_id, + "password_type": "bcrypt", + "password_hash": cls.hash_password_bcrypt(password), + "recovery_code": cls.gen_recovery_code(), + "last_auth_at": int(time.time()) + } + db.accounts.insert_one(data) + return cls(data) + + @classmethod + def get_by_id(cls: "Account", account_id: str) -> "Account": + data: Optional[AccountDB] = db.accounts.find_one({"_id": account_id}) + if not data: + raise errors.AccountNotFound + return cls(data) + + @classmethod + def get_by_email(cls: "Account", email: str) -> "Account": + data: Optional[AccountDB] = db.accounts.find_one({ + "normalized_email_hash": cls.get_normalized_email_hash(email) + }) + if not data: + raise errors.AccountNotFound + return cls(data) + + @staticmethod + def get_normalized_email_hash(address: str) -> str: + """ + Get a hash of an email address with aliases and dots stripped. + This is to allow using address aliases, but to still detect ban evasion. + Also, Gmail ignores dots in addresses. Thanks Google. + """ + + identifier, domain = address.split("@") + identifier = re.split(r'\+|\%', identifier)[0] + identifier = identifier.replace(".", "") + + return urlsafe_b64encode(sha256(f"{identifier}@{domain}".encode()).digest()).decode() + + @staticmethod + def hash_password_bcrypt(password: str) -> str: + return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=BCRYPT_SALT_ROUNDS)) + + @staticmethod + def gen_recovery_code() -> str: + return secrets.token_hex(5) + + @staticmethod + def gen_totp_secret() -> str: + return pyotp.random_base32() + + @staticmethod + def get_totp_provisioning_uri(secret: str, username: str) -> str: + return pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name="Meower") + + @staticmethod + def get_totp_qrcode(provisioning_uri: str) -> str: + return qrcode.make( + provisioning_uri, + image_factory=qrcode.image.svg.SvgImage + ).to_string(encoding="unicode").replace("svg:rect", "rect") + + @property + def mfa_methods(self) -> list[str]: + methods = set() + for authenticator in self.authenticators: + methods.add(authenticator["type"]) + return list(methods) + + async def log_security_action(self, action_type: str, data: dict): + db.security_log.insert_one({ + "_id": await gen_id(), + "type": action_type, + "user": self.id, + "time": int(time.time()), + "data": data + }) + + if action_type in { + "email_changed", + "password_changed", + "mfa_added", + "mfa_removed", + "mfa_recovery_reset", + "mfa_recovery_used", + "locked" + }: + tmpl_name = "locked" if action_type == "locked" else "security_alert" + platform_name = os.environ["EMAIL_PLATFORM_NAME"] + platform_brand = os.environ["EMAIL_PLATFORM_BRAND"] + + txt_tmpl, html_tmpl = security.render_email_tmpl(tmpl_name, self.id, self.email, { + "msg": { + "email_changed": f"The email address on your {platform_name} account has been changed.", + "password_changed": f"The password on your {platform_name} account has been changed.", + "mfa_added": f"A multi-factor authenticator has been added to your {platform_name} account.", + "mfa_removed": f"A multi-factor authenticator has been removed from your {platform_name} account.", + "mfa_recovery_reset": f"The multi-factor authentication recovery code on your {platform_name} account has been reset.", + "mfa_recovery_used": f"Your multi-factor authentication recovery code has been used to reset multi-factor authentication on your {platform_name} account." + }[action_type] if action_type != "locked" else None, + "token": create_token("email", [ # this doesn't use EmailTicket in sessions.py because it'd be a recursive import + self.email, + self.id, + "lockdown", + int(time.time())+86400 + ]) if self.email and action_type != "locked" else None + }) + + # Email + if self.email: + Thread( + target=security.send_email, + args=[security.EMAIL_SUBJECTS[tmpl_name], self.id, self.email, txt_tmpl, html_tmpl] + ).start() + + # Inbox + rdb.publish("admin", msgpack.packb({ + "op": "alert_user", + "user": self.id, + "content": txt_tmpl.replace(f"- {platform_brand}", f"""\- {platform_brand}""") + })) + + def check_password(self, password: str) -> bool: + return bcrypt.checkpw(password.encode(), self.password_hash) + + def check_totp_code(self, code: str) -> bool: + for authenticator in self.authenticators: + if authenticator["type"] != "totp": + continue + if pyotp.TOTP(authenticator["totp_secret"]).verify(code, valid_window=1): + return True + return False + + def reset_mfa(self): + self.recovery_code = secrets.token_hex(5) + self.authenticators = [] + db.accounts.update_one({"_id": self.id}, {"$set": { + "recovery_code": self.recovery_code, + "authenticators": self.authenticators + }}) diff --git a/cloudlink.py b/pkg/legacy/cloudlink.py similarity index 88% rename from cloudlink.py rename to pkg/legacy/cloudlink.py index e2cd568..1155ae7 100755 --- a/cloudlink.py +++ b/pkg/legacy/cloudlink.py @@ -1,8 +1,8 @@ import websockets, asyncio, json, time, requests, os from typing import Optional, Iterable, TypedDict, Literal, Any -from inspect import getfullargspec from urllib.parse import urlparse, parse_qs +from database import rdb from utils import log, full_stack VERSION = "0.1.7.10" @@ -31,7 +31,7 @@ def __init__(self): #"EmptyPacket": "E:109 | Empty packet", -- unused #"IDConflict": "E:110 | ID conflict", -- deprecated #"IDSet": "E:111 | ID already set", -- deprecated - "TAEnabled": "I:112 | Trusted Access enabled", + #"TAEnabled": "I:112 | Trusted Access enabled", -- deprecated #"TAInvalid": "E:113 | TA Key invalid", -- deprecated #"TAExpired": "E:114 | TA Key expired", -- deprecated #"Refused": "E:115 | Refused", -- deprecated @@ -66,6 +66,15 @@ def __init__(self): self.clients: set[CloudlinkClient] = set() self.usernames: dict[str, list[CloudlinkClient]] = {} # {"username": [cl_client1, cl_client2, ...]} + # on: = online by node ID + # ou: = online by user ID + # pg: = global presence by user ID + # pc:: = chat presence by user ID + for user_id in rdb.smembers(f"on:{os.environ['NODE_ID']}"): + rdb.srem(f"ou:{user_id}", os.environ['NODE_ID']) + if not rdb.exists(f"ou:{user_id}"): + pass + async def client_handler(self, websocket: websockets.WebSocketServerProtocol): # Create CloudlinkClient cl_client = CloudlinkClient(self, websocket) @@ -76,12 +85,6 @@ async def client_handler(self, websocket: websockets.WebSocketServerProtocol): # Send ulist cl_client.send("ulist", self.get_ulist()) - # Send Trusted Access statuscode - if cl_client.proto_version == 0: - cl_client.send_statuscode("TAEnabled") - else: - cl_client.trusted = True - # Process incoming packets until WebSocket closes try: async for packet in websocket: @@ -101,13 +104,6 @@ async def client_handler(self, websocket: websockets.WebSocketServerProtocol): cl_client.send_statuscode("Syntax", packet.get("listener")) continue - # Pseudo Trusted Access - if not cl_client.trusted and packet["cmd"] in ["direct", "gmsg"]: - if isinstance(packet["val"], str): - cl_client.trusted = True - cl_client.send_statuscode("OK", packet.get("listener")) - continue - # Unwrap "direct" cmd if packet["cmd"] == "direct" and isinstance(packet["val"], dict): packet["cmd"] = packet["val"].get("cmd") @@ -130,14 +126,7 @@ async def client_handler(self, websocket: websockets.WebSocketServerProtocol): # Execute command try: - # Extra args mainly used for pmsg, gvar, and pvar - extra_args = {} - if "id" in getfullargspec(cmd_func).args: - extra_args["id"] = packet.get("id") - if "name" in getfullargspec(cmd_func).args: - extra_args["name"] = packet.get("name") - - await cmd_func(cl_client, packet["val"], packet.get("listener"), **extra_args) + await cmd_func(cl_client, packet["val"], packet.get("listener")) except: print(full_stack()) cl_client.send_statuscode("InternalServerError", packet.get("listener")) @@ -227,14 +216,13 @@ def __init__( self.server = server self.websocket = websocket - # Set account session ID, username, protocol version, IP, and trusted status + # Set account session ID, username, protocol version, and IP self.acc_session_id: Optional[str] = None self.username: Optional[str] = None try: self.proto_version: int = int(self.req_params.get("v")[0]) except: self.proto_version: int = 0 - self.trusted: bool = False # Automatic login if "token" in self.req_params: @@ -261,11 +249,13 @@ def authenticate(self, acc_session: dict[str, Any], token: str, account: dict[st if self.username: self.logout() - # Check ban - if (account["ban"]["state"] == "perm_ban") or (account["ban"]["state"] == "temp_ban" and account["ban"]["expires"] > time.time()): - self.send("banned", account["ban"], listener=listener) - return self.send_statuscode("Banned", listener) - + """ + # Check ban + if (account["ban"]["state"] == "perm_ban") or (account["ban"]["state"] == "temp_ban" and account["ban"]["expires"] > time.time()): + self.send("banned", account["ban"], listener=listener) + return self.send_statuscode("Banned", listener) + """ + # Authenticate self.acc_session_id = acc_session["_id"] self.username = account["_id"] @@ -281,9 +271,9 @@ def authenticate(self, acc_session: dict[str, Any], token: str, account: dict[st "session": acc_session, "token": token, "account": account, - "relationships": self.proxy_api_request("/me/relationships", "get")["autoget"], + "relationships": [],#self.proxy_api_request("/me/relationships", "get")["autoget"], **({ - "chats": self.proxy_api_request("/chats", "get")["autoget"] + "chats": []#self.proxy_api_request("/chats", "get")["autoget"] } if self.proto_version != 0 else {}) }, listener=listener) diff --git a/database.py b/pkg/legacy/database.py similarity index 85% rename from database.py rename to pkg/legacy/database.py index e66ba1a..c0396c0 100644 --- a/database.py +++ b/pkg/legacy/database.py @@ -8,8 +8,10 @@ from hashlib import sha256 from base64 import urlsafe_b64encode +from meowid import gen_id_injected, MEOWER_EPOCH from utils import log +CURRENT_DB_VERSION = 10 CURRENT_DB_VERSION = 10 # Create Redis connection @@ -303,6 +305,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) + + + # Delete system users from DB log("[Migrator] Deleting system users from DB") db.usersv0.delete_many({"_id": {"$in": ["Server", "Deleted", "Meower", "Admin", "username"]}}) diff --git a/email_templates/_base.html b/pkg/legacy/email_templates/_base.html similarity index 100% rename from email_templates/_base.html rename to pkg/legacy/email_templates/_base.html diff --git a/email_templates/_base.txt b/pkg/legacy/email_templates/_base.txt similarity index 100% rename from email_templates/_base.txt rename to pkg/legacy/email_templates/_base.txt diff --git a/email_templates/locked.html b/pkg/legacy/email_templates/locked.html similarity index 100% rename from email_templates/locked.html rename to pkg/legacy/email_templates/locked.html diff --git a/email_templates/locked.txt b/pkg/legacy/email_templates/locked.txt similarity index 100% rename from email_templates/locked.txt rename to pkg/legacy/email_templates/locked.txt diff --git a/email_templates/recover.html b/pkg/legacy/email_templates/recover.html similarity index 100% rename from email_templates/recover.html rename to pkg/legacy/email_templates/recover.html diff --git a/email_templates/recover.txt b/pkg/legacy/email_templates/recover.txt similarity index 100% rename from email_templates/recover.txt rename to pkg/legacy/email_templates/recover.txt diff --git a/email_templates/security_alert.html b/pkg/legacy/email_templates/security_alert.html similarity index 100% rename from email_templates/security_alert.html rename to pkg/legacy/email_templates/security_alert.html diff --git a/email_templates/security_alert.txt b/pkg/legacy/email_templates/security_alert.txt similarity index 100% rename from email_templates/security_alert.txt rename to pkg/legacy/email_templates/security_alert.txt diff --git a/email_templates/verify.html b/pkg/legacy/email_templates/verify.html similarity index 100% rename from email_templates/verify.html rename to pkg/legacy/email_templates/verify.html diff --git a/email_templates/verify.txt b/pkg/legacy/email_templates/verify.txt similarity index 100% rename from email_templates/verify.txt rename to pkg/legacy/email_templates/verify.txt diff --git a/emojis/ar.json b/pkg/legacy/emojis/ar.json similarity index 100% rename from emojis/ar.json rename to pkg/legacy/emojis/ar.json diff --git a/emojis/de.json b/pkg/legacy/emojis/de.json similarity index 100% rename from emojis/de.json rename to pkg/legacy/emojis/de.json diff --git a/emojis/en.json b/pkg/legacy/emojis/en.json similarity index 100% rename from emojis/en.json rename to pkg/legacy/emojis/en.json diff --git a/emojis/es.json b/pkg/legacy/emojis/es.json similarity index 100% rename from emojis/es.json rename to pkg/legacy/emojis/es.json diff --git a/emojis/fa.json b/pkg/legacy/emojis/fa.json similarity index 100% rename from emojis/fa.json rename to pkg/legacy/emojis/fa.json diff --git a/emojis/fr.json b/pkg/legacy/emojis/fr.json similarity index 100% rename from emojis/fr.json rename to pkg/legacy/emojis/fr.json diff --git a/emojis/generator.py b/pkg/legacy/emojis/generator.py similarity index 100% rename from emojis/generator.py rename to pkg/legacy/emojis/generator.py diff --git a/emojis/id.json b/pkg/legacy/emojis/id.json similarity index 100% rename from emojis/id.json rename to pkg/legacy/emojis/id.json diff --git a/emojis/it.json b/pkg/legacy/emojis/it.json similarity index 100% rename from emojis/it.json rename to pkg/legacy/emojis/it.json diff --git a/emojis/ja.json b/pkg/legacy/emojis/ja.json similarity index 100% rename from emojis/ja.json rename to pkg/legacy/emojis/ja.json diff --git a/emojis/ko.json b/pkg/legacy/emojis/ko.json similarity index 100% rename from emojis/ko.json rename to pkg/legacy/emojis/ko.json diff --git a/emojis/pt.json b/pkg/legacy/emojis/pt.json similarity index 100% rename from emojis/pt.json rename to pkg/legacy/emojis/pt.json diff --git a/emojis/ru.json b/pkg/legacy/emojis/ru.json similarity index 100% rename from emojis/ru.json rename to pkg/legacy/emojis/ru.json diff --git a/emojis/tr.json b/pkg/legacy/emojis/tr.json similarity index 100% rename from emojis/tr.json rename to pkg/legacy/emojis/tr.json diff --git a/emojis/zh.json b/pkg/legacy/emojis/zh.json similarity index 100% rename from emojis/zh.json rename to pkg/legacy/emojis/zh.json diff --git a/pkg/legacy/errors.py b/pkg/legacy/errors.py new file mode 100644 index 0000000..b0d95ab --- /dev/null +++ b/pkg/legacy/errors.py @@ -0,0 +1,13 @@ +class UsernameDisallowed(Exception): pass +class UsernameTaken(Exception): pass +class PasswordDisallowed(Exception): pass +class UserNotFound(Exception): pass +class AccountNotFound(Exception): pass + +class InvalidTokenSignature(Exception): pass + +class AccSessionTokenExpired(Exception): pass + +class AccSessionNotFound(Exception): pass + +class EmailTicketExpired(Exception): pass diff --git a/pkg/legacy/events.py b/pkg/legacy/events.py new file mode 100644 index 0000000..094b897 --- /dev/null +++ b/pkg/legacy/events.py @@ -0,0 +1,149 @@ +""" +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/pkg/legacy/grpc_auth/auth_service_pb2.py similarity index 100% rename from grpc_auth/auth_service_pb2.py rename to pkg/legacy/grpc_auth/auth_service_pb2.py diff --git a/grpc_auth/auth_service_pb2.pyi b/pkg/legacy/grpc_auth/auth_service_pb2.pyi similarity index 100% rename from grpc_auth/auth_service_pb2.pyi rename to pkg/legacy/grpc_auth/auth_service_pb2.pyi diff --git a/grpc_auth/auth_service_pb2_grpc.py b/pkg/legacy/grpc_auth/auth_service_pb2_grpc.py similarity index 100% rename from grpc_auth/auth_service_pb2_grpc.py rename to pkg/legacy/grpc_auth/auth_service_pb2_grpc.py diff --git a/grpc_auth/service.py b/pkg/legacy/grpc_auth/service.py similarity index 100% rename from grpc_auth/service.py rename to pkg/legacy/grpc_auth/service.py diff --git a/grpc_uploads/client.py b/pkg/legacy/grpc_uploads/client.py similarity index 100% rename from grpc_uploads/client.py rename to pkg/legacy/grpc_uploads/client.py diff --git a/grpc_uploads/uploads_service_pb2.py b/pkg/legacy/grpc_uploads/uploads_service_pb2.py similarity index 100% rename from grpc_uploads/uploads_service_pb2.py rename to pkg/legacy/grpc_uploads/uploads_service_pb2.py diff --git a/grpc_uploads/uploads_service_pb2.pyi b/pkg/legacy/grpc_uploads/uploads_service_pb2.pyi similarity index 100% rename from grpc_uploads/uploads_service_pb2.pyi rename to pkg/legacy/grpc_uploads/uploads_service_pb2.pyi diff --git a/grpc_uploads/uploads_service_pb2_grpc.py b/pkg/legacy/grpc_uploads/uploads_service_pb2_grpc.py similarity index 100% rename from grpc_uploads/uploads_service_pb2_grpc.py rename to pkg/legacy/grpc_uploads/uploads_service_pb2_grpc.py diff --git a/pkg/legacy/meowid.py b/pkg/legacy/meowid.py new file mode 100644 index 0000000..6a91e6d --- /dev/null +++ b/pkg/legacy/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(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/pkg/legacy/requirements.txt similarity index 100% rename from requirements.txt rename to pkg/legacy/requirements.txt diff --git a/rest_api/__init__.py b/pkg/legacy/rest_api/__init__.py similarity index 62% rename from rest_api/__init__.py rename to pkg/legacy/rest_api/__init__.py index 6153be7..76fdc0a 100755 --- a/rest_api/__init__.py +++ b/pkg/legacy/rest_api/__init__.py @@ -1,6 +1,6 @@ -from quart import Quart, request, abort +from quart import Quart, request from quart_cors import cors -from quart_schema import QuartSchema, RequestSchemaValidationError, validate_headers, hide +from quart_schema import QuartSchema, RequestSchemaValidationError, validate_headers from pydantic import BaseModel from sentry_sdk import capture_exception import time, os @@ -10,7 +10,7 @@ from .admin import admin_bp -from database import db, blocked_ips, registration_blocked_ips +from database import db from sessions import AccSession import security @@ -34,20 +34,6 @@ async def check_repair_mode(): return {"error": True, "type": "repairModeEnabled"}, 503 -@app.before_request -async def internal_auth(): - if "Cf-Connecting-Ip" not in request.headers: # Make sure there's no Cf-Connecting-Ip header - if request.headers.get("X-Internal-Token") == os.getenv("INTERNAL_API_TOKEN"): # Check internal token - # Safety check - if os.getenv("INTERNAL_API_TOKEN") == "" and request.remote_addr != "127.0.0.1": - abort(401) - - request.internal_ip = request.headers.get("X-Internal-Ip") - request.headers["User-Agent"] = request.headers.get("X-Internal-UA") - request.internal_username = request.headers.get("X-Internal-Username") - request.bypass_captcha = True - - @app.before_request async def get_ip(): if hasattr(request, "internal_ip") and request.internal_ip: # internal IP forwarding @@ -66,15 +52,7 @@ async def check_auth(headers: TokenHeader): # Authenticate request account = None if request.path != "/status": - if hasattr(request, "internal_username") and request.internal_username: # internal auth - account = db.usersv0.find_one({"_id": request.internal_username}, projection={ - "_id": 1, - "flags": 1, - "permissions": 1, - "ban.state": 1, - "ban.expires": 1 - }) - elif headers.token: # external auth + if headers.token: # external auth try: username = AccSession.get_username_by_token(headers.token) except Exception as e: @@ -96,43 +74,6 @@ async def check_auth(headers: TokenHeader): request.permissions = account["permissions"] -@app.get("/") # Welcome message -async def index(): - return { - "captcha": { - "enabled": os.getenv("CAPTCHA_SECRET") is not None, - "sitekey": os.getenv("CAPTCHA_SITEKEY") - } - }, 200 - - -@app.get("/favicon.ico") # Favicon, my ass. We need no favicon for an API. -@hide -async def favicon_my_ass(): - return "", 200 - - -@app.get("/status") -async def get_status(): - return { - "scratchDeprecated": True, - "registrationEnabled": app.supporter.registration, - "isRepairMode": app.supporter.repair_mode, - "ipBlocked": (blocked_ips.search_best(request.ip) is not None), - "ipRegistrationBlocked": (registration_blocked_ips.search_best(request.ip) is not None) - }, 200 - - -@app.get("/statistics") -async def get_statistics(): - return { - "error": False, - "users": db.usersv0.estimated_document_count(), - "posts": db.posts.estimated_document_count(), - "chats": db.chats.estimated_document_count() - }, 200 - - @app.get("/ulist") async def get_ulist(): # Get page diff --git a/rest_api/admin.py b/pkg/legacy/rest_api/admin.py similarity index 92% rename from rest_api/admin.py rename to pkg/legacy/rest_api/admin.py index d4c8c0d..5ce21aa 100644 --- a/rest_api/admin.py +++ b/pkg/legacy/rest_api/admin.py @@ -8,12 +8,24 @@ import security from database import db, get_total_pages, blocked_ips, registration_blocked_ips +from meowid import gen_id from sessions import AccSession admin_bp = Blueprint("admin_bp", __name__, url_prefix="/admin") +async def add_audit_log(action_type, mod_username, mod_ip, data): + db.audit_log.insert_one({ + "_id": await gen_id, + "type": action_type, + "mod_username": mod_username, + "mod_ip": mod_ip, + "time": int(time.time()), + "data": data + }) + + class GetReportsQueryArgs(BaseModel): status: Optional[Literal[ "pending", @@ -149,7 +161,7 @@ async def get_reports(query_args: GetReportsQueryArgs): report["content"] = security.get_account(report.get("content_id")) # Add log - security.add_audit_log( + await add_audit_log( "got_reports", request.user, request.ip, @@ -193,7 +205,7 @@ async def get_report(report_id): report["content"] = security.get_account(report.get("content_id")) # Add log - security.add_audit_log( + await add_audit_log( "got_report", request.user, request.ip, {"report_id": report_id} ) @@ -234,7 +246,7 @@ async def update_report(report_id, data: UpdateReportBody): report["content"] = security.get_account(report.get("content_id")) # Add log - security.add_audit_log( + await add_audit_log( "updated_report", request.user, request.ip, @@ -277,7 +289,7 @@ async def escalate_report(report_id): report["content"] = security.get_account(report.get("content_id")) # Add log - security.add_audit_log( + await add_audit_log( "updated_report", request.user, request.ip, @@ -299,7 +311,7 @@ async def get_admin_notes(identifier): notes = db.admin_notes.find_one({"_id": identifier}) # Add log - security.add_audit_log( + await add_audit_log( "got_notes", request.user, request.ip, {"identifier": identifier} ) @@ -336,7 +348,7 @@ async def edit_admin_note(identifier, data: UpdateNotesBody): ) # Add log - security.add_audit_log( + await add_audit_log( "updated_notes", request.user, request.ip, @@ -466,7 +478,7 @@ async def get_users(query_args: GetUsersQueryArgs): usernames = [user["_id"] for user in db.usersv0.find({}, sort=[("created", pymongo.DESCENDING)], skip=(query_args.page-1)*25, limit=25)] # Add log - security.add_audit_log("got_users", request.user, request.ip, {"page": query_args.page}) + await add_audit_log("got_users", request.user, request.ip, {"page": query_args.page}) # Return users return { @@ -572,7 +584,7 @@ async def get_user(username): ] # Add log - security.add_audit_log( + await add_audit_log( "got_user", request.user, request.ip, @@ -600,7 +612,7 @@ async def update_user(username, data: UpdateUserBody): # Permissions if data.permissions is not None: updated_fields["permissions"] = data.permissions - security.add_audit_log( + await add_audit_log( "updated_permissions", request.user, request.ip, @@ -696,7 +708,7 @@ async def ban_user(username, data: UpdateUserBanBody): ) # Add log - security.add_audit_log( + await add_audit_log( "banned", request.user, request.ip, @@ -736,7 +748,7 @@ async def get_user_posts(username, query_args: GetUserPostsQueryArgs): ) # Add log - security.add_audit_log( + await add_audit_log( "got_user_posts", request.user, request.ip, @@ -784,7 +796,7 @@ async def clear_user_posts(username, query_args: ClearUserPostsQueryArgs): ) # Add log - security.add_audit_log( + await add_audit_log( "clear_user_posts", request.user, request.ip, @@ -806,10 +818,10 @@ async def send_alert(username, data: InboxMessageBody): abort(404) # Create inbox message - post = app.supporter.create_post("inbox", username, data.content) + post = await app.supporter.create_post("inbox", username, data.content) # Add log - security.add_audit_log( + await add_audit_log( "alerted", request.user, request.ip, @@ -832,7 +844,7 @@ async def kick_user(username): session.revoke() # Add log - security.add_audit_log( + await add_audit_log( "kicked", request.user, request.ip, {"username": username} ) @@ -867,7 +879,7 @@ async def clear_avatar(username): app.cl.send_event("update_profile", {"_id": username, "avatar": ""}) # Add log - security.add_audit_log( + await add_audit_log( "cleared_avatar", request.user, request.ip, {"username": username} ) @@ -902,7 +914,7 @@ async def clear_quote(username): app.cl.send_event("update_profile", {"_id": username, "quote": ""}) # Add log - security.add_audit_log( + await add_audit_log( "cleared_quote", request.user, request.ip, {"username": username} ) @@ -921,7 +933,7 @@ async def get_chat(chat_id): abort(404) # Add log - security.add_audit_log("got_chat", request.user, request.ip, {"chat_id": chat_id}) + await add_audit_log("got_chat", request.user, request.ip, {"chat_id": chat_id}) # Return chat chat.update({ @@ -967,7 +979,7 @@ async def update_chat(chat_id, data: UpdateChatBody): # Add log updated_vals["chat_id"] = updated_vals.pop("_id") - security.add_audit_log("updated_chat", request.user, request.ip, updated_vals) + await add_audit_log("updated_chat", request.user, request.ip, updated_vals) # Return chat chat["error"] = False @@ -993,7 +1005,7 @@ async def delete_chat(chat_id): app.cl.send_event("delete_chat", {"chat_id": chat_id}, usernames=chat["members"]) # Add log - security.add_audit_log("deleted_chat", request.user, request.ip, {"chat_id": chat_id}) + await add_audit_log("deleted_chat", request.user, request.ip, {"chat_id": chat_id}) # Return chat chat["error"] = False @@ -1019,7 +1031,7 @@ async def restore_chat(chat_id): app.cl.send_event("create_chat", chat, usernames=chat["members"]) # Add log - security.add_audit_log("restored_chat", request.user, request.ip, {"chat_id": chat_id}) + await add_audit_log("restored_chat", request.user, request.ip, {"chat_id": chat_id}) # Return chat chat["error"] = False @@ -1053,7 +1065,7 @@ async def transfer_chat_ownership(chat_id, username): app.cl.send_event("update_chat", {"_id": chat_id, "owner": chat["owner"]}, usernames=chat["members"]) # Add log - security.add_audit_log("transferred_chat_ownership", request.user, request.ip, {"chat_id": chat_id, "username": username}) + await add_audit_log("transferred_chat_ownership", request.user, request.ip, {"chat_id": chat_id, "username": username}) # Return chat chat["error"] = False @@ -1115,7 +1127,7 @@ async def get_netinfo(ip): hit_users.add(session["user"]) # Add log - security.add_audit_log("got_netinfo", request.user, request.ip, {"ip": ip}) + await add_audit_log("got_netinfo", request.user, request.ip, {"ip": ip}) # Return netinfo, netblocks, and netlogs return { @@ -1137,7 +1149,7 @@ async def get_netblocks(query_args: GetNetblocksQueryArgs): netblocks = list(db.netblock.find({}, sort=[("created", pymongo.DESCENDING)], skip=(query_args.page-1)*25, limit=25)) # Add log - security.add_audit_log("got_netblocks", request.user, request.ip, {"page": query_args.page}) + await add_audit_log("got_netblocks", request.user, request.ip, {"page": query_args.page}) # Return netblocks return { @@ -1163,7 +1175,7 @@ async def get_netblock(cidr): abort(404) # Add log - security.add_audit_log( + await add_audit_log( "got_netblock", request.user, request.ip, {"cidr": cidr, "netblock": netblock} ) @@ -1216,7 +1228,7 @@ async def create_netblock(cidr, data: NetblockBody): await client.websocket.close() # Add log - security.add_audit_log( + await add_audit_log( "created_netblock", request.user, request.ip, @@ -1247,7 +1259,7 @@ async def delete_netblock(cidr): registration_blocked_ips.delete(cidr) # Add log - security.add_audit_log( + await add_audit_log( "deleted_netblock", request.user, request.ip, {"cidr": cidr} ) @@ -1272,7 +1284,7 @@ async def get_announcements(query_args: GetAnnouncementsQueryArgs): ) # Add log - security.add_audit_log( + await add_audit_log( "got_announcements", request.user, request.ip, {"page": query_args.page} ) @@ -1295,10 +1307,10 @@ async def send_announcement(data: InboxMessageBody): abort(401) # Create announcement - post = app.supporter.create_post("inbox", "Server", data.content) + post = await app.supporter.create_post("inbox", "Server", data.content) # Add log - security.add_audit_log( + await add_audit_log( "sent_announcement", request.user, request.ip, {"content": data.content} ) @@ -1318,7 +1330,7 @@ async def kick_all_clients(): await client.websocket.close() # Add log - security.add_audit_log("kicked_all", request.user, request.ip, {}) + await add_audit_log("kicked_all", request.user, request.ip, {}) return {"error": False}, 200 @@ -1340,7 +1352,7 @@ async def enable_repair_mode(): await client.websocket.close() # Add log - security.add_audit_log("enabled_repair_mode", request.user, request.ip, {}) + await add_audit_log("enabled_repair_mode", request.user, request.ip, {}) return {"error": False}, 200 @@ -1358,7 +1370,7 @@ async def disable_registration(): app.supporter.registration = False # Add log - security.add_audit_log("disabled_registration", request.user, request.ip, {}) + await add_audit_log("disabled_registration", request.user, request.ip, {}) return {"error": False}, 200 @@ -1376,6 +1388,6 @@ async def enable_registration(): app.supporter.registration = True # Add log - security.add_audit_log("enabled_registration", request.user, request.ip, {}) + await add_audit_log("enabled_registration", request.user, request.ip, {}) return {"error": False}, 200 diff --git a/rest_api/v0/__init__.py b/pkg/legacy/rest_api/v0/__init__.py similarity index 82% rename from rest_api/v0/__init__.py rename to pkg/legacy/rest_api/v0/__init__.py index 7fb6162..ff00e23 100644 --- a/rest_api/v0/__init__.py +++ b/pkg/legacy/rest_api/v0/__init__.py @@ -4,9 +4,7 @@ from .chats import chats_bp from .inbox import inbox_bp from .posts import posts_bp -from .users import users_bp from .auth import auth_bp -from .home import home_bp from .me import me_bp from .emojis import emojis_bp from .emails import emails_bp @@ -16,10 +14,8 @@ v0.register_blueprint(auth_bp) v0.register_blueprint(emails_bp) v0.register_blueprint(me_bp) -v0.register_blueprint(home_bp) v0.register_blueprint(inbox_bp) v0.register_blueprint(posts_bp) -v0.register_blueprint(users_bp) v0.register_blueprint(chats_bp) v0.register_blueprint(search_bp) v0.register_blueprint(emojis_bp) diff --git a/rest_api/v0/auth.py b/pkg/legacy/rest_api/v0/auth.py similarity index 54% rename from rest_api/v0/auth.py rename to pkg/legacy/rest_api/v0/auth.py index 1b35b5b..1b1f2c5 100644 --- a/rest_api/v0/auth.py +++ b/pkg/legacy/rest_api/v0/auth.py @@ -1,4 +1,4 @@ -import re, os, requests, pyotp, secrets, time +import re, os, requests, time from pydantic import BaseModel from quart import Blueprint, request, abort, current_app as app from quart_schema import validate_request @@ -8,9 +8,11 @@ from hashlib import sha256 from threading import Thread -from database import db, rdb, blocked_ips, registration_blocked_ips +from database import rdb, blocked_ips, registration_blocked_ips from sessions import AccSession, EmailTicket -import security +from users import UserFlags, User +from accounts import EMAIL_REGEX, TOTP_REGEX, Account +import errors, security auth_bp = Blueprint("auth_bp", __name__, url_prefix="/auth") @@ -22,7 +24,7 @@ class AuthRequest(BaseModel): captcha: Optional[str] = Field(default="", max_length=2000) class RecoverAccountBody(BaseModel): - email: str = Field(min_length=1, max_length=255, pattern=security.EMAIL_REGEX) + email: str = Field(min_length=1, max_length=255, pattern=EMAIL_REGEX) captcha: Optional[str] = Field(default="", max_length=2000) @@ -40,39 +42,36 @@ async def login(data: AuthRequest): abort(429) security.ratelimit(f"login:i:{request.ip}", 50, 900) - # Get basic account details - account = db.usersv0.find_one({ - "email": data.username - } if "@" in data.username else { - "lower_username": data.username.lower() - }, projection={ - "_id": 1, - "flags": 1, - "pswd": 1, - "mfa_recovery_code": 1 - }) - if not account: + # Get user and account + try: + if "@" in data.username: + account = Account.get_by_email(data.username) + user = User.get_by_id(account.id) + else: + user = User.get_by_username(data.username) + account = user.account + except errors.UserNotFound or errors.AccountNotFound: abort(401) # Make sure account isn't deleted - if account["flags"] & security.UserFlags.DELETED: + if user.flags & UserFlags.DELETED: return {"error": True, "type": "accountDeleted"}, 401 # Make sure account isn't locked - if account["flags"] & security.UserFlags.LOCKED: + if user.flags & UserFlags.LOCKED: return {"error": True, "type": "accountLocked"}, 401 # Make sure account isn't ratelimited - if security.ratelimited(f"login:u:{account['_id']}"): + if security.ratelimited(f"login:u:{user.id}"): abort(429) # Legacy tokens (remove in the future at some point) if len(data.password) == 86: encoded_token = urlsafe_b64encode(sha256(data.password.encode()).digest()) - username = rdb.get(encoded_token) - if username and username.decode() == account["_id"]: + user_id = rdb.get(user_id) + if user_id and int(user_id.decode()): data.password = AccSession.create( - username.decode(), + int(user_id.decode()), request.ip, request.headers.get("User-Agent") ).token @@ -84,30 +83,30 @@ async def login(data: AuthRequest): session.refresh(request.ip, request.headers.get("User-Agent"), check_token=data.password) except: # no error capturing here, as it's probably just a password rather than a token, and we don't want to capture passwords # Check password - password_valid = security.check_password_hash(data.password, account["pswd"]) + password_valid = account.check_password(data.password) # Maybe they put their MFA credentials at the end of their password? - if (not password_valid) and db.authenticators.count_documents({"user": account["_id"]}, limit=1): + if (not password_valid) and len(account.mfa_methods): if (not data.mfa_recovery_code) and data.password.endswith(account["mfa_recovery_code"]): try: data.mfa_recovery_code = data.password[-10:] data.password = data.password[:-10] except: pass else: - password_valid = security.check_password_hash(data.password, account["pswd"]) + password_valid = account.check_password(data.password) elif not data.totp_code: try: data.totp_code = data.password[-6:] data.password = data.password[:-6] except: pass else: - if re.fullmatch(security.TOTP_REGEX, data.totp_code): - password_valid = security.check_password_hash(data.password, account["pswd"]) + if re.fullmatch(TOTP_REGEX, data.totp_code): + password_valid = account.check_password(data.password) # Abort if password is invalid if not password_valid: - security.ratelimit(f"login:u:{account['_id']}", 5, 60) - security.log_security_action("auth_fail", account["_id"], { + security.ratelimit(f"login:u:{account.id}", 5, 60) + await account.log_security_action("auth_fail", account.id, { "status": "invalid_password", "ip": request.ip, "user_agent": request.headers.get("User-Agent") @@ -115,51 +114,35 @@ async def login(data: AuthRequest): abort(401) # Check MFA - authenticators = list(db.authenticators.find({"user": account["_id"]})) - if len(authenticators) > 0: + if len(account.authenticators): if data.totp_code: - passed = False - for authenticator in authenticators: - if authenticator["type"] != "totp": - continue - if pyotp.TOTP(authenticator["totp_secret"]).verify(data.totp_code, valid_window=1): - passed = True - break - if not passed: - security.ratelimit(f"login:u:{account['_id']}", 5, 60) - security.log_security_action("auth_fail", account["_id"], { + if not account.check_totp_code(data.totp_code): + security.ratelimit(f"login:u:{account.id}", 5, 60) + await account.log_security_action("auth_fail", account.id, { "status": "invalid_totp_code", "ip": request.ip, "user_agent": request.headers.get("User-Agent") }) abort(401) elif data.mfa_recovery_code: - if data.mfa_recovery_code == account["mfa_recovery_code"]: - db.authenticators.delete_many({"user": account["_id"]}) - - new_recovery_code = secrets.token_hex(5) - db.usersv0.update_one({"_id": account["_id"]}, {"$set": { - "mfa_recovery_code": new_recovery_code - }}) - security.log_security_action("mfa_recovery_used", account["_id"], { - "old_recovery_code_hash": urlsafe_b64encode(sha256(data.mfa_recovery_code.encode()).digest()).decode(), - "new_recovery_code_hash": urlsafe_b64encode(sha256(new_recovery_code.encode()).digest()).decode(), - "ip": request.ip, - "user_agent": request.headers.get("User-Agent") - }) - else: - security.ratelimit(f"login:u:{account['_id']}", 5, 60) - security.log_security_action("auth_fail", account["_id"], { + if data.mfa_recovery_code != account.recovery_code: + security.ratelimit(f"login:u:{account.id}", 5, 60) + await account.log_security_action("auth_fail", account.id, { "status": "invalid_recovery_code", "ip": request.ip, "user_agent": request.headers.get("User-Agent") }) abort(401) + + account.reset_mfa() + await account.log_security_action("mfa_recovery_used", account.id, { + "old_recovery_code_hash": urlsafe_b64encode(sha256(data.mfa_recovery_code.encode()).digest()).decode(), + "new_recovery_code_hash": urlsafe_b64encode(sha256(account.recovery_code.encode()).digest()).decode(), + "ip": request.ip, + "user_agent": request.headers.get("User-Agent") + }) else: - mfa_methods = set() - for authenticator in authenticators: - mfa_methods.add(authenticator["type"]) - security.log_security_action("auth_fail", account["_id"], { + await account.log_security_action("auth_fail", account.id, { "status": "mfa_required", "ip": request.ip, "user_agent": request.headers.get("User-Agent") @@ -167,18 +150,18 @@ async def login(data: AuthRequest): return { "error": True, "type": "mfaRequired", - "mfa_methods": list(mfa_methods) + "mfa_methods": account.mfa_methods }, 401 # Create session - session = AccSession.create(account["_id"], request.ip, request.headers.get("User-Agent")) + session = AccSession.create(account.id, request.ip, request.headers.get("User-Agent")) # Return session and account details return { "error": False, "session": session.v0, "token": session.token, - "account": security.get_account(account['_id'], True) + "account": security.get_account(account.id, True) }, 200 @@ -189,52 +172,46 @@ async def register(data: AuthRequest): if not app.supporter.registration: return {"error": True, "type": "registrationDisabled"}, 403 - # Make sure IP isn't being ratelimited - if security.ratelimited(f"register:{request.ip}:f") or security.ratelimited(f"register:{request.ip}:s"): - abort(429) - - # Make sure password is between 8-72 characters - if len(data.password) < 8 or len(data.password) > 72: - abort(400) - - # Make sure username matches regex - if not re.fullmatch(security.USERNAME_REGEX, data.username): - abort(400) - # Make sure IP isn't blocked from creating new accounts if registration_blocked_ips.search_best(request.ip): - security.ratelimit(f"register:{request.ip}:f", 5, 30) return {"error": True, "type": "registrationBlocked"}, 403 - # Make sure username isn't taken - if security.account_exists(data.username, ignore_case=True): - security.ratelimit(f"register:{request.ip}:f", 5, 30) - return {"error": True, "type": "usernameExists"}, 409 - + # Make sure IP isn't being ratelimited + if security.ratelimited(f"register:{request.ip}:f") or security.ratelimited(f"register:{request.ip}:s"): + abort(429) + # Check captcha 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"]: + security.ratelimit(f"register:{request.ip}:f", 5, 30) return {"error": True, "type": "invalidCaptcha"}, 403 # Create account - security.create_account(data.username, data.password, request.ip) + try: + account, user = await User.create_account(data.username, data.password) + except errors.UsernameDisallowed or errors.PasswordDisallowed: + security.ratelimit(f"register:{request.ip}:f", 5, 30) + abort(400) + except errors.UsernameTaken: + security.ratelimit(f"register:{request.ip}:f", 5, 30) + abort(409) + else: + # Ratelimit + security.ratelimit(f"register:{request.ip}:s", 3, 900) - # Ratelimit - security.ratelimit(f"register:{request.ip}:s", 5, 900) - - # Create session - session = AccSession.create(data.username, request.ip, request.headers.get("User-Agent")) + # Create session + session = AccSession.create(data.username, request.ip, request.headers.get("User-Agent")) - # Return session and account details - return { - "error": False, - "session": session.v0, - "token": session.token, - "account": security.get_account(data.username, True) - }, 200 + # Return session and account details + return { + "error": False, + "session": session.v0, + "token": session.token, + "account": {"email": account.email, **user.v0, **user.settings} + }, 200 @auth_bp.post("/recover") @@ -243,7 +220,7 @@ async def recover_account(data: RecoverAccountBody): # Check ratelimits if security.ratelimited(f"recover:{request.ip}"): abort(429) - security.ratelimit(f"recover:{request.ip}", 3, 2700) + security.ratelimit(f"recover:{request.ip}", 3, 900) # Check captcha if os.getenv("CAPTCHA_SECRET") and not (hasattr(request, "bypass_captcha") and request.bypass_captcha): @@ -254,18 +231,18 @@ async def recover_account(data: RecoverAccountBody): return {"error": True, "type": "invalidCaptcha"}, 403 # Get account - account = db.usersv0.find_one({"email": data.email}, projection={"_id": 1, "email": 1, "flags": 1}) - if not account: + try: + account = Account.get_by_email(data.email) + except errors.AccountNotFound: pass + else: + # Create recovery email ticket + ticket = EmailTicket(account.email, account.id, "recover", expires_at=int(time.time())+1800) + + # Send email + txt_tmpl, html_tmpl = security.render_email_tmpl("recover", account.id, account.email, {"token": ticket.token}) + Thread( + target=security.send_email, + args=[security.EMAIL_SUBJECTS["recover"], account.id, account.email, txt_tmpl, html_tmpl] + ).start() + finally: return {"error": False}, 200 - - # Create recovery email ticket - ticket = EmailTicket(data.email, account["_id"], "recover", expires_at=int(time.time())+1800) - - # Send email - txt_tmpl, html_tmpl = security.render_email_tmpl("recover", account["_id"], account["email"], {"token": ticket.token}) - Thread( - target=security.send_email, - args=[security.EMAIL_SUBJECTS["recover"], account["_id"], account["email"], txt_tmpl, html_tmpl] - ).start() - - return {"error": False}, 200 diff --git a/pkg/legacy/rest_api/v0/chats.py b/pkg/legacy/rest_api/v0/chats.py new file mode 100644 index 0000000..a751e03 --- /dev/null +++ b/pkg/legacy/rest_api/v0/chats.py @@ -0,0 +1,366 @@ +from quart import Blueprint, current_app as app, request, abort +from quart_schema import validate_request +from pydantic import BaseModel, Field +from typing import Optional +import uuid, time + +import security +from database import db +from meowid import gen_id +from uploads import claim_file, delete_file +from utils import log + +chats_bp = Blueprint("chats_bp", __name__, url_prefix="/chats") + + +class GetPostsQueryArgs(BaseModel): + page: Optional[int] = Field(default=1, ge=1) + +class ChatBody(BaseModel): + nickname: str = Field(default=None, min_length=1, max_length=32) + icon: str = Field(default=None, max_length=24) + icon_color: str = Field(default=None, min_length=6, max_length=6) # hex code without the # + allow_pinning: bool = Field(default=None) + + class Config: + validate_assignment = True + str_strip_whitespace = True + + +@chats_bp.post("/") +@validate_request(ChatBody) +async def create_chat(data: ChatBody): + # Check authorization + if not request.user: + abort(401) + + # Check ratelimit + if security.ratelimited(f"create_chat:{request.user}"): + abort(429) + + # Ratelimit + security.ratelimit(f"create_chat:{request.user}", 5, 30) + + # Check restrictions + if security.is_restricted(request.user, security.Restrictions.NEW_CHATS): + return {"error": True, "type": "accountBanned"}, 403 + + # Make sure the requester isn't in too many chats + if db.chats.count_documents({"type": 0, "members": request.user}, limit=150) >= 150: + return {"error": True, "type": "tooManyChats"}, 403 + + # Claim icon + if data.icon: + try: + claim_file(data.icon, "icons") + except Exception as e: + log(f"Unable to claim icon: {e}") + return {"error": True, "type": "unableToClaimIcon"}, 500 + + # Create chat + if data.icon is None: + data.icon = "" + if data.icon_color is None: + data.icon_color = "000000" + if data.allow_pinning is None: + data.allow_pinning = False + chat = { + "_id": str(uuid.uuid4()), + "meowid": await gen_id(), + "type": 0, + "nickname": data.nickname, + "icon": data.icon, + "icon_color": data.icon_color, + "owner": request.user, + "members": [request.user], + "created": int(time.time()), + "last_active": int(time.time()), + "deleted": False, + "allow_pinning": data.allow_pinning + } + db.chats.insert_one(chat) + + # Add emotes + chat.update({ + "emojis": list(db.chat_emojis.find({ + "chat_id": chat["_id"] + }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})), + "stickers": list(db.chat_stickers.find({ + "chat_id": chat["_id"] + }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})) + }) + + + # Tell the requester the chat was created + app.cl.send_event("create_chat", chat, usernames=[request.user]) + + # Return chat + chat["error"] = False + return chat, 200 + + +@chats_bp.patch("/") +@validate_request(ChatBody) +async def update_chat(chat_id, data: ChatBody): + # Check authorization + if not request.user: + abort(401) + + # Check ratelimit + if security.ratelimited(f"update_chat:{request.user}"): + abort(429) + + # Ratelimit + security.ratelimit(f"update_chat:{request.user}", 5, 5) + + # Check restrictions + if security.is_restricted(request.user, security.Restrictions.EDITING_CHAT_DETAILS): + return {"error": True, "type": "accountBanned"}, 403 + + # Get chat + chat = db.chats.find_one({"_id": chat_id, "members": request.user, "deleted": False}) + if not chat: + abort(404) + + # Make sure requester is owner + if chat["owner"] != request.user: + abort(403) + + # Get updated values + updated_vals = {"_id": chat_id} + if data.nickname is not None and chat["nickname"] != data.nickname: + updated_vals["nickname"] = data.nickname + await app.supporter.create_post(chat_id, "Server", f"@{request.user} changed the nickname of the group chat to '{chat['nickname']}'.", chat_members=chat["members"]) + if data.icon is not None and chat["icon"] != data.icon: + # Claim icon (and delete old one) + if data.icon != "": + try: + updated_vals["icon"] = claim_file(data.icon, "icons")["id"] + except Exception as e: + log(f"Unable to claim icon: {e}") + return {"error": True, "type": "unableToClaimIcon"}, 500 + if chat["icon"]: + try: + delete_file(chat["icon"]) + except Exception as e: + log(f"Unable to delete icon: {e}") + await app.supporter.create_post(chat_id, "Server", f"@{request.user} changed the icon of the group chat.", chat_members=chat["members"]) + if data.icon_color is not None and chat["icon_color"] != data.icon_color: + updated_vals["icon_color"] = data.icon_color + if data.icon is None or chat["icon"] == data.icon: + await app.supporter.create_post(chat_id, "Server", f"@{request.user} changed the icon of the group chat.", chat_members=chat["members"]) + if data.allow_pinning is not None: + updated_vals["allow_pinning"] = data.allow_pinning + + # Update chat + db.chats.update_one({"_id": chat_id}, {"$set": updated_vals}) + + # Send update chat event + app.cl.send_event("update_chat", updated_vals, usernames=chat["members"]) + + # Return chat + chat.update({ + "error": False, + "emojis": list(db.chat_emojis.find({ + "chat_id": chat["_id"] + }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})), + "stickers": list(db.chat_stickers.find({ + "chat_id": chat["_id"] + }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})) + }) + return chat, 200 + + +@chats_bp.put("//members/") +async def add_chat_member(chat_id, username): + # Check authorization + if not request.user: + abort(401) + + # Check ratelimit + if security.ratelimited(f"update_chat:{request.user}"): + abort(429) + + # Ratelimit + security.ratelimit(f"update_chat:{request.user}", 5, 5) + + # Check restrictions + if security.is_restricted(request.user, security.Restrictions.NEW_CHATS): + return {"error": True, "type": "accountBanned"}, 403 + + # Get chat + chat = db.chats.find_one({"_id": chat_id, "members": request.user, "deleted": False}) + if not chat: + abort(404) + + # Make sure the chat isn't full + if chat["type"] == 1 or len(chat["members"]) >= 256: + return {"error": True, "type": "chatFull"}, 403 + + # Make sure the user isn't already in the chat + if username in chat["members"]: + return {"error": True, "type": "chatMemberAlreadyExists"}, 409 + + # Make sure requested user exists and isn't deleted + user = db.usersv0.find_one({"_id": username}, projection={"permissions": 1}) + if (not user) or (user["permissions"] is None): + abort(404) + + # Make sure requested user isn't blocked or is blocking client + if db.relationships.count_documents({"$or": [ + { + "_id": {"from": request.user, "to": username}, + "state": 2 + }, + { + "_id": {"from": username, "to": request.user}, + "state": 2 + } + ]}, limit=1) > 0: + abort(403) + + # Update chat + chat["members"].append(username) + db.chats.update_one({"_id": chat_id}, {"$addToSet": {"members": username}}) + + # Send create chat event + app.cl.send_event("create_chat", chat, usernames=[username]) + + # Send update chat event + app.cl.send_event("update_chat", { + "_id": chat_id, + "members": chat["members"] + }, usernames=chat["members"]) + + # Send inbox message to user + await app.supporter.create_post("inbox", username, f"You have been added to the group chat '{chat['nickname']}' by @{request.user}!") + + # Send in-chat notification + await app.supporter.create_post(chat_id, "Server", f"@{request.user} added @{username} to the group chat.", chat_members=chat["members"]) + + # Return chat + chat.update({ + "error": False, + "emojis": list(db.chat_emojis.find({ + "chat_id": chat["_id"] + }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})), + "stickers": list(db.chat_stickers.find({ + "chat_id": chat["_id"] + }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})) + }) + return chat, 200 + + +@chats_bp.delete("//members/") +async def remove_chat_member(chat_id, username): + # Check authorization + if not request.user: + abort(401) + + # Check ratelimit + if security.ratelimited(f"update_chat:{request.user}"): + abort(429) + + # Ratelimit + security.ratelimit(f"update_chat:{request.user}", 5, 5) + + # Get chat + chat = db.chats.find_one({ + "_id": chat_id, + "members": {"$all": [request.user, username]}, + "deleted": False + }) + if not chat: + abort(404) + + # Make sure requester is owner + if chat["owner"] != request.user: + abort(403) + + # Update chat + chat["members"].remove(username) + db.chats.update_one({"_id": chat_id}, {"$pull": {"members": username}}) + + # Send delete chat event to user + app.cl.send_event("delete_chat", {"chat_id": chat_id}, usernames=[username]) + + # Send update chat event + app.cl.send_event("update_chat", { + "_id": chat_id, + "members": chat["members"] + }, usernames=chat["members"]) + + # Send inbox message to user + await app.supporter.create_post("inbox", username, f"You have been removed from the group chat '{chat['nickname']}' by @{request.user}!") + + # Send in-chat notification + await app.supporter.create_post(chat_id, "Server", f"@{request.user} removed @{username} from the group chat.", chat_members=chat["members"]) + + # Return chat + chat.update({ + "error": False, + "emojis": list(db.chat_emojis.find({ + "chat_id": chat["_id"] + }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})), + "stickers": list(db.chat_stickers.find({ + "chat_id": chat["_id"] + }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})) + }) + return chat, 200 + + +@chats_bp.post("//members//transfer") +async def transfer_chat_ownership(chat_id, username): + # Check authorization + if not request.user: + abort(401) + + # Check ratelimit + if security.ratelimited(f"update_chat:{request.user}"): + abort(429) + + # Ratelimit + security.ratelimit(f"update_chat:{request.user}", 5, 5) + + # Get chat + chat = db.chats.find_one({ + "_id": chat_id, + "members": {"$all": [request.user, username]}, + "deleted": False + }) + if not chat: + abort(404) + + # Make sure requester is owner + if chat["owner"] != request.user: + abort(403) + + # Make sure requested user isn't already owner + if chat["owner"] == username: + chat["error"] = False + return chat, 200 + + # Update chat + chat["owner"] = username + db.chats.update_one({"_id": chat_id}, {"$set": {"owner": username}}) + + # Send update chat event + app.cl.send_event("update_chat", { + "_id": chat_id, + "owner": chat["owner"] + }, usernames=chat["members"]) + + # Send in-chat notification + await app.supporter.create_post(chat_id, "Server", f"@{request.user} transferred ownership of the group chat to @{username}.", chat_members=chat["members"]) + + # Return chat + chat.update({ + "error": False, + "emojis": list(db.chat_emojis.find({ + "chat_id": chat["_id"] + }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})), + "stickers": list(db.chat_stickers.find({ + "chat_id": chat["_id"] + }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})) + }) + return chat, 200 diff --git a/rest_api/v0/emails.py b/pkg/legacy/rest_api/v0/emails.py similarity index 100% rename from rest_api/v0/emails.py rename to pkg/legacy/rest_api/v0/emails.py diff --git a/rest_api/v0/emojis.py b/pkg/legacy/rest_api/v0/emojis.py similarity index 82% rename from rest_api/v0/emojis.py rename to pkg/legacy/rest_api/v0/emojis.py index da0bd97..fb60823 100644 --- a/rest_api/v0/emojis.py +++ b/pkg/legacy/rest_api/v0/emojis.py @@ -6,9 +6,9 @@ DEFAULT_EMOJIS = {} # {lang: [...]} -for filename in os.listdir("emojis"): +for filename in os.listdir("pkg/legacy/emojis"): if filename.endswith(".json"): - f = open(f"emojis/{filename}", "r") + f = open(f"pkg/legacy/emojis/{filename}", "r") DEFAULT_EMOJIS[filename.replace(".json", "")] = json.load(f) f.close() diff --git a/rest_api/v0/inbox.py b/pkg/legacy/rest_api/v0/inbox.py similarity index 100% rename from rest_api/v0/inbox.py rename to pkg/legacy/rest_api/v0/inbox.py diff --git a/rest_api/v0/me.py b/pkg/legacy/rest_api/v0/me.py similarity index 57% rename from rest_api/v0/me.py rename to pkg/legacy/rest_api/v0/me.py index 8e7b0e9..9a310cb 100644 --- a/rest_api/v0/me.py +++ b/pkg/legacy/rest_api/v0/me.py @@ -1,23 +1,19 @@ from quart import Blueprint, current_app as app, request, abort from quart_schema import validate_request, validate_querystring from pydantic import BaseModel, Field -from typing import Optional, List, Literal +from typing import Optional, List from copy import copy -from base64 import urlsafe_b64encode -from hashlib import sha256 from threading import Thread import pymongo import uuid import time -import pyotp -import qrcode, qrcode.image.svg import uuid -import secrets import os import requests import security from database import db, rdb, get_total_pages +from accounts import EMAIL_REGEX from uploads import claim_file, delete_file from sessions import AccSession, EmailTicket from utils import log @@ -34,7 +30,9 @@ class UpdateConfigBody(BaseModel): avatar: Optional[str] = Field(default=None, max_length=24) avatar_color: Optional[str] = Field(default=None, min_length=6, max_length=6) # hex code without the # quote: Optional[str] = Field(default=None, max_length=360) + unread_inbox: Optional[bool] = Field(default=None) + theme: Optional[str] = Field(default=None, min_length=1, max_length=256) mode: Optional[bool] = Field(default=None) layout: Optional[str] = Field(default=None, min_length=1, max_length=256) @@ -43,6 +41,7 @@ class UpdateConfigBody(BaseModel): bgm_song: Optional[int] = Field(default=None) debug: Optional[bool] = Field(default=None) hide_blocked_users: Optional[bool] = Field(default=None) + favorited_chats: Optional[List[str]] = Field(default=None) class Config: @@ -51,32 +50,12 @@ class Config: class UpdateEmailBody(BaseModel): password: str = Field(min_length=1, max_length=255) # change in API v1 - email: str = Field(max_length=255, pattern=security.EMAIL_REGEX) + email: str = Field(max_length=255, pattern=EMAIL_REGEX) captcha: Optional[str] = Field(default="", max_length=2000) class RemoveEmailBody(BaseModel): password: str = Field(min_length=1, max_length=255) # change in API v1 -class ChangePasswordBody(BaseModel): - old: str = Field(min_length=1, max_length=255) # change in API v1 - new: str = Field(min_length=8, max_length=72) - -class AddAuthenticatorBody(BaseModel): - password: str = Field(min_length=1, max_length=255) # change in API v1 - type: Literal["totp"] = Field() - nickname: str = Field(default="", max_length=32) - totp_secret: Optional[str] = Field(default=None, min_length=32, max_length=32) - totp_code: Optional[str] = Field(default=None, min_length=6, max_length=6) - -class UpdateAuthenticatorBody(BaseModel): - nickname: str = Field(default="", max_length=32) - -class RemoveAuthenticatorBody(BaseModel): - password: str = Field(min_length=1, max_length=255) # change in API v1 - -class ResetMFARecoveryCodeBody(BaseModel): - password: str = Field(min_length=1, max_length=255) # change in API v1 - class GetReportsQueryArgs(BaseModel): page: Optional[int] = Field(default=1, ge=1) @@ -193,24 +172,6 @@ async def update_config(data: UpdateConfigBody): return {"error": False}, 200 -@me_bp.get("/relationships") -async def get_relationships(): - # Check authorization - if not request.user: - abort(401) - - return { - "error": False, - "autoget": [{ - "username": r["_id"]["to"], - "state": r["state"], - "updated_at": r["updated_at"] - } for r in db.relationships.find({"_id.from": request.user})], - "page#": 1, - "pages": 1 - }, 200 - - @me_bp.patch("/email") @validate_request(UpdateEmailBody) async def update_email(data: UpdateEmailBody): @@ -298,223 +259,6 @@ async def remove_email(data: RemoveEmailBody): return {"error": False}, 200 -@me_bp.patch("/password") -@validate_request(ChangePasswordBody) -async def change_password(data: ChangePasswordBody): - # Check authorization - if not request.user: - abort(401) - - # Check ratelimit - if security.ratelimited(f"login:u:{request.user}"): - abort(429) - - # Check password - account = db.usersv0.find_one({"_id": request.user}, projection={"email": 1, "pswd": 1}) - if not security.check_password_hash(data.old, account["pswd"]): - security.ratelimit(f"login:u:{request.user}", 5, 60) - return {"error": True, "type": "invalidCredentials"}, 401 - - # Update password - new_hash = security.hash_password(data.new) - db.usersv0.update_one({"_id": request.user}, {"$set": {"pswd": new_hash}}) - - # Log event - security.log_security_action("password_changed", account["_id"], { - "method": "self", - "old_pswd_hash": account["pswd"], - "new_pswd_hash": new_hash, - "ip": request.ip, - "user_agent": request.headers.get("User-Agent") - }) - - return {"error": False}, 200 - - -@me_bp.get("/authenticators") -async def get_authenticators(): - return { - "error": False, - "autoget": list(db.authenticators.find({"user": request.user}, projection={ - "_id": 1, - "type": 1, - "nickname": 1, - "registered_at": 1, - })), - "page#": 1, - "pages": 1 - }, 200 - - -@me_bp.post("/authenticators") -@validate_request(AddAuthenticatorBody) -async def add_authenticator(data: AddAuthenticatorBody): - # Check authorization - if not request.user: - abort(401) - - # Validate - if data.type == "totp" and data.totp_secret and data.totp_code: - if not pyotp.TOTP(data.totp_secret).verify(data.totp_code, valid_window=1): - return {"error": True, "type": "invalidTOTPCode"}, 401 - else: - abort(400) - - # Check ratelimit - if security.ratelimited(f"login:u:{request.user}"): - abort(429) - - # Check password - account = db.usersv0.find_one({"_id": request.user}, projection={"email": 1, "pswd": 1, "mfa_recovery_code": 1}) - if not security.check_password_hash(data.password, account["pswd"]): - security.ratelimit(f"login:u:{request.user}", 5, 60) - return {"error": True, "type": "invalidCredentials"}, 401 - - # Register - authenticator = { - "_id": str(uuid.uuid4()), - "user": request.user, - "type": data.type, - "nickname": data.nickname, - "totp_secret": data.totp_secret, - "registered_at": int(time.time()) - } - db.authenticators.insert_one(authenticator) - - # Log action - security.log_security_action("mfa_added", account["_id"], { - "authenticator_id": authenticator["_id"], - "ip": request.ip, - "user_agent": request.headers.get("User-Agent") - }) - - # Return authenticator and MFA recovery code - del authenticator["user"] - del authenticator["totp_secret"] - return { - "error": False, - "authenticator": authenticator, - "mfa_recovery_code": account["mfa_recovery_code"] - } - - -@me_bp.patch("/authenticators/") -@validate_request(UpdateAuthenticatorBody) -async def update_authenticator(authenticator_id: str, data: UpdateAuthenticatorBody): - # Check authorization - if not request.user: - abort(401) - - # Get authenticator - authenticator = db.authenticators.find_one({ - "_id": authenticator_id, - "user": request.user - }, projection={ - "_id": 1, - "type": 1, - "nickname": 1, - "registered_at": 1, - }) - if not authenticator: - abort(404) - - # Update - updated = {} - if data.nickname: - updated["nickname"] = data.nickname - authenticator.update(updated) - db.authenticators.update_one({ - "_id": authenticator_id, - "user": request.user - }, {"$set": updated}) - - return {"error": False, **authenticator} - - -@me_bp.delete("/authenticators/") -@validate_request(RemoveAuthenticatorBody) -async def remove_authenticator(authenticator_id: str, data: RemoveAuthenticatorBody): - # Check authorization - if not request.user: - abort(401) - - # Check ratelimit - if security.ratelimited(f"login:u:{request.user}"): - abort(429) - - # Check password - account = db.usersv0.find_one({"_id": request.user}, projection={"email": 1, "pswd": 1}) - if not security.check_password_hash(data.password, account["pswd"]): - security.ratelimit(f"login:u:{request.user}", 5, 60) - return {"error": True, "type": "invalidCredentials"}, 401 - - # Unregister - result = db.authenticators.delete_one({ - "_id": authenticator_id, - "user": request.user - }) - if result.deleted_count < 1: - abort(404) - - # Log action - security.log_security_action("mfa_removed", account["_id"], { - "authenticator_id": authenticator_id, - "ip": request.ip, - "user_agent": request.headers.get("User-Agent") - }) - - return {"error": False} - - -@me_bp.get("/authenticators/totp-secret") -async def get_new_totp_secret(): - # Check authorization - if not request.user: - abort(401) - - # Create secret and provisioning URI - secret = pyotp.random_base32() - provisioning_uri = pyotp.TOTP(secret).provisioning_uri(name=request.user, issuer_name="Meower") - - # Create QR code - qr = qrcode.make(provisioning_uri, image_factory=qrcode.image.svg.SvgImage) - - return { - "error": False, - "secret": secret, - "provisioning_uri": provisioning_uri, - "qr_code_svg": qr.to_string(encoding='unicode') - } - - -@me_bp.post("/reset-mfa-recovery-code") -@validate_request(ResetMFARecoveryCodeBody) -async def reset_mfa_recovery_code(data: ResetMFARecoveryCodeBody): - # Check authorization - if not request.user: - abort(401) - - # Check password - account = db.usersv0.find_one({"_id": request.user}, projection={"pswd": 1, "mfa_recovery_code": 1}) - if not security.check_password_hash(data.password, account["pswd"]): - security.ratelimit(f"login:u:{request.user}", 5, 60) - return {"error": True, "type": "invalidCredentials"}, 401 - - # Reset MFA recovery code - new_recovery_code = secrets.token_hex(5) - db.usersv0.update_one({"_id": account["_id"]}, {"$set": { - "mfa_recovery_code": new_recovery_code - }}) - security.log_security_action("mfa_recovery_reset", account["_id"], { - "old_recovery_code_hash": urlsafe_b64encode(sha256(account["mfa_recovery_code"]).digest()).decode(), - "new_recovery_code_hash": urlsafe_b64encode(sha256(new_recovery_code.encode()).digest()).decode(), - "ip": request.ip, - "user_agent": request.headers.get("User-Agent") - }) - - return {"error": False, "mfa_recovery_code": new_recovery_code} - - @me_bp.delete("/tokens") async def delete_tokens(): # Check authorization diff --git a/pkg/legacy/rest_api/v0/posts.py b/pkg/legacy/rest_api/v0/posts.py new file mode 100644 index 0000000..5d1a527 --- /dev/null +++ b/pkg/legacy/rest_api/v0/posts.py @@ -0,0 +1,215 @@ +from quart import Blueprint, current_app as app, request, abort +from quart_schema import validate_querystring +from pydantic import BaseModel, Field +from typing import Optional +import pymongo, time, emoji + +import security +from database import db, get_total_pages + + +posts_bp = Blueprint("posts_bp", __name__, url_prefix="/posts") + + +class PagedQueryArgs(BaseModel): + page: Optional[int] = Field(default=1, ge=1) + + +@posts_bp.get("//reactions/") +@validate_querystring(PagedQueryArgs) +async def get_post_reactors(query_args: PagedQueryArgs, post_id: str, emoji_reaction: str): + # Get necessary post details and check access + post = db.posts.find_one({ + "_id": post_id, + "isDeleted": {"$ne": True} + }, projection={"_id": 1, "post_origin": 1, "u": 1}) + if not post: + abort(404) + elif post["post_origin"] != "home" and not request.user: + abort(404) + elif post["post_origin"] == "inbox" and post["u"] not in ["Server", request.user]: + abort(404) + elif post["post_origin"] not in ["home", "inbox"]: + if not db.chats.count_documents({ + "_id": post["post_origin"], + "members": request.user, + "deleted": False + }, limit=1): + abort(404) + + # Get and return reactors + query = {"_id.post_id": post_id, "_id.emoji": emoji_reaction} + return { + "error": False, + "autoget": [security.get_account(r["_id"]["user"]) for r in db.post_reactions.find( + query, + sort=[("time", pymongo.DESCENDING)], + skip=(query_args.page-1)*25, + limit=25 + )], + "page#": query_args.page, + "pages": (get_total_pages("post_reactions", query) if request.user else 1) + }, 200 + +@posts_bp.post("//reactions/") +async def add_post_reaction(post_id: str, emoji_reaction: str): + # Check authorization + if not request.user: + abort(401) + + # Ratelimit + if security.ratelimited(f"react:{request.user}"): + abort(429) + security.ratelimit(f"react:{request.user}", 5, 5) + + # Check if the emoji is only one emoji, with support for variants + if not (emoji.purely_emoji(emoji_reaction) and len(emoji.distinct_emoji_list(emoji_reaction)) == 1): + # Check if the emoji is a custom emoji + if not db.chat_emojis.count_documents({"_id": emoji_reaction}, limit=1): + abort(400) + + # Get necessary post details and check access + post = db.posts.find_one({ + "_id": post_id, + "isDeleted": {"$ne": True} + }, projection={ + "_id": 1, + "post_origin": 1, + "u": 1, + "reactions": 1 + }) + if not post: + abort(404) + elif post["post_origin"] == "inbox" and post["u"] not in ["Server", request.user]: + abort(404) + elif post["post_origin"] not in ["home", "inbox"]: + if not db.chats.count_documents({ + "_id": post["post_origin"], + "members": request.user, + "deleted": False + }, limit=1): + abort(404) + + # Make sure there's not too many reactions (50) + if len(post["reactions"]) >= 50: + return {"error": True, "type": "tooManyReactions"}, 403 + + # Add reaction + db.post_reactions.update_one({"_id": { + "post_id": post["_id"], + "emoji": emoji_reaction, + "user": request.user + }}, {"$set": {"time": int(time.time())}}, upsert=True) + + # Update post + existing_reaction = None + for reaction in post["reactions"]: + if reaction["emoji"] == emoji_reaction: + existing_reaction = reaction + break + if existing_reaction: + existing_reaction["count"] = db.post_reactions.count_documents({ + "_id.post_id": post["_id"], + "_id.emoji": reaction["emoji"] + }) + else: + post["reactions"].append({ + "emoji": emoji_reaction, + "count": 1 + }) + db.posts.update_one({"_id": post["_id"]}, {"$set": { + "reactions": post["reactions"] + }}) + + # Send event + app.cl.send_event("post_reaction_add", { + "chat_id": post["post_origin"], + "post_id": post["_id"], + "emoji": emoji_reaction, + "username": request.user + }) + + return {"error": False}, 200 + +@posts_bp.delete("//reactions//") +async def remove_post_reaction(post_id: str, emoji_reaction: str, username: str): + # Check authorization + if not request.user: + abort(401) + + # @me -> requester + if username == "@me": + username = request.user + + # Ratelimit + if security.ratelimited(f"react:{request.user}"): + abort(429) + security.ratelimit(f"react:{request.user}", 5, 5) + + # Make sure reaction exists + if not db.post_reactions.count_documents({"_id": { + "post_id": post_id, + "emoji": emoji_reaction, + "user": username + }}, limit=1): + abort(404) + + # Get necessary post details and check access + post = db.posts.find_one({ + "_id": post_id, + "isDeleted": {"$ne": True} + }, projection={ + "_id": 1, + "post_origin": 1, + "u": 1, + "reactions": 1 + }) + if not post: + abort(404) + elif post["post_origin"] == "inbox" and post["u"] not in ["Server", request.user]: + abort(404) + elif post["post_origin"] not in ["home", "inbox"]: + chat = db.chats.find_one({ + "_id": post["post_origin"], + "members": request.user, + "deleted": False + }, projection={"owner": 1}) + if not chat: + abort(404) + + # Make sure requester can remove the reaction + if request.user != username: + if (post["post_origin"] in ["home", "inbox"]) or (chat["owner"] != request.user): + abort(403) + + # Remove reaction + db.post_reactions.delete_one({"_id": { + "post_id": post["_id"], + "emoji": emoji_reaction, + "user": username + }}) + + # Update post + for reaction in post["reactions"]: + if reaction["emoji"] != emoji_reaction: + continue + reaction["count"] = db.post_reactions.count_documents({ + "_id.post_id": post["_id"], + "_id.emoji": reaction["emoji"] + }) + if not reaction["count"]: + post["reactions"].remove(reaction) + break + db.posts.update_one({"_id": post["_id"]}, {"$set": { + "reactions": post["reactions"] + }}) + + # Send event + app.cl.send_event("post_reaction_remove", { + "chat_id": post["post_origin"], + "post_id": post["_id"], + "emoji": emoji_reaction, + "username": username + }) + + return {"error": False}, 200 diff --git a/rest_api/v0/search.py b/pkg/legacy/rest_api/v0/search.py similarity index 100% rename from rest_api/v0/search.py rename to pkg/legacy/rest_api/v0/search.py diff --git a/security.py b/pkg/legacy/security.py similarity index 57% rename from security.py rename to pkg/legacy/security.py index 67cdac5..e7ae1ed 100644 --- a/security.py +++ b/pkg/legacy/security.py @@ -1,16 +1,14 @@ -from typing import Optional, Any, Literal +from typing import Optional from hashlib import sha256 -from base64 import urlsafe_b64encode, urlsafe_b64decode -from threading import Thread +from base64 import urlsafe_b64encode from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.utils import formataddr -import time, requests, os, uuid, secrets, bcrypt, hmac, msgpack, jinja2, smtplib, re +import time, requests, os, msgpack, jinja2, smtplib -from database import db, rdb, signing_keys +from database import db, rdb from utils import log from uploads import clear_files -import errors """ Meower Security Module @@ -28,9 +26,6 @@ for key in SENSITIVE_ACCOUNT_FIELDS: SENSITIVE_ACCOUNT_FIELDS_DB_PROJECTION[key] = 0 -SYSTEM_USER_USERNAMES = {"server", "deleted", "meower", "admin", "username"} -SYSTEM_USER = {} - DEFAULT_USER_SETTINGS = { "unread_inbox": True, "theme": "orange", @@ -45,21 +40,6 @@ "favorited_chats": [] } - -USERNAME_REGEX = "[a-zA-Z0-9-_]{1,20}" -# I hate this. But, thanks https://stackoverflow.com/a/201378 -EMAIL_REGEX = r"""(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])""" -TOTP_REGEX = "[0-9]{6}" -BCRYPT_SALT_ROUNDS = 14 -TOKEN_BYTES = 64 - - -TOKEN_TYPES = Literal[ - "acc", # account authorization - "email", # email actions (such as email verification or account recovery) -] - - EMAIL_SUBJECTS = { "verify": "Verify your email address", "recover": "Reset your password", @@ -68,48 +48,10 @@ } -email_file_loader = jinja2.FileSystemLoader("email_templates") +email_file_loader = jinja2.FileSystemLoader("pkg/legacy/email_templates") email_env = jinja2.Environment(loader=email_file_loader) -class UserFlags: - SYSTEM = 1 - DELETED = 2 - PROTECTED = 4 - POST_RATELIMIT_BYPASS = 8 - REQUIRE_EMAIL = 16 # not used yet - LOCKED = 32 - - -class AdminPermissions: - SYSADMIN = 1 - - VIEW_REPORTS = 2 - EDIT_REPORTS = 4 - - VIEW_NOTES = 8 - EDIT_NOTES = 16 - - VIEW_POSTS = 32 - DELETE_POSTS = 64 - - VIEW_ALTS = 128 - SEND_ALERTS = 256 - KICK_USERS = 512 - CLEAR_PROFILE_DETAILS = 1024 - VIEW_BAN_STATES = 2048 - EDIT_BAN_STATES = 4096 - DELETE_USERS = 8192 - - VIEW_IPS = 16384 - BLOCK_IPS = 32768 - - VIEW_CHATS = 65536 - EDIT_CHATS = 131072 - - SEND_ANNOUNCEMENTS = 262144 - - class Restrictions: HOME_POSTS = 1 CHAT_POSTS = 2 @@ -145,71 +87,6 @@ def clear_ratelimit(bucket_id: str): rdb.delete(f"rtl:{bucket_id}") -def account_exists(username, ignore_case=False): - if not isinstance(username, str): - log(f"Error on account_exists: Expected str for username, got {type(username)}") - return False - - if (ignore_case or username == username.lower()) and username.lower() in SYSTEM_USER_USERNAMES: - return True - - query = ({"lower_username": username.lower()} if ignore_case else {"_id": username}) - return (db.usersv0.count_documents(query, limit=1) > 0) - - -def create_account(username: str, password: str, ip: str): - # Create user - db.usersv0.insert_one({ - "_id": username, - "lower_username": username.lower(), - "uuid": str(uuid.uuid4()), - "created": int(time.time()), - "pfp_data": 1, - "avatar": "", - "avatar_color": "000000", - "quote": "", - "email": "", - "normalized_email_hash": "", - "pswd": hash_password(password), - "mfa_recovery_code": secrets.token_hex(5), - "flags": 0, - "permissions": 0, - "ban": { - "state": "none", - "restrictions": 0, - "expires": 0, - "reason": "" - }, - "last_seen": int(time.time()), - "delete_after": None - }) - db.user_settings.insert_one({"_id": username}) - - # Send welcome message - rdb.publish("admin", msgpack.packb({ - "op": "alert_user", - "user": username, - "content": "Welcome to Meower! We welcome you with open arms! You can get started by making friends in the global chat or home, or by searching for people and adding them to a group chat. We hope you have fun!" - })) - - # Automatically report if VPN is detected - if get_ip_info(ip)["vpn"]: - db.reports.insert_one({ - "_id": str(uuid.uuid4()), - "type": "user", - "content_id": username, - "status": "pending", - "escalated": False, - "reports": [{ - "user": "Server", - "ip": ip, - "reason": f"User registered while using a VPN ({ip}).", - "comment": "", - "time": int(time()) - }] - }) - - def get_account(username, include_config=False): # Check datatype if not isinstance(username, str): @@ -271,36 +148,6 @@ def get_account(username, include_config=False): return account -def create_token(ttype: TOKEN_TYPES, claims: Any) -> str: - # Encode claims - encoded_claims = msgpack.packb(claims) - - # Sign encoded claims - signature = hmac.digest(signing_keys[ttype], encoded_claims, digest=sha256) - - # Construct token - token = b".".join([urlsafe_b64encode(encoded_claims), urlsafe_b64encode(signature)]) - - return token.decode() - - -def extract_token(token: str, expected_type: TOKEN_TYPES) -> Optional[Any]: - # Extract data and signature - encoded_claims, signature = token.split(".") - encoded_claims = urlsafe_b64decode(encoded_claims) - signature = urlsafe_b64decode(signature) - - # Check signature - expected_signature = hmac.digest(signing_keys[expected_type], encoded_claims, digest=sha256) - if not hmac.compare_digest(signature, expected_signature): - raise errors.InvalidTokenSignature - - # Decode claims - claims = msgpack.unpackb(encoded_claims) - - return claims - - def update_settings(username, newdata): # Check datatype if not isinstance(username, str): @@ -349,25 +196,6 @@ def update_settings(username, newdata): return True -def get_permissions(username): - if not isinstance(username, str): - log(f"Error on get_permissions: Expected str for username, got {type(username)}") - return 0 - - account = db.usersv0.find_one({"lower_username": username.lower()}, projection={"permissions": 1}) - if account: - return account["permissions"] - else: - return 0 - - -def has_permission(user_permissions, permission): - if ((user_permissions & AdminPermissions.SYSADMIN) == AdminPermissions.SYSADMIN): - return True - else: - return ((user_permissions & permission) == permission) - - def is_restricted(username, restriction): # Check datatypes if not isinstance(username, str): @@ -515,72 +343,6 @@ def get_ip_info(ip_address): "vpn": False } - -def log_security_action(action_type: str, user: str, data: dict): - db.security_log.insert_one({ - "_id": str(uuid.uuid4()), - "type": action_type, - "user": user, - "time": int(time.time()), - "data": data - }) - - if action_type in { - "email_changed", - "password_changed", - "mfa_added", - "mfa_removed", - "mfa_recovery_reset", - "mfa_recovery_used", - "locked" - }: - tmpl_name = "locked" if action_type == "locked" else "security_alert" - platform_name = os.environ["EMAIL_PLATFORM_NAME"] - - account = db.usersv0.find_one({"_id": user}, projection={"_id": 1, "email": 1}) - - txt_tmpl, html_tmpl = render_email_tmpl(tmpl_name, account["_id"], account.get("email", ""), { - "msg": { - "email_changed": f"The email address on your {platform_name} account has been changed.", - "password_changed": f"The password on your {platform_name} account has been changed.", - "mfa_added": f"A multi-factor authenticator has been added to your {platform_name} account.", - "mfa_removed": f"A multi-factor authenticator has been removed from your {platform_name} account.", - "mfa_recovery_reset": f"The multi-factor authentication recovery code on your {platform_name} account has been reset.", - "mfa_recovery_used": f"Your multi-factor authentication recovery code has been used to reset multi-factor authentication on your {platform_name} account." - }[action_type] if action_type != "locked" else None, - "token": create_token("email", [ # this doesn't use EmailTicket in sessions.py because it'd be a recursive import - account["email"], - account["_id"], - "lockdown", - int(time.time())+86400 - ]) if account.get("email") and action_type != "locked" else None - }) - - # Email - if account.get('email'): - Thread( - target=send_email, - args=[EMAIL_SUBJECTS[tmpl_name], account["_id"], account["email"], txt_tmpl, html_tmpl] - ).start() - - # Inbox - rdb.publish("admin", msgpack.packb({ - "op": "alert_user", - "user": account["_id"], - "content": txt_tmpl - })) - - -def add_audit_log(action_type, mod_username, mod_ip, data): - db.audit_log.insert_one({ - "_id": str(uuid.uuid4()), - "type": action_type, - "mod_username": mod_username, - "mod_ip": mod_ip, - "time": int(time.time()), - "data": data - }) - def background_tasks_loop(): while True: @@ -627,28 +389,6 @@ def background_tasks_loop(): log("Finished background tasks!") -def hash_password(password: str) -> str: - return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=BCRYPT_SALT_ROUNDS)).decode() - - -def check_password_hash(password: str, hashed_password: str) -> bool: - return bcrypt.checkpw(password.encode(), hashed_password.encode()) - - -def get_normalized_email_hash(address: str) -> str: - """ - Get a hash of an email address with aliases and dots stripped. - This is to allow using address aliases, but to still detect ban evasion. - Also, Gmail ignores dots in addresses. Thanks Google. - """ - - identifier, domain = address.split("@") - identifier = re.split(r'\+|\%', identifier)[0] - identifier = identifier.replace(".", "") - - return urlsafe_b64encode(sha256(f"{identifier}@{domain}".encode()).digest()).decode() - - def render_email_tmpl(template: str, to_name: str, to_address: str, data: Optional[dict[str, str]] = {}) -> tuple[str, str]: data.update({ "subject": EMAIL_SUBJECTS[template], diff --git a/sessions.py b/pkg/legacy/sessions.py similarity index 69% rename from sessions.py rename to pkg/legacy/sessions.py index a5e9b5d..982c400 100644 --- a/sessions.py +++ b/pkg/legacy/sessions.py @@ -1,10 +1,18 @@ -from typing import Optional, TypedDict, Literal -import uuid, time, msgpack, pymongo +from typing import Optional, TypedDict, Literal, Any +from base64 import urlsafe_b64encode, urlsafe_b64decode +from hashlib import sha256 +import uuid, time, msgpack, pymongo, hmac -from database import db, rdb +from database import db, rdb, signing_keys import security, errors +TOKEN_TYPES = Literal[ + "acc", # account authorization + "email", # email actions (such as email verification or account recovery) +] + + class AccSessionDB(TypedDict): _id: str user: str @@ -48,11 +56,11 @@ def create(cls: "AccSession", user: str, ip: str, user_agent: str) -> "AccSessio } db.acc_sessions.insert_one(data) - security.log_security_action("session_create", user, { - "session_id": data["_id"], - "ip": ip, - "user_agent": user_agent - }) + #security.log_security_action("session_create", user, { + # "session_id": data["_id"], + # "ip": ip, + # "user_agent": user_agent + #}) return cls(data) @@ -66,14 +74,14 @@ def get_by_id(cls: "AccSession", session_id: str) -> "AccSession": @classmethod def get_by_token(cls: "AccSession", token: str) -> "AccSession": - session_id, _, expires_at = security.extract_token(token, "acc") + session_id, _, expires_at = extract_token(token, "acc") if expires_at < int(time.time()): raise errors.AccSessionTokenExpired return cls.get_by_id(session_id) @classmethod def get_username_by_token(cls: "AccSession", token: str) -> str: - session_id, _, expires_at = security.extract_token(token, "acc") + session_id, _, expires_at = extract_token(token, "acc") if expires_at < int(time.time()): raise errors.AccSessionTokenExpired username = rdb.get(session_id) @@ -101,7 +109,7 @@ def id(self) -> str: @property def token(self) -> str: - return security.create_token("acc", [ + return create_token("acc", [ self._db["_id"], self._db["refreshed_at"], self._db["refreshed_at"]+(86400*21) # expire token after 3 weeks @@ -136,11 +144,11 @@ def refresh(self, ip: str, user_agent: str, check_token: Optional[str] = None): }) db.acc_sessions.update_one({"_id": self._db["_id"]}, {"$set": self._db}) - security.log_security_action("session_refresh", self._db["user"], { - "session_id": self._db["_id"], - "ip": ip, - "user_agent": user_agent - }) + #security.log_security_action("session_refresh", self._db["user"], { + # "session_id": self._db["_id"], + # "ip": ip, + # "user_agent": user_agent + #}) def revoke(self): db.acc_sessions.delete_one({"_id": self._db["_id"]}) @@ -151,9 +159,9 @@ def revoke(self): "sid": self._db["_id"] })) - security.log_security_action("session_revoke", self._db["user"], { - "session_id": self._db["_id"] - }) + #security.log_security_action("session_revoke", self._db["user"], { + # "session_id": self._db["_id"] + #}) class EmailTicket: @@ -174,13 +182,43 @@ def __init__( @classmethod def get_by_token(cls: "EmailTicket", token: str) -> "EmailTicket": - return cls(*security.extract_token(token, "email")) + return cls(*extract_token(token, "email")) @property def token(self) -> str: - return security.create_token("email", [ + return create_token("email", [ self.email_address, self.username, self.action, self.expires_at ]) + + +def create_token(ttype: TOKEN_TYPES, claims: Any) -> str: + # Encode claims + encoded_claims = msgpack.packb(claims) + + # Sign encoded claims + signature = hmac.digest(signing_keys[ttype], encoded_claims, digest=sha256) + + # Construct token + token = b".".join([urlsafe_b64encode(encoded_claims), urlsafe_b64encode(signature)]) + + return token.decode() + + +def extract_token(token: str, expected_type: TOKEN_TYPES) -> Optional[Any]: + # Extract data and signature + encoded_claims, signature = token.split(".") + encoded_claims = urlsafe_b64decode(encoded_claims) + signature = urlsafe_b64decode(signature) + + # Check signature + expected_signature = hmac.digest(signing_keys[expected_type], encoded_claims, digest=sha256) + if not hmac.compare_digest(signature, expected_signature): + raise errors.InvalidTokenSignature + + # Decode claims + claims = msgpack.unpackb(encoded_claims) + + return claims diff --git a/supporter.py b/pkg/legacy/supporter.py similarity index 84% rename from supporter.py rename to pkg/legacy/supporter.py index d79d713..d9203c7 100644 --- a/supporter.py +++ b/pkg/legacy/supporter.py @@ -4,6 +4,7 @@ from cloudlink import CloudlinkServer from database import db, rdb +from meowid import gen_id from uploads import FileDetails """ @@ -14,6 +15,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 @@ -27,39 +30,7 @@ def __init__(self, cl: CloudlinkServer): # Start admin pub/sub listener Thread(target=self.listen_for_admin_pubsub, daemon=True).start() - def get_chats(self, username: str) -> list[dict[str, Any]]: - # Get active DMs and favorited chats - user_settings = db.user_settings.find_one({"_id": username}, projection={ - "active_dms": 1, - "favorited_chats": 1 - }) - if not user_settings: - user_settings = { - "active_dms": [], - "favorited_chats": [] - } - if "active_dms" not in user_settings: - user_settings["active_dms"] = [] - if "favorited_chats" not in user_settings: - user_settings["favorited_chats"] = [] - - # Get and return chats - return list(db.chats.find({"$or": [ - { # DMs - "_id": { - "$in": user_settings["active_dms"] + user_settings["favorited_chats"] - }, - "members": username, - "deleted": False - }, - { # group chats - "members": username, - "type": 0, - "deleted": False - } - ]})) - - def create_post( + async def create_post( self, origin: str, author: str, @@ -84,6 +55,7 @@ def create_post( # Construct post object post = { "_id": post_id, + "meowid": await gen_id(), "post_origin": origin, "u": author, "t": {"e": int(time.time())}, @@ -105,12 +77,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": @@ -136,7 +112,8 @@ def listen_for_admin_pubsub(self): continue asyncio.run(c.kick()) case "alert_user": - self.create_post("inbox", msg["user"], msg["content"]) + pass + #self.create_post("inbox", msg["user"], msg["content"]) case "ban_user": # Get user details username = msg.pop("user") @@ -197,7 +174,8 @@ def parse_posts_v0( "flags": 1, "pfp_data": 1, "avatar": 1, - "avatar_color": 1 + "avatar_color": 1, + "meowid": 1 })}) # Replies @@ -243,3 +221,4 @@ def parse_posts_v0( }) return posts + diff --git a/uploads.py b/pkg/legacy/uploads.py similarity index 100% rename from uploads.py rename to pkg/legacy/uploads.py diff --git a/pkg/legacy/users.py b/pkg/legacy/users.py new file mode 100644 index 0000000..1b904c1 --- /dev/null +++ b/pkg/legacy/users.py @@ -0,0 +1,211 @@ +from typing import Optional, TypedDict +import pymongo.collation, re, msgpack + +from database import db, rdb +from meowid import gen_id, extract_id +from accounts import Account +import errors + + +USERNAME_REGEX = "[a-zA-Z0-9-_]{1,20}" +SYSTEM_USER_USERNAMES = {"server", "deleted", "meower", "admin", "username"} +MIN_USER_PROJECTION = { + "permissions": 0, + "quote": 0, + "last_seen_at": 0 +} + + +class UserFlags: + SYSTEM = 1 + DELETED = 2 + PROTECTED = 4 + POST_RATELIMIT_BYPASS = 8 + REQUIRE_EMAIL = 16 # not used yet + LOCKED = 32 + + +class AdminPermissions: + SYSADMIN = 1 + + VIEW_REPORTS = 2 + EDIT_REPORTS = 4 + + VIEW_NOTES = 8 + EDIT_NOTES = 16 + + VIEW_POSTS = 32 + DELETE_POSTS = 64 + + VIEW_ALTS = 128 + SEND_ALERTS = 256 + KICK_USERS = 512 + CLEAR_PROFILE_DETAILS = 1024 + VIEW_BAN_STATES = 2048 + EDIT_BAN_STATES = 4096 + DELETE_USERS = 8192 + + VIEW_IPS = 16384 + BLOCK_IPS = 32768 + + VIEW_CHATS = 65536 + EDIT_CHATS = 131072 + + SEND_ANNOUNCEMENTS = 262144 + + +class UserDB(TypedDict): + _id: int + username: str + redirect_to: Optional[int] + + flags: Optional[int] + permissions: Optional[int] + + legacy_icon: Optional[int] + icon: Optional[str] + color: Optional[str] + quote: Optional[str] + + settings: Optional[bytes] + + last_seen_at: Optional[int] + + +class UserV0(TypedDict): + _id: str # username + uuid: str # MeowID + created: Optional[int] # creation timestamp + + pfp_data: int # legacy icon + avatar: str # icon + avatar_color: str # color + quote: Optional[str] + + flags: Optional[int] + permissions: Optional[int] + banned: Optional[bool] + + last_seen: Optional[int] + + +class User: + def __init__(self, data: UserDB): + self.id = data["_id"] + self.username = data["username"] + + self.flags = data.get("flags", 0) + self.permissions = data.get("permissions", 0) + + self.legacy_icon = data.get("legacy_icon", 1) + self.icon = data.get("icon", "") + self.color = data.get("color", "000000") + self.quote = data.get("quote", "") + + if data.get("settings"): + self.settings = msgpack.unpackb(data["settings"]) + else: + self.settings = {} + + self.last_seen_at = data.get("last_seen_at") + + @classmethod + async def create_account(cls: "User", username: str, password: str) -> tuple[Account, "User"]: + # Check username + if not re.fullmatch(USERNAME_REGEX, username): + raise errors.UsernameDisallowed + if cls.username_taken(username): + raise errors.UsernameTaken + + # Make sure password meets requirements + if len(password) < 8 or len(password) > 72: + raise errors.PasswordDisallowed + + # Generate user ID + user_id = await gen_id() + + # Create user + data: UserDB = { + "_id": user_id, + "username": username + } + db.users.insert_one(data) + + # Create account + account = Account.create(user_id, password) + + # Send welcome message + rdb.publish("admin", msgpack.packb({ + "op": "alert_user", + "user": user_id, + "content": "Welcome to Meower! We welcome you with open arms! You can get started by making friends in the global chat or home, or by searching for people and adding them to a group chat. We hope you have fun!" + })) + + return account, cls(data) + + @classmethod + def get_by_id(cls: "User", user_id: int, min: bool = False) -> "User": + data: Optional[UserDB] = db.users.find_one({"_id": user_id}, projection=MIN_USER_PROJECTION if min else None) + if not data: + raise errors.UserNotFound + return cls(data) + + @classmethod + def get_by_username(cls: "User", username: str, allow_redirects: bool = True) -> "User": + data: Optional[UserDB] = db.users.find_one({ + "username": username + }, collation=pymongo.collation.Collation("en_US", False)) + if not data: + raise errors.UserNotFound + + redirect_to = data.get("redirect_to") + if redirect_to and allow_redirects: + return cls.get_by_id(redirect_to) + + return cls(data) + + @staticmethod + def username_taken(username: str) -> bool: + if username.lower() in SYSTEM_USER_USERNAMES: + return True + return db.users.count_documents({ + "username": username + }, limit=1, collation=pymongo.collation.Collation("en_US", False)) > 0 + + @property + def v0(self) -> UserV0: + created, _, _ = extract_id(self.id) + + banned = False + + return { + **self.v0_min, + "created": created, + "quote": self.quote, + "permissions": self.permissions, + "banned": banned, + "last_seen": self.last_seen_at + } + + @property + def v0_min(self) -> UserV0: + return { + "_id": self.username, + "uuid": str(self.id), + + "pfp_data": self.legacy_icon, + "avatar": self.icon, + "avatar_color": self.color, + + "flags": self.flags + } + + @property + def account(self) -> Account: + return Account.get_by_id(self.id) + + def has_permission(self, permission: int) -> bool: + if ((self.permissions & AdminPermissions.SYSADMIN) == AdminPermissions.SYSADMIN): + return True + else: + return ((self.permissions & permission) == permission) diff --git a/utils.py b/pkg/legacy/utils.py similarity index 100% rename from utils.py rename to pkg/legacy/utils.py diff --git a/pkg/meowid/meowid.go b/pkg/meowid/meowid.go new file mode 100644 index 0000000..6c9b683 --- /dev/null +++ b/pkg/meowid/meowid.go @@ -0,0 +1,94 @@ +package meowid + +import ( + "math" + "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(nodeId string) error { + var err error + NodeId, err = strconv.Atoi(nodeId) + return 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/networks/block.go b/pkg/networks/block.go new file mode 100644 index 0000000..b36efe8 --- /dev/null +++ b/pkg/networks/block.go @@ -0,0 +1,64 @@ +package networks + +import ( + "context" + "net" + + "github.com/meower-media/server/pkg/db" + "github.com/meower-media/server/pkg/meowid" + "github.com/meower-media/server/pkg/rdb" + "github.com/vmihailenco/msgpack/v5" + "github.com/yl2chen/cidranger" +) + +var ( + OpCreateBlock byte = 0x1 + OpDeleteBlock byte = 0x2 +) + +var ranger = cidranger.NewPCTrieRanger() + +type BlockEntry struct { + Id meowid.MeowID `bson:"_id"` + Address string `bson:"address"` + ExpiresAt int64 `bson:"expires_at"` +} + +func (b BlockEntry) Network() net.IPNet { + _, net, _ := net.ParseCIDR(b.Address) + return *net +} + +func CreateBlock(address string, expiresAt int64) (BlockEntry, error) { + entry := BlockEntry{ + Id: meowid.GenId(), + Address: address, + ExpiresAt: expiresAt, + } + + if err := ranger.Insert(entry); err != nil { + return entry, err + } + + // Store in database + if _, err := db.Netblock.InsertOne(context.TODO(), entry); err != nil { + return entry, err + } + + // Tell other instances about the block + marshaledEntry, err := msgpack.Marshal(entry) + if err != nil { + return entry, err + } + marshaledEntry = append([]byte{OpCreateBlock}, marshaledEntry...) + err = rdb.Client.Publish(context.TODO(), "firewall", marshaledEntry).Err() + if err != nil { + return entry, err + } + + return entry, nil +} + +func IsBlocked(address string) (bool, error) { + return ranger.Contains(net.ParseIP(address)) +} 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/errors.go b/pkg/posts/errors.go new file mode 100644 index 0000000..0e6f21d --- /dev/null +++ b/pkg/posts/errors.go @@ -0,0 +1,9 @@ +package posts + +import "errors" + +var ( + ErrPostNotFound = errors.New("post not found") + ErrAttachmentNotFound = errors.New("attachment not found") + ErrReactionAlreadyExists = errors.New("reaction already exists") +) diff --git a/pkg/posts/events.go b/pkg/posts/events.go new file mode 100644 index 0000000..b498920 --- /dev/null +++ b/pkg/posts/events.go @@ -0,0 +1,108 @@ +package posts + +import ( + "context" + "fmt" + + "github.com/meower-media/server/pkg/meowid" + "github.com/meower-media/server/pkg/rdb" + "github.com/meower-media/server/pkg/utils" + "github.com/vmihailenco/msgpack/v5" +) + +type CreatePostEvent struct { + Post *Post `msgpack:"post"` + Attachments []Attachment `msgpack:"attachments"` + Nonce string `msgpack:"nonce,omitempty"` +} + +type UpdatePostEvent struct { + PostId meowid.MeowID `msgpack:"post"` + Content *string `msgpack:"content,omitempty"` + AttachmentIds *[]string `msgpack:"attachments,omitempty"` + Pinned *bool `msgpack:"pinned,omitempty"` + LastEditedAt *int64 `msgpack:"last_edited,omitempty"` +} + +type BulkDeletePostsEvent struct { + StartId meowid.MeowID `msgpack:"start"` + EndId meowid.MeowID `msgpack:"end"` + FilterPostIds *[]meowid.MeowID `msgpack:"posts"` + FilterUserIds *[]meowid.MeowID `msgpack:"users"` +} + +func EmitCreatePostEvent(post *Post, attachments []Attachment, nonce string) error { + // Marshal packet + marshaledPacket, err := msgpack.Marshal(&CreatePostEvent{ + Post: post, + Attachments: attachments, + Nonce: nonce, + }) + if err != nil { + return err + } + marshaledPacket = append(marshaledPacket, utils.EvOpCreatePost) + + // Send packet + return rdb.Client.Publish(context.TODO(), fmt.Sprint("c", post.ChatId), marshaledPacket).Err() +} + +func EmitUpdatePostEvent( + chatId meowid.MeowID, + postId meowid.MeowID, + content *string, + attachmentIds *[]string, + pinned *bool, + lastEditedAt *int64, +) error { + // Marshal packet + marshaledPacket, err := msgpack.Marshal(&UpdatePostEvent{ + PostId: postId, + Content: content, + AttachmentIds: attachmentIds, + Pinned: pinned, + LastEditedAt: lastEditedAt, + }) + if err != nil { + return err + } + marshaledPacket = append(marshaledPacket, utils.EvOpUpdatePost) + + // Send packet + return rdb.Client.Publish(context.TODO(), fmt.Sprint("c", chatId), marshaledPacket).Err() +} + +func EmitDeletePostEvent(chatId meowid.MeowID, postId meowid.MeowID) error { + // Marshal packet + marshaledPacket, err := msgpack.Marshal(&postId) + if err != nil { + return err + } + marshaledPacket = append(marshaledPacket, utils.EvOpDeletePost) + + // Send packet + return rdb.Client.Publish(context.TODO(), fmt.Sprint("c", chatId), marshaledPacket).Err() +} + +func EmitBulkDeletePostsEvent( + chatId meowid.MeowID, + startId meowid.MeowID, + endId meowid.MeowID, + filterPostIds *[]meowid.MeowID, + filterUserIds *[]meowid.MeowID, +) error { + // Marshal packet + marshaledPacket, err := msgpack.Marshal(&BulkDeletePostsEvent{ + StartId: startId, + EndId: endId, + FilterPostIds: filterPostIds, + FilterUserIds: filterUserIds, + }) + if err != nil { + return err + } + marshaledPacket = append(marshaledPacket, utils.EvOpBulkDeletePosts) + + // Send packet + return rdb.Client.Publish(context.TODO(), fmt.Sprint("c", chatId), marshaledPacket).Err() +} diff --git a/pkg/posts/post.go b/pkg/posts/post.go new file mode 100644 index 0000000..6d75d49 --- /dev/null +++ b/pkg/posts/post.go @@ -0,0 +1,329 @@ +package posts + +import ( + "context" + "log" + "regexp" + "strconv" + "time" + + "github.com/meower-media/server/pkg/db" + "github.com/meower-media/server/pkg/meowid" + "github.com/meower-media/server/pkg/structs" + "github.com/meower-media/server/pkg/users" + "github.com/meower-media/server/pkg/utils" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var customEmojiRegex = regexp.MustCompile(`<:([a-zA-Z0-9]{24})>`) + +type Post struct { + Id meowid.MeowID `bson:"_id" msgpack:"id"` + ChatId meowid.MeowID `bson:"chat" msgpack:"chat"` // 0: home, 1: livechat + AuthorId meowid.MeowID `bson:"author" msgpack:"author"` + ReplyToPostIds []meowid.MeowID `bson:"reply_to" msgpack:"reply_to"` + Content string `bson:"content,omitempty" msgpack:"content,omitempty"` + StickerIds []string `bson:"stickers" msgpack:"stickers"` + AttachmentIds []string `bson:"attachments" msgpack:"attachments"` + ReactionIndexes []ReactionIndex `bson:"reactions" msgpack:"reactions"` + LastEditedAt int64 `bson:"last_edited,omitempty" msgpack:"last_edited,omitempty"` + Pinned bool `bson:"pinned,omitempty" msgpack:"pinned,omitempty"` +} + +type GetPostOpts struct { + BeforeId *meowid.MeowID + AfterId *meowid.MeowID + Skip *int64 + Limit *int64 +} + +type PaginationOpts interface { + BeforeId() *meowid.MeowID + AfterId() *meowid.MeowID + Skip() int64 + Limit() int64 +} + +func GetPost(postId meowid.MeowID) (Post, error) { + var p Post + err := db.Posts.FindOne(context.TODO(), bson.M{"_id": postId}).Decode(&p) + if err == mongo.ErrNoDocuments { + return p, ErrPostNotFound + } + return p, err +} + +func GetPosts(chatId meowid.MeowID, onlyIncludePinned bool, paginationOpts PaginationOpts) ([]Post, error) { + queryFilter := bson.M{"$and": []bson.M{{"chat": chatId}}} + if onlyIncludePinned { + queryFilter["$and"] = append( + queryFilter["$and"].([]bson.M), + bson.M{"pinned": true}, + ) + } + + beforeId := paginationOpts.BeforeId() + afterId := paginationOpts.AfterId() + if beforeId != nil { + queryFilter["$and"] = append( + queryFilter["$and"].([]bson.M), + bson.M{"_id": bson.M{"$lt": beforeId}}, + ) + } + if afterId != nil { + queryFilter["$and"] = append( + queryFilter["$and"].([]bson.M), + bson.M{"_id": bson.M{"$gt": afterId}}, + ) + } + + queryOpts := options.Find() + queryOpts.SetSort(bson.M{"_id": -1}) + queryOpts.SetSkip(paginationOpts.Skip()) + queryOpts.SetLimit(paginationOpts.Limit()) + + var posts []Post + cur, err := db.Posts.Find(context.TODO(), queryFilter, queryOpts) + if err != nil { + return posts, err + } + if err := cur.All(context.TODO(), &posts); err != nil { + return posts, err + } + + return posts, nil +} + +func CreatePost( + chatId meowid.MeowID, + authorId meowid.MeowID, + replyToPostIds []meowid.MeowID, + content string, + stickerIds []string, + attachmentIds []string, + nonce string, +) (Post, error) { + var p Post + + // De-dupe and validate reply to posts + if len(replyToPostIds) > 0 { + replyToPostIds = utils.RemoveDuplicates(replyToPostIds).([]meowid.MeowID) + count, err := db.Posts.CountDocuments( + context.TODO(), + bson.M{ + "_id": bson.M{"$in": replyToPostIds}, + "chat": chatId, + }, + ) + if err != nil { + return p, err + } + if count != int64(len(replyToPostIds)) { + return p, err + } + } + + // De-dupe and validate stickers + if len(stickerIds) > 0 { + stickerIds = utils.RemoveDuplicates(stickerIds).([]string) + count, err := db.ChatEmotes.CountDocuments( + context.TODO(), + bson.M{ + "_id": bson.M{"$in": stickerIds}, + "type": 0, + }, + ) + if err != nil { + return p, err + } + if count != int64(len(stickerIds)) { + return p, err + } + } + + // De-dupe, validate, and claim attachments + if len(attachmentIds) > 0 { + attachmentIds = utils.RemoveDuplicates(attachmentIds).([]string) + result, err := db.Files.UpdateMany( + context.TODO(), + bson.M{ + "_id": bson.M{"$in": attachmentIds}, + "uploader": authorId, + "claimed": false, + }, + bson.M{"$set": bson.M{"claimed": true}}, + ) + if err != nil { + return p, err + } + if result.ModifiedCount != int64(len(attachmentIds)) { + return p, err + } + } + + // Create post + p = Post{ + Id: meowid.GenId(), + ChatId: chatId, + AuthorId: authorId, + ReplyToPostIds: replyToPostIds, + Content: content, + StickerIds: stickerIds, + AttachmentIds: attachmentIds, + ReactionIndexes: []ReactionIndex{}, + } + if _, err := db.Posts.InsertOne(context.TODO(), &p); err != nil { + return p, err + } + + // Emit event + if err := EmitCreatePostEvent(&p, nil, nonce); err != nil { + return p, err + } + + return p, nil +} + +func (p *Post) V0(includeReplies bool, requesterId *meowid.MeowID) structs.V0Post { + // Chat ID + var chatIdStr string + if p.ChatId == 0 { + chatIdStr = "home" + } else if p.ChatId == 1 { + chatIdStr = "livechat" + } else { + chatIdStr = strconv.FormatInt(p.Id, 10) + } + + // Get author + author, _ := users.GetUser(p.AuthorId) + + // Get replied to posts + replyToV0 := []*structs.V0Post{} + for _, postId := range p.ReplyToPostIds { + if includeReplies { + post, _ := GetPost(postId) + postV0 := post.V0(false, requesterId) + replyToV0 = append(replyToV0, &postV0) + } else { + replyToV0 = append(replyToV0, nil) + } + } + + // Get custom emojis + for _, match := range customEmojiRegex.FindAllStringSubmatch(p.Content, -1) { + customEmojiId := match[1] + log.Println(customEmojiId) + } + + // Parse reaction indexes + reactionIndexesV0 := []structs.V0ReactionIndex{} + for _, reactionIndex := range p.ReactionIndexes { + reactionIndexesV0 = append(reactionIndexesV0, reactionIndex.V0(p.Id, requesterId)) + } + + return structs.V0Post{ + Id: strconv.FormatInt(p.Id, 10), + PostId: strconv.FormatInt(p.Id, 10), + ChatId: chatIdStr, + Type: 1, + Author: author.V0(true, false), + AuthorUsername: author.Username, + ReplyTo: replyToV0, + Timestamp: structs.V0PostTimestamp{ + Unix: meowid.Extract(p.Id).Timestamp / 1000, + }, + Content: p.Content, + Emojis: []*structs.V0Emote{}, + Stickers: []*structs.V0Emote{}, + Attachments: []interface{}{}, + ReactionIndexes: reactionIndexesV0, + Pinned: p.Pinned, + LastEditedAt: &p.LastEditedAt, + } +} + +func (p *Post) UpdateContent(newContent string) error { + // Update content + p.Content = newContent + p.LastEditedAt = time.Now().UnixMilli() + if _, err := db.Posts.UpdateByID(context.TODO(), p.Id, bson.M{"content": p.Content}); err != nil { + return err + } + + // Emit event + if err := EmitUpdatePostEvent(p.ChatId, p.Id, &p.Content, nil, nil, &p.LastEditedAt); err != nil { + return err + } + + return nil +} + +func (p *Post) RemoveAttachment(attachmentId string) error { + // Remove and unclaim any matching attachment + for i := range p.AttachmentIds { + if p.AttachmentIds[i] == attachmentId { + p.AttachmentIds = append(p.AttachmentIds[:i], p.AttachmentIds[i+1:]...) + + if _, err := db.Posts.UpdateByID(context.TODO(), p.Id, bson.M{"attachments": p.AttachmentIds}); err != nil { + return err + } + + if err := EmitUpdatePostEvent( + p.ChatId, + p.Id, + nil, + &p.AttachmentIds, + nil, + &p.LastEditedAt, + ); err != nil { + return err + } + + return nil + } + } + + return ErrAttachmentNotFound +} + +func (p *Post) SetPinnedState(pinned bool) error { + // Set pinned state + p.Pinned = pinned + if _, err := db.Posts.UpdateByID(context.TODO(), p.Id, bson.M{"pinned": p.Pinned}); err != nil { + return err + } + + // Emit event + if err := EmitUpdatePostEvent(p.ChatId, p.Id, nil, nil, &p.Pinned, &p.LastEditedAt); err != nil { + return err + } + + return nil +} + +func (p *Post) Delete(sendEvent bool) error { + // Unclaim attachments + go func() {}() + + // Delete reactions + go func() { + + }() + + // Delete post + if _, err := db.Posts.DeleteOne(context.TODO(), bson.M{"_id": p.Id}); err != nil { + return err + } + + // Emit event + if sendEvent { + if err := EmitDeletePostEvent(p.ChatId, p.Id); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/posts/reaction.go b/pkg/posts/reaction.go new file mode 100644 index 0000000..4cf39a1 --- /dev/null +++ b/pkg/posts/reaction.go @@ -0,0 +1,33 @@ +package posts + +import ( + "context" + + "github.com/meower-media/server/pkg/db" + "github.com/meower-media/server/pkg/meowid" + "go.mongodb.org/mongo-driver/mongo" +) + +type Reaction struct { + PostId int64 `bson:"post" msgpack:"post"` + Emoji string `bson:"emoji" msgpack:"emoji"` + UserId int64 `bson:"user" msgpack:"user"` +} + +func (p *Post) AddPostReaction(emoji string, userId meowid.MeowID) (Reaction, error) { + // Add reaction + r := Reaction{ + PostId: p.Id, + Emoji: emoji, + UserId: userId, + } + _, err := db.PostReactions.InsertOne(context.TODO(), r) + if mongo.IsDuplicateKeyError(err) { + err = ErrReactionAlreadyExists + } + + // Update index in background + go func() {}() + + return r, err +} diff --git a/pkg/posts/reaction_index.go b/pkg/posts/reaction_index.go new file mode 100644 index 0000000..09322dc --- /dev/null +++ b/pkg/posts/reaction_index.go @@ -0,0 +1,40 @@ +package posts + +import ( + "context" + + "github.com/getsentry/sentry-go" + "github.com/meower-media/server/pkg/db" + "github.com/meower-media/server/pkg/meowid" + "github.com/meower-media/server/pkg/structs" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type ReactionIndex struct { + Emoji string `bson:"emoji" msgpack:"emoji"` + Count int64 `bson:"count" msgpack:"count"` +} + +func (ri *ReactionIndex) V0(postId meowid.MeowID, requesterId *meowid.MeowID) structs.V0ReactionIndex { + var userReacted bool + if requesterId != nil { + q := bson.M{"_id": bson.M{ + "post": postId, + "emoji": ri.Emoji, + "user": requesterId, + }} + opts := options.CountOptions{} + opts.SetLimit(1) + count, err := db.PostReactions.CountDocuments(context.TODO(), q, &opts) + if err != nil { + sentry.CaptureException(err) + } + userReacted = count > 0 + } + return structs.V0ReactionIndex{ + Emoji: ri.Emoji, + Count: ri.Count, + UserReacted: userReacted, + } +} diff --git a/pkg/rdb/rdb.go b/pkg/rdb/rdb.go new file mode 100644 index 0000000..b0c086c --- /dev/null +++ b/pkg/rdb/rdb.go @@ -0,0 +1,27 @@ +package rdb + +import ( + "context" + + "github.com/redis/go-redis/v9" +) + +var Client *redis.Client + +func Init(uri string) error { + // Get Redis options + rdbOpts, err := redis.ParseURL(uri) + if err != nil { + return err + } + + // Create Redis client + Client = redis.NewClient(rdbOpts) + + // Ping Redis cluster + if err := Client.Ping(context.Background()).Err(); err != nil { + return err + } + + return nil +} diff --git a/pkg/safety/pagination.go b/pkg/safety/pagination.go new file mode 100644 index 0000000..0089bd3 --- /dev/null +++ b/pkg/safety/pagination.go @@ -0,0 +1,33 @@ +package safety + +import "github.com/meower-media/server/pkg/meowid" + +type SnapshotPaginationOpts struct { + Mode int8 // 0: before, 1: after + ChatId meowid.MeowID + PostId meowid.MeowID +} + +func (p SnapshotPaginationOpts) BeforeId() *meowid.MeowID { + if p.Mode == 0 { + return &p.PostId + } else { + return nil + } +} + +func (p SnapshotPaginationOpts) AfterId() *meowid.MeowID { + if p.Mode == 1 { + return &p.PostId + } else { + return nil + } +} + +func (p SnapshotPaginationOpts) Skip() int64 { + return 0 +} + +func (p SnapshotPaginationOpts) Limit() int64 { + return 25 +} diff --git a/pkg/safety/report.go b/pkg/safety/report.go new file mode 100644 index 0000000..ad29a62 --- /dev/null +++ b/pkg/safety/report.go @@ -0,0 +1,69 @@ +package safety + +import ( + "context" + "strconv" + + "github.com/meower-media/server/pkg/db" + "github.com/meower-media/server/pkg/meowid" + "github.com/meower-media/server/pkg/structs" +) + +type Report struct { + Id meowid.MeowID `bson:"_id"` + Type string `bson:"type"` // "user" / "chat" / "post" + ContentId meowid.MeowID `bson:"content"` + SnapshotHash string `bson:"snapshot"` + ReporterId meowid.MeowID `bson:"reporter"` + Reason string `bson:"reason"` + Comment string `bson:"comment"` + Status string `bson:"status"` // "pending" / "no_action_taken" / "action_taken" +} + +func CreateReport(reportType string, contentId meowid.MeowID, reporterId meowid.MeowID, reason string, comment string) (Report, error) { + var report Report + var snapshot Snapshot + var err error + + // Create snapshot + if reportType == "user" { + snapshot, err = CreateUserSnapshot(contentId) + } + if reportType == "post" { + snapshot, err = CreatePostSnapshot(contentId) + } + if err != nil { + return report, err + } + + // Create report + report = Report{ + Id: meowid.GenId(), + Type: reportType, + ContentId: contentId, + SnapshotHash: snapshot.Hash, + ReporterId: reporterId, + Reason: reason, + Comment: comment, + Status: "pending", + } + if _, err := db.Reports.InsertOne(context.TODO(), report); err != nil { + return report, err + } + + return report, nil +} + +// this is for the reporter, not admin +func (r *Report) V0() structs.V0Report { + return structs.V0Report{ + Id: strconv.FormatInt(r.Id, 10), + Type: r.Type, + ContentId: strconv.FormatInt(r.ContentId, 10), + Content: nil, + Reason: r.Reason, + Comment: r.Comment, + Time: meowid.Extract(r.Id).Timestamp / 1000, + Status: r.Status, + } +} diff --git a/pkg/safety/snapshot.go b/pkg/safety/snapshot.go new file mode 100644 index 0000000..f8426dd --- /dev/null +++ b/pkg/safety/snapshot.go @@ -0,0 +1,130 @@ +package safety + +import ( + "context" + "crypto/sha256" + "encoding/base64" + + "github.com/getsentry/sentry-go" + "github.com/meower-media/server/pkg/chats" + "github.com/meower-media/server/pkg/db" + "github.com/meower-media/server/pkg/meowid" + "github.com/meower-media/server/pkg/posts" + "github.com/meower-media/server/pkg/users" + "github.com/vmihailenco/msgpack/v5" + "go.mongodb.org/mongo-driver/mongo" +) + +type Snapshot struct { + Hash string `bson:"_id" msgpack:"-"` + Users []users.User `bson:"users" msgpack:"users"` + Chats []chats.Chat `bson:"chats" msgpack:"chats"` + Posts []posts.Post `bson:"posts" msgpack:"posts"` +} + +func CreateUserSnapshot(userId meowid.MeowID) (Snapshot, error) { + s := Snapshot{ + Users: []users.User{}, + Chats: []chats.Chat{}, + Posts: []posts.Post{}, + } + + // Snapshot user + user, err := users.GetUser(userId) + if err != nil { + return s, err + } + s.Users = append(s.Users, user) + + // Add hash + s.Hash, err = s.GetHash() + if err != nil { + return s, err + } + + // Add to database + if _, err := db.ReportSnapshots.InsertOne(context.TODO(), s); err != nil && !mongo.IsDuplicateKeyError(err) { + return s, err + } + + return s, nil +} + +func CreatePostSnapshot(postId meowid.MeowID) (Snapshot, error) { + s := Snapshot{ + Users: []users.User{}, + Chats: []chats.Chat{}, + Posts: []posts.Post{}, + } + + // Get post + post, err := posts.GetPost(postId) + if err != nil { + return s, err + } + + // Get surrounding posts + paginationOpts := SnapshotPaginationOpts{ + ChatId: post.ChatId, + PostId: post.Id, + } + paginationOpts.Mode = 0 + beforePosts, err := posts.GetPosts(post.ChatId, false, &paginationOpts) + if err != nil { + return s, err + } + paginationOpts.Mode = 1 + afterPosts, err := posts.GetPosts(post.ChatId, false, &paginationOpts) + if err != nil { + return s, err + } + + // Add posts to snapshot + s.Posts = append(append(beforePosts, post), afterPosts...) + + // Snapshot authors + seenAuthors := make(map[meowid.MeowID]bool) + for _, post := range s.Posts { + if seenAuthors[post.AuthorId] { + continue + } else { + seenAuthors[post.AuthorId] = true + } + user, err := users.GetUser(post.AuthorId) + if err != nil { + sentry.CaptureException(err) + continue + } + s.Users = append(s.Users, user) + } + + // Snapshot chat + if post.ChatId != chats.ChatDefaultIdHome { + return s, nil + } + + // Add hash + s.Hash, err = s.GetHash() + if err != nil { + return s, err + } + + // Add to database + if _, err := db.ReportSnapshots.InsertOne(context.TODO(), s); err != nil && !mongo.IsDuplicateKeyError(err) { + return s, err + } + + return s, nil +} + +func (s *Snapshot) GetHash() (string, error) { + marshaled, err := msgpack.Marshal(s) + if err != nil { + return "", err + } + h := sha256.New() + if _, err := h.Write(marshaled); err != nil { + return "", err + } + return base64.RawStdEncoding.EncodeToString(h.Sum(nil)), nil +} diff --git a/pkg/safety/strike.go b/pkg/safety/strike.go new file mode 100644 index 0000000..2ae0eed --- /dev/null +++ b/pkg/safety/strike.go @@ -0,0 +1,51 @@ +package safety + +import ( + "context" + "time" + + "github.com/meower-media/server/pkg/db" + "github.com/meower-media/server/pkg/meowid" + "go.mongodb.org/mongo-driver/bson" +) + +type Strike struct { + Id meowid.MeowID `bson:"_id" msgpack:"id"` + UserId meowid.MeowID `bson:"user" msgpack:"user"` + Reason string `bson:"reason" msgpack:"reason"` + Content Snapshot `bson:"content" msgpack:"content"` + + /* + A strike can have an impact of warning, restriction, or ban. + + Warning carries no additional punishment. + + Restriction blocks the user from starting new chats, joining public chats, and editing their profile. + + Ban blocks the user from logging in. + */ + Impact string `bson:"impact" msgpack:"impact"` + + ExpiresAt int64 `bson:"expires" msgpack:"expires"` // -1 for permanent +} + +func GetActiveStrikes(userId meowid.MeowID) ([]Strike, error) { + var strikes []Strike + + cur, err := db.Strikes.Find( + context.TODO(), + bson.M{ + "user": userId, + "$or": []bson.M{ + {"expires": -1}, + {"expires": bson.M{"$gt": time.Now().UnixMilli()}}, + }, + }, + ) + if err != nil { + return strikes, err + } + + err = cur.All(context.TODO(), &strikes) + return strikes, err +} diff --git a/pkg/structs/authenticator.go b/pkg/structs/authenticator.go new file mode 100644 index 0000000..ee26bb8 --- /dev/null +++ b/pkg/structs/authenticator.go @@ -0,0 +1,8 @@ +package structs + +type V0Authenticator struct { + Id string `json:"_id"` + Type string `json:"type"` + Nickname string `json:"nickname"` + RegisteredAt int64 `json:"registered_at"` +} diff --git a/pkg/structs/chat.go b/pkg/structs/chat.go new file mode 100644 index 0000000..b6b4cdb --- /dev/null +++ b/pkg/structs/chat.go @@ -0,0 +1,26 @@ +package structs + +type V0Chat struct { + Id string `json:"_id"` + Type int8 `json:"type"` + + // not included on DMs + Nickname *string `json:"nickname,omitempty"` + IconId *string `json:"icon,omitempty"` + Color *string `json:"icon_color,omitempty"` + + // only on DMs + DirectRecipient *V0User `json:"direct_recipient,omitempty"` + + OwnerUsername *string `json:"owner,omitempty"` // requester if they are an admin | not included on DMs + MemberUsernames []string `json:"members"` // only includes first 256 members (this will be deprecated in the future) + MemberCount *int64 `json:"member_count,omitempty"` // not included on DMs + + CreatedAt int64 `json:"created"` + LastPostId string `json:"last_post_id"` + LastActiveAt int64 `json:"last_active"` + + AllowPinning bool `json:"allow_pinning"` + + Deleted bool `json:"deleted"` // deprecated +} diff --git a/pkg/structs/chat_ban.go b/pkg/structs/chat_ban.go new file mode 100644 index 0000000..12a3fca --- /dev/null +++ b/pkg/structs/chat_ban.go @@ -0,0 +1,8 @@ +package structs + +type V0ChatBan struct { + User *V0User `json:"user,omitempty"` // only included when accessing the full ban list + DetectEvasion *bool `json:"detect_evasion,omitempty"` // only included when accessing the full ban list + Reason string `json:"reason"` + ExpiresAt *int64 `json:"expires_at"` +} diff --git a/pkg/structs/chat_emote.go b/pkg/structs/chat_emote.go new file mode 100644 index 0000000..6db557d --- /dev/null +++ b/pkg/structs/chat_emote.go @@ -0,0 +1,7 @@ +package structs + +type V0ChatEmote struct { + Id string `json:"_id"` + Name string `json:"name"` + Animated bool `json:"animated"` +} diff --git a/pkg/structs/chat_member.go b/pkg/structs/chat_member.go new file mode 100644 index 0000000..1232041 --- /dev/null +++ b/pkg/structs/chat_member.go @@ -0,0 +1,20 @@ +package structs + +import ( + "github.com/meower-media/server/pkg/meowid" +) + +type V0ChatMember struct { + User *V0User `json:"user,omitempty"` // only included + + JoinedAt int64 `json:"joined_at"` + Admin bool `json:"admin"` + + Ban *V0ChatBan `json:"ban"` + + // only included when getting own membership + NotificationSettings *V0ChatNotificationSettings `json:"notification_settings,omitempty"` + LastAckedPostId *meowid.MeowID `json:"last_acked_post_id,omitempty"` + UnreadCount *int64 `json:"unread_count,omitempty"` + UnreadMentionCount *int64 `json:"unread_mention_count,omitempty"` +} diff --git a/pkg/structs/chat_notification_settings.go b/pkg/structs/chat_notification_settings.go new file mode 100644 index 0000000..bad20fa --- /dev/null +++ b/pkg/structs/chat_notification_settings.go @@ -0,0 +1,7 @@ +package structs + +type V0ChatNotificationSettings struct { + Mode int8 `json:"mode"` // 2: all, 1: mentions, 0: none + Push bool `json:"push"` + MutedUntil int64 `json:"muted_until"` // -1 for permanent mute +} diff --git a/pkg/structs/emote.go b/pkg/structs/emote.go new file mode 100644 index 0000000..b292e21 --- /dev/null +++ b/pkg/structs/emote.go @@ -0,0 +1,8 @@ +package structs + +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"` +} diff --git a/pkg/structs/post.go b/pkg/structs/post.go new file mode 100644 index 0000000..d361019 --- /dev/null +++ b/pkg/structs/post.go @@ -0,0 +1,28 @@ +package structs + +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 V0PostTimestamp `json:"t" msgpack:"t"` + Content string `json:"p" msgpack:"p"` + Emojis []*V0Emote `json:"emojis" msgpack:"emojis"` + Stickers []*V0Emote `json:"stickers" msgpack:"stickers"` + Attachments []interface{} `json:"attachments" msgpack:"attachments"` + ReactionIndexes []V0ReactionIndex `json:"reactions" msgpack:"reactions"` + Pinned bool `json:"pinned" msgpack:"pinned"` + LastEditedAt *int64 `json:"last_edited,omitempty" msgpack:"last_edited,omitempty"` + + Nonce string `json:"nonce,omitempty" msgpack:"nonce,omitempty"` + + // deprecated + Deleted bool `json:"isDeleted" msgpack:"isDeleted"` +} + +type V0PostTimestamp struct { + Unix int64 `json:"e" msgpack:"e"` +} diff --git a/pkg/structs/reaction_index.go b/pkg/structs/reaction_index.go new file mode 100644 index 0000000..e76dbff --- /dev/null +++ b/pkg/structs/reaction_index.go @@ -0,0 +1,7 @@ +package structs + +type V0ReactionIndex struct { + Emoji string `json:"emoji" msgpack:"emoji"` + Count int64 `json:"count" msgpack:"count"` + UserReacted bool `json:"user_reacted" msgpack:"user_reacted"` +} diff --git a/pkg/structs/relationship.go b/pkg/structs/relationship.go new file mode 100644 index 0000000..dd2ee5f --- /dev/null +++ b/pkg/structs/relationship.go @@ -0,0 +1,7 @@ +package structs + +type V0Relationship struct { + Username string `json:"username"` + State int8 `json:"state"` + UpdatedAt int64 `json:"updated_at"` +} diff --git a/pkg/structs/report.go b/pkg/structs/report.go new file mode 100644 index 0000000..ba8f691 --- /dev/null +++ b/pkg/structs/report.go @@ -0,0 +1,12 @@ +package structs + +type V0Report struct { + Id string `json:"_id"` + Type string `json:"type"` + ContentId string `json:"content_id"` + Content interface{} `json:"content"` + Reason string `json:"reason"` + Comment string `json:"comment"` + Time int64 `json:"time"` + Status string `json:"status"` +} diff --git a/pkg/structs/session.go b/pkg/structs/session.go new file mode 100644 index 0000000..aa78025 --- /dev/null +++ b/pkg/structs/session.go @@ -0,0 +1,9 @@ +package structs + +type V0Session struct { + Id string `json:"_id" msgpack:"_id"` + IPAddress string `json:"ip" msgpack:"ip"` + Location string `json:"location" msgpack:"location"` + UserAgent string `json:"user_agent" msgpack:"user_agent"` + RefreshedAt int64 `json:"refreshed_at" msgpack:"refreshed_at"` +} diff --git a/pkg/structs/user.go b/pkg/structs/user.go new file mode 100644 index 0000000..605dd1a --- /dev/null +++ b/pkg/structs/user.go @@ -0,0 +1,17 @@ +package structs + +type V0User struct { + Id string `json:"uuid" msgpack:"uuid"` + Username string `json:"_id" msgpack:"_id"` + Flags int64 `json:"flags" msgpack:"flags"` + IconId string `json:"avatar" msgpack:"avatar"` + LegacyIcon *int8 `json:"pfp_data" msgpack:"pfp_data"` + Color *string `json:"avatar_color" msgpack:"avatar_color"` + + Email *string `json:"email,omitempty" msgpack:"email,omitempty"` + Permissions *int64 `json:"permissions,omitempty" msgpack:"permissions,omitempty"` + Quote *string `json:"quote,omitempty" msgpack:"quote,omitempty"` + LastSeenAt *int64 `json:"last_seen,omitempty" msgpack:"last_seen,omitempty"` + + V0UserSettings +} diff --git a/pkg/structs/user_settings.go b/pkg/structs/user_settings.go new file mode 100644 index 0000000..d733a3f --- /dev/null +++ b/pkg/structs/user_settings.go @@ -0,0 +1,34 @@ +package structs + +type V0UserSettings struct { + Theme *string `json:"theme,omitempty"` + Mode *bool `json:"mode,omitempty"` + Layout *string `json:"layout,omitempty"` + Sfx *bool `json:"sfx,omitempty"` + Bgm *bool `json:"bgm,omitempty"` + BgmSong *int8 `json:"bgm_song,omitempty"` + Debug *bool `json:"debug,omitempty"` + HideBlockedUsers *bool `json:"hide_blocked_users,omitempty"` +} + +var ( + v0DefaultTheme string = "orange" + v0DefaultMode bool = true + v0DefaultLayout string = "new" + v0DefaultSfx bool = true + v0DefaultBgm bool = false + v0DefaultBgmSong int8 = 1 + v0DefaultDebug bool = false + v0DefaultHideBlockedUsers bool = false +) + +var V0DefaultUserSettings = V0UserSettings{ + Theme: &v0DefaultTheme, + Mode: &v0DefaultMode, + Layout: &v0DefaultLayout, + Sfx: &v0DefaultSfx, + Bgm: &v0DefaultBgm, + BgmSong: &v0DefaultBgmSong, + Debug: &v0DefaultDebug, + HideBlockedUsers: &v0DefaultHideBlockedUsers, +} diff --git a/pkg/users/account.go b/pkg/users/account.go new file mode 100644 index 0000000..9cc2072 --- /dev/null +++ b/pkg/users/account.go @@ -0,0 +1,135 @@ +package users + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "github.com/meower-media/server/pkg/db" + "github.com/meower-media/server/pkg/meowid" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "golang.org/x/crypto/bcrypt" +) + +const BcryptCost = 14 + +type Account struct { + Id meowid.MeowID `bson:"_id"` + + Email string `bson:"email,omitempty"` + NormalizedEmailHash string `bson:"normalized_email,omitempty"` + + PasswordHash []byte `bson:"password,omitempty"` + RecoveryCode string `bson:"recovery_code,omitempty"` + Authenticators []Authenticator `bson:"authenticators,omitempty"` + + LastAuthAt int64 `bson:"last_auth_at"` +} + +func CreateAccount(username string, password string) (Account, User, error) { + userId := meowid.GenId() + var account Account + var user User + + // Make sure username hasn't been taken + taken, err := UsernameTaken(username) + if err != nil { + fmt.Println(err) + return account, user, err + } else if taken { + return account, user, ErrUsernameTaken + } + + // Create user + user = User{ + Id: userId, + Username: username, + } + if _, err := db.Users.InsertOne(context.TODO(), user); err != nil { + return account, user, err + } + + // Hash password + passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost) + if err != nil { + return account, user, err + } + + // Create account + recoveryCode := make([]byte, 5) + rand.Read(recoveryCode) + account = Account{ + Id: userId, + PasswordHash: passwordHash, + RecoveryCode: hex.EncodeToString(recoveryCode), + LastAuthAt: time.Now().UnixMilli(), + } + if _, err := db.Accounts.InsertOne(context.TODO(), account); err != nil { + return account, user, err + } + + return account, user, nil +} + +func GetAccount(id meowid.MeowID) (Account, error) { + var account Account + err := db.Accounts.FindOne(context.TODO(), bson.M{"_id": id}).Decode(&account) + if err == mongo.ErrNoDocuments { + err = ErrUserNotFound + } + return account, err +} + +func (a *Account) CheckPassword(password string) error { + return bcrypt.CompareHashAndPassword(a.PasswordHash, []byte(password)) +} + +func (a *Account) ChangePassword(newPassword string) error { + var err error + a.PasswordHash, err = bcrypt.GenerateFromPassword([]byte(newPassword), BcryptCost) + if err != nil { + return err + } + + if _, err := db.Accounts.UpdateByID( + context.TODO(), + a.Id, + bson.M{"password": a.PasswordHash}, + ); err != nil { + return err + } + + return nil +} + +func (a *Account) MfaMethods() []string { + methodsMap := make(map[string]bool) + for _, authenticator := range a.Authenticators { + methodsMap[authenticator.Type] = true + } + + methodsSlice := []string{} + for method := range methodsMap { + methodsSlice = append(methodsSlice, method) + } + return methodsSlice +} + +func (a *Account) ResetRecoveryCode() error { + recoveryCode := make([]byte, 5) + if _, err := rand.Read(recoveryCode); err != nil { + return err + } + a.RecoveryCode = hex.EncodeToString(recoveryCode) + if _, err := db.Accounts.UpdateByID( + context.TODO(), + a.Id, + bson.M{"recovery_code": a.RecoveryCode}, + ); err != nil { + return err + } + return nil +} diff --git a/pkg/users/authenticator.go b/pkg/users/authenticator.go new file mode 100644 index 0000000..f16a0b0 --- /dev/null +++ b/pkg/users/authenticator.go @@ -0,0 +1,107 @@ +package users + +import ( + "context" + "strconv" + + "github.com/meower-media/server/pkg/db" + "github.com/meower-media/server/pkg/meowid" + "github.com/meower-media/server/pkg/structs" + "github.com/pquerna/otp/totp" + "go.mongodb.org/mongo-driver/bson" +) + +type Authenticator struct { + Id meowid.MeowID `bson:"_id"` + Type string `bson:"type"` + Nickname string `bson:"nickname,omitempty"` + TotpSecret string `bson:"totp_secret,omitempty"` +} + +func (a *Account) AddTotpAuthenticator(nickname string, secret string) (*Authenticator, error) { + authenticator := Authenticator{ + Id: meowid.GenId(), + Type: "totp", + Nickname: nickname, + TotpSecret: secret, + } + a.Authenticators = append(a.Authenticators, authenticator) + _, err := db.Accounts.UpdateByID( + context.TODO(), + a.Id, + bson.M{"$addToSet": bson.M{"authenticators": &authenticator}}, + ) + return &authenticator, err +} + +func (a *Account) GetAuthenticator(authenticatorId meowid.MeowID) (*Authenticator, error) { + var authenticator *Authenticator + for _, _authenticator := range a.Authenticators { + if _authenticator.Id == authenticatorId { + authenticator = &_authenticator + break + } + } + if authenticator == nil { + return authenticator, ErrAuthenticatorNotFound + } + return authenticator, nil +} + +func (a *Account) ChangeAuthenticatorNickname(authenticatorId meowid.MeowID, nickname string) (*Authenticator, error) { + authenticator, err := a.GetAuthenticator(authenticatorId) + if err != nil { + return authenticator, err + } + authenticator.Nickname = nickname + _, err = db.Accounts.UpdateOne( + context.TODO(), + bson.M{"_id": a.Id, "authenticators._id": authenticatorId}, + bson.M{"$set": bson.M{"authenticators.$.nickname": nickname}}, + ) + return authenticator, err +} + +func (a *Account) RemoveAuthenticator(authenticatorId meowid.MeowID) error { + newAuthenticators := []Authenticator{} + for _, authenticator := range a.Authenticators { + if authenticator.Id == authenticatorId { + continue + } + newAuthenticators = append(newAuthenticators, authenticator) + } + a.Authenticators = newAuthenticators + _, err := db.Accounts.UpdateOne( + context.TODO(), + bson.M{"_id": a.Id}, + bson.M{"$pull": bson.M{"authenticators": bson.M{"_id": authenticatorId}}}, + ) + return err +} + +func (a *Account) CheckTotp(code string) bool { + for _, authenticator := range a.Authenticators { + if authenticator.Type != "totp" { + continue + } + + if valid := authenticator.CheckTotp(code); valid { + return true + } + } + + return false +} + +func (a *Authenticator) V0() *structs.V0Authenticator { + return &structs.V0Authenticator{ + Id: strconv.FormatInt(a.Id, 10), + Type: a.Type, + Nickname: a.Nickname, + RegisteredAt: meowid.Extract(a.Id).Timestamp, + } +} + +func (a *Authenticator) CheckTotp(code string) bool { + return totp.Validate(code, a.TotpSecret) +} diff --git a/pkg/users/deleted_user.go b/pkg/users/deleted_user.go new file mode 100644 index 0000000..b3dc624 --- /dev/null +++ b/pkg/users/deleted_user.go @@ -0,0 +1,21 @@ +package users + +var deletedFlags int64 = 0 +var deletedPermissions int64 = 0 +var deletedIconId string = "" +var deletedLegacyIcon int8 = 0 +var deletedColor string = "" +var deletedQuote string = "" + +var DeletedUser User = User{ + Id: 1, + Username: "Deleted", + + Flags: deletedFlags, + Permissions: &deletedPermissions, + + IconId: deletedIconId, + LegacyIcon: deletedLegacyIcon, + Color: deletedColor, + Quote: &deletedQuote, +} diff --git a/pkg/users/errors.go b/pkg/users/errors.go new file mode 100644 index 0000000..64fbda7 --- /dev/null +++ b/pkg/users/errors.go @@ -0,0 +1,14 @@ +package users + +import "errors" + +var ( + ErrUsernameTaken = errors.New("username taken") + ErrUserNotFound = errors.New("user not found") + ErrAccountNotFound = errors.New("account not found") + ErrAuthenticatorNotFound = errors.New("authenticator not found") + ErrSessionNotFound = errors.New("session not found") + ErrInvalidTokenFormat = errors.New("invalid token format") + ErrInvalidTokenSignature = errors.New("invalid token signature") + ErrTokenExpired = errors.New("token expired") +) diff --git a/pkg/users/relationship.go b/pkg/users/relationship.go new file mode 100644 index 0000000..b5a70ed --- /dev/null +++ b/pkg/users/relationship.go @@ -0,0 +1,101 @@ +package users + +import ( + "context" + "time" + + "github.com/meower-media/server/pkg/db" + "github.com/meower-media/server/pkg/meowid" + "github.com/meower-media/server/pkg/structs" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + RelationshipStateNone int8 = 0 + RelationshipStateFollowing int8 = 1 + RelationshipStateBlocked int8 = 2 + RelationshipStateOutgoingFriendRequest int8 = 3 + RelationshipStateIncomingFriendRequest int8 = 4 + RelationshipStateFriend int8 = 5 +) + +type Relationship struct { + Id RelationshipIdCompound `bson:"_id" msgpack:"id"` + State int8 `bson:"state" msgpack:"state"` + UpdatedAt int64 `bson:"updated_at" msgpack:"updated_at"` +} + +type RelationshipIdCompound struct { + From meowid.MeowID `bson:"from" msgpack:"from"` + To meowid.MeowID `bson:"to" msgpack:"to"` +} + +func (u *User) GetRelationship(toId meowid.MeowID) (Relationship, error) { + var relationship Relationship + err := db.Relationships.FindOne( + context.TODO(), + bson.M{"_id": RelationshipIdCompound{ + From: u.Id, + To: toId, + }}, + ).Decode(&relationship) + if err == mongo.ErrNoDocuments { + err = nil + } + return relationship, err +} + +func (u *User) GetAllRelationships() ([]Relationship, error) { + relationships := []Relationship{} + + cur, err := db.Relationships.Find(context.TODO(), bson.M{"_id.from": u.Id}) + if err != nil { + return relationships, err + } + + err = cur.All(context.TODO(), &relationships) + if err != nil { + return relationships, err + } + + return relationships, nil +} + +func (r *Relationship) V0() (structs.V0Relationship, error) { + v0r := structs.V0Relationship{ + State: r.State, + UpdatedAt: r.UpdatedAt / 1000, + } + var err error + v0r.Username, err = GetUsername(r.Id.To) + return v0r, err +} + +func (r *Relationship) Update(state int8) error { + r.State = state + r.UpdatedAt = time.Now().UnixMilli() + + if r.State == 0 { + if _, err := db.Relationships.DeleteOne(context.TODO(), bson.M{"_id": r.Id}); err != nil { + return err + } + } else { + opts := options.UpdateOptions{} + opts.SetUpsert(true) + if _, err := db.Relationships.UpdateByID( + context.TODO(), + r.Id, + bson.M{"$set": bson.M{ + "state": r.State, + "updated_at": r.UpdatedAt, + }}, + &opts, + ); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/users/session.go b/pkg/users/session.go new file mode 100644 index 0000000..36cb780 --- /dev/null +++ b/pkg/users/session.go @@ -0,0 +1,120 @@ +package users + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "strconv" + "strings" + "time" + + "github.com/meower-media/server/pkg/db" + "github.com/meower-media/server/pkg/meowid" + structs "github.com/meower-media/server/pkg/structs" + "github.com/vmihailenco/msgpack/v5" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" +) + +type AccSession struct { + Id meowid.MeowID `bson:"_id" msgpack:"_id"` + UserId meowid.MeowID `bson:"user" msgpack:"user"` + IPAddress string `bson:"ip" msgpack:"ip"` + UserAgent string `bson:"ua" msgpack:"ua"` + RefreshedAt int64 `bson:"refreshed" msgpack:"refreshed"` +} + +func CreateAccSession(userId meowid.MeowID, ipAddress string, userAgent string) (AccSession, error) { + s := AccSession{ + Id: meowid.GenId(), + UserId: userId, + IPAddress: ipAddress, + UserAgent: userAgent, + RefreshedAt: time.Now().UnixMilli(), + } + + if _, err := db.AccSessions.InsertOne(context.TODO(), s); err != nil { + return s, err + } + + return s, nil +} + +func GetAccSession(id meowid.MeowID) (AccSession, error) { + var s AccSession + err := db.AccSessions.FindOne(context.TODO(), bson.M{"_id": id}).Decode(&s) + if err == mongo.ErrNoDocuments { + err = ErrSessionNotFound + } + return s, err +} + +func GetAccSessionByToken(token string) (AccSession, error) { + var s AccSession + + // Split token into claims and signature + parts := strings.Split(token, ".") + if len(parts) != 2 { + return s, ErrInvalidTokenFormat + } + + // Get claims + claims, err := base64.URLEncoding.DecodeString(parts[0]) + if err != nil { + return s, err + } + + // Get signature + signature, err := base64.URLEncoding.DecodeString(parts[1]) + if err != nil { + return s, err + } + + // Check signature + h := hmac.New(sha256.New, AccSessionSigningKey) + if _, err := h.Write(claims); err != nil { + return s, err + } + if !hmac.Equal(signature, h.Sum(nil)) { + return s, ErrInvalidTokenSignature + } + + // Decode claims + var decodedClaims []int64 + if err := msgpack.Unmarshal(claims, &decodedClaims); err != nil { + return s, err + } + + // Make sure token hasn't expired (less than 21 days since last refresh) + if decodedClaims[1] > time.Now().Add((time.Hour*24)*21).UnixMilli() { + return s, ErrTokenExpired + } + + return GetAccSession(decodedClaims[0]) +} + +func (s *AccSession) V0() structs.V0Session { + return structs.V0Session{ + Id: strconv.FormatInt(s.Id, 10), + IPAddress: s.IPAddress, + UserAgent: s.UserAgent, + RefreshedAt: s.RefreshedAt, + } +} + +func (s *AccSession) Token() (string, error) { + // Claims + claims, err := msgpack.Marshal([]int64{s.Id, s.RefreshedAt}) + if err != nil { + return "", err + } + + // Signature + h := hmac.New(sha256.New, AccSessionSigningKey) + if _, err := h.Write(claims); err != nil { + return "", err + } + + return base64.URLEncoding.EncodeToString(claims) + "." + base64.URLEncoding.EncodeToString(h.Sum(nil)), nil +} diff --git a/pkg/users/token.go b/pkg/users/token.go new file mode 100644 index 0000000..15d6e92 --- /dev/null +++ b/pkg/users/token.go @@ -0,0 +1,43 @@ +package users + +import ( + "context" + "crypto/rand" + + "github.com/meower-media/server/pkg/db" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" +) + +var AccSessionSigningKey []byte +var EmailTicketSigningKey []byte + +func InitTokenSigningKeys() error { + var signingKeys struct { + Id string `bson:"_id"` + Acc []byte `bson:"acc"` + Email []byte `bson:"email"` + } + err := db.Config.FindOne(context.TODO(), bson.M{"_id": "signing_keys"}).Decode(&signingKeys) + if err == mongo.ErrNoDocuments { + signingKeys.Id = "signing_keys" + signingKeys.Acc = make([]byte, 64) + signingKeys.Email = make([]byte, 64) + if _, err := rand.Read(AccSessionSigningKey); err != nil { + return err + } + if _, err := rand.Read(EmailTicketSigningKey); err != nil { + return err + } + if _, err := db.Config.InsertOne(context.TODO(), signingKeys); err != nil { + return err + } + } else if err != nil { + return err + } else { + AccSessionSigningKey = signingKeys.Acc + EmailTicketSigningKey = signingKeys.Email + } + + return nil +} diff --git a/pkg/users/user.go b/pkg/users/user.go new file mode 100644 index 0000000..7a48d27 --- /dev/null +++ b/pkg/users/user.go @@ -0,0 +1,115 @@ +package users + +import ( + "context" + "strconv" + + "github.com/getsentry/sentry-go" + "github.com/meower-media/server/pkg/db" + "github.com/meower-media/server/pkg/meowid" + "github.com/meower-media/server/pkg/structs" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + FlagSystem int64 = 1 + FlagDeleted int64 = 2 + FlagProtected int64 = 4 + FlagRatelimitBypass int64 = 8 + FlagRequireEmail int64 = 16 + FlagLocked int64 = 32 +) + +type User struct { + Id int64 `bson:"_id" msgpack:"id"` + Username string `bson:"username" msgpack:"username"` // required for v0 and v1 events + + Flags int64 `bson:"flags,omitempty" msgpack:"flags"` + Permissions *int64 `bson:"permissions,omitempty" msgpack:"permissions"` + + IconId string `bson:"icon_id,omitempty" msgpack:"icon_id"` + LegacyIcon int8 `bson:"legacy_icon,omitempty" msgpack:"legacy_icon"` + Color string `bson:"color,omitempty" msgpack:"color"` + Quote *string `bson:"quote,omitempty" msgpack:"quote"` + + LastSeenAt *int64 `bson:"last_seen_at,omitempty" msgpack:"last_seen_at"` +} + +func UsernameTaken(username string) (bool, error) { + opts := options.Count() + opts.Collation = &options.Collation{Locale: "en_US", Strength: 2} + limit := int64(1) + opts.Limit = &limit + count, err := db.Users.CountDocuments(context.TODO(), bson.M{"username": username}, opts) + return count > 0, err +} + +func GetUser(id meowid.MeowID) (User, error) { + var user User + err := db.Users.FindOne(context.TODO(), bson.M{"_id": id}).Decode(&user) + if err == mongo.ErrNoDocuments { + err = ErrUserNotFound + } + return user, err +} + +func GetUserByUsername(username string) (User, error) { + var user User + opts := options.FindOne() + opts.Collation = &options.Collation{Locale: "en_US", Strength: 2} + err := db.Users.FindOne(context.TODO(), bson.M{"username": username}, opts).Decode(&user) + if err == mongo.ErrNoDocuments { + err = ErrUserNotFound + } + return user, err +} + +func GetUsername(id meowid.MeowID) (string, error) { + var user User + opts := options.FindOne() + opts.SetProjection(bson.M{"username": 1}) + err := db.Users.FindOne(context.TODO(), bson.M{"_id": id}, opts).Decode(&user) + if err == mongo.ErrNoDocuments { + err = ErrUserNotFound + } + return user.Username, err +} + +func (u *User) V0(min bool, includeEmail bool) structs.V0User { + v0u := structs.V0User{ + Id: strconv.FormatInt(u.Id, 10), + Username: u.Username, + Flags: u.Flags, + IconId: u.IconId, + LegacyIcon: &u.LegacyIcon, + Color: &u.Color, + } + if !min { + v0u.Permissions = u.Permissions + v0u.Quote = u.Quote + v0u.LastSeenAt = u.LastSeenAt + } + + if includeEmail { + account, err := GetAccount(u.Id) + if err != nil { + sentry.CaptureException(err) + } + v0u.Email = &account.Email + } + + return v0u +} + +func (u *User) HasFlag(flag int64) bool { + return u.Flags&flag == flag +} + +func (u *User) GetSettings(version int8, v interface{}) error { + return db.UserSettings.FindOne( + context.TODO(), + bson.M{"_id": bson.M{"user": u.Id, "version": version}}, + ).Decode(v) +} diff --git a/pkg/utils/event_op_codes.go b/pkg/utils/event_op_codes.go new file mode 100644 index 0000000..f14fc66 --- /dev/null +++ b/pkg/utils/event_op_codes.go @@ -0,0 +1,40 @@ +package utils + +// 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 ( + EvOpCreateUser uint8 = 0 // * + EvOpUpdateUser uint8 = 1 // // + EvOpDeleteUser uint8 = 2 // * + + EvOpUpdateUserSettings uint8 = 3 + + EvOpRevokeSession uint8 = 4 + + EvOpUpdateRelationship uint8 = 5 // // + + EvOpCreateChat uint8 = 6 + EvOpUpdateChat uint8 = 7 + EvOpDeleteChat uint8 = 8 + + EvOpCreateChatMember uint8 = 9 + EvOpUpdateChatMember uint8 = 10 + EvOpDeleteChatMember uint8 = 11 + + EvOpCreateChatEmote uint8 = 12 + EvOpUpdateChatEmote uint8 = 13 + EvOpDeleteChatEmote uint8 = 14 + + EvOpTyping uint8 = 15 // // + + EvOpCreatePost uint8 = 16 // // + EvOpUpdatePost uint8 = 17 // // + EvOpDeletePost uint8 = 18 // // + EvOpBulkDeletePosts uint8 = 19 // // + + EvOpPostReactionAdd uint8 = 20 // // + EvOpPostReactionRemove uint8 = 21 // // +) diff --git a/pkg/utils/qrcode.go b/pkg/utils/qrcode.go new file mode 100644 index 0000000..57cd79f --- /dev/null +++ b/pkg/utils/qrcode.go @@ -0,0 +1,49 @@ +package utils + +import ( + "fmt" + "log" + + "github.com/skip2/go-qrcode" +) + +// thanks ChatGPT +func GenerateSVGQRCode(content string) string { + // Generate QR code matrix + qr, err := qrcode.New(content, qrcode.Medium) + if err != nil { + log.Fatal(err) + } + + // Get the QR code's bitmap (2D array) + bitmap := qr.Bitmap() + + // Set block size to 1mm + blockSize := 1 // 1mm per block + + // Define total size in mm (45mm x 45mm) + totalSize := 45 + + // Calculate the size of the QR code in mm + qrSize := len(bitmap) * blockSize + + // Calculate the margin to center the QR code within the 45mm x 45mm space + margin := (totalSize - qrSize) / 2 + + // Start building SVG content + svg := fmt.Sprintf(``, totalSize) + + // Iterate over the bitmap to create SVG rectangles for each black square + for y := range bitmap { + for x := range bitmap[y] { + if bitmap[y][x] { + // Draw black (filled) squares using currentColor, with offset for the margin + svg += fmt.Sprintf(``, + x*blockSize+margin, y*blockSize+margin, blockSize, blockSize) + } + } + } + + svg += `` + return svg +} diff --git a/pkg/utils/slices.go b/pkg/utils/slices.go new file mode 100644 index 0000000..7e532ed --- /dev/null +++ b/pkg/utils/slices.go @@ -0,0 +1,14 @@ +package utils + +func RemoveDuplicates(items interface{}) interface{} { + uniqueItems := []interface{}{} + seenItems := make(map[interface{}]bool) + for _, item := range items.([]interface{}) { + if _, ok := seenItems[item]; ok { + continue + } + uniqueItems = append(uniqueItems, item) + seenItems[item] = true + } + return uniqueItems +} diff --git a/rest_api/v0/chats.py b/rest_api/v0/chats.py deleted file mode 100644 index be1f795..0000000 --- a/rest_api/v0/chats.py +++ /dev/null @@ -1,788 +0,0 @@ -from quart import Blueprint, current_app as app, request, abort -from quart_schema import validate_querystring, validate_request -from pydantic import BaseModel, Field -from typing import Optional, Literal -import pymongo, uuid, time, re, os - -import security -from database import db, get_total_pages -from uploads import claim_file, delete_file -from utils import log - -chats_bp = Blueprint("chats_bp", __name__, url_prefix="/chats") - - -class GetPostsQueryArgs(BaseModel): - page: Optional[int] = Field(default=1, ge=1) - -class ChatBody(BaseModel): - nickname: str = Field(default=None, min_length=1, max_length=32) - icon: str = Field(default=None, max_length=24) - icon_color: str = Field(default=None, min_length=6, max_length=6) # hex code without the # - allow_pinning: bool = Field(default=None) - - class Config: - validate_assignment = True - str_strip_whitespace = True - -class EmoteBody(BaseModel): - name: Optional[str] = Field(default=None, min_length=1, max_length=32) - - -@chats_bp.get("/") -async def get_chats(): - # Check authorization - if not request.user: - abort(401) - - # Get chats - chats = app.supporter.get_chats(request.user) - - # Add emotes - [chat.update({ - "emojis": list(db.chat_emojis.find({ - "chat_id": chat["_id"] - }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})), - "stickers": list(db.chat_stickers.find({ - "chat_id": chat["_id"] - }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})) - }) for chat in chats] - - # Get and return chats - return { - "error": False, - "autoget": chats, - "page#": 1, - "pages": 1 - }, 200 - - -@chats_bp.post("/") -@validate_request(ChatBody) -async def create_chat(data: ChatBody): - # Check authorization - if not request.user: - abort(401) - - # Check ratelimit - if security.ratelimited(f"create_chat:{request.user}"): - abort(429) - - # Ratelimit - security.ratelimit(f"create_chat:{request.user}", 5, 30) - - # Check restrictions - if security.is_restricted(request.user, security.Restrictions.NEW_CHATS): - return {"error": True, "type": "accountBanned"}, 403 - - # Make sure the requester isn't in too many chats - if db.chats.count_documents({"type": 0, "members": request.user}, limit=150) >= 150: - return {"error": True, "type": "tooManyChats"}, 403 - - # Claim icon - if data.icon: - try: - claim_file(data.icon, "icons") - except Exception as e: - log(f"Unable to claim icon: {e}") - return {"error": True, "type": "unableToClaimIcon"}, 500 - - # Create chat - if data.icon is None: - data.icon = "" - if data.icon_color is None: - data.icon_color = "000000" - if data.allow_pinning is None: - data.allow_pinning = False - chat = { - "_id": str(uuid.uuid4()), - "type": 0, - "nickname": data.nickname, - "icon": data.icon, - "icon_color": data.icon_color, - "owner": request.user, - "members": [request.user], - "created": int(time.time()), - "last_active": int(time.time()), - "deleted": False, - "allow_pinning": data.allow_pinning - } - db.chats.insert_one(chat) - - # Add emotes - chat.update({ - "emojis": list(db.chat_emojis.find({ - "chat_id": chat["_id"] - }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})), - "stickers": list(db.chat_stickers.find({ - "chat_id": chat["_id"] - }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})) - }) - - - # Tell the requester the chat was created - app.cl.send_event("create_chat", chat, usernames=[request.user]) - - # Return chat - chat["error"] = False - return chat, 200 - - -@chats_bp.get("/") -async def get_chat(chat_id): - # Check authorization - if not request.user: - abort(401) - - # Get chat - chat = db.chats.find_one({"_id": chat_id, "members": request.user, "deleted": False}) - if not chat: - abort(404) - - # Return chat - chat.update({ - "error": False, - "emojis": list(db.chat_emojis.find({ - "chat_id": chat["_id"] - }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})), - "stickers": list(db.chat_stickers.find({ - "chat_id": chat["_id"] - }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})) - }) - return chat, 200 - - -@chats_bp.patch("/") -@validate_request(ChatBody) -async def update_chat(chat_id, data: ChatBody): - # Check authorization - if not request.user: - abort(401) - - # Check ratelimit - if security.ratelimited(f"update_chat:{request.user}"): - abort(429) - - # Ratelimit - security.ratelimit(f"update_chat:{request.user}", 5, 5) - - # Check restrictions - if security.is_restricted(request.user, security.Restrictions.EDITING_CHAT_DETAILS): - return {"error": True, "type": "accountBanned"}, 403 - - # Get chat - chat = db.chats.find_one({"_id": chat_id, "members": request.user, "deleted": False}) - if not chat: - abort(404) - - # Make sure requester is owner - if chat["owner"] != request.user: - abort(403) - - # Get updated values - updated_vals = {"_id": chat_id} - if data.nickname is not None and chat["nickname"] != data.nickname: - updated_vals["nickname"] = data.nickname - app.supporter.create_post(chat_id, "Server", f"@{request.user} changed the nickname of the group chat to '{chat['nickname']}'.", chat_members=chat["members"]) - if data.icon is not None and chat["icon"] != data.icon: - # Claim icon (and delete old one) - if data.icon != "": - try: - updated_vals["icon"] = claim_file(data.icon, "icons")["id"] - except Exception as e: - log(f"Unable to claim icon: {e}") - return {"error": True, "type": "unableToClaimIcon"}, 500 - if chat["icon"]: - try: - delete_file(chat["icon"]) - except Exception as e: - log(f"Unable to delete icon: {e}") - app.supporter.create_post(chat_id, "Server", f"@{request.user} changed the icon of the group chat.", chat_members=chat["members"]) - if data.icon_color is not None and chat["icon_color"] != data.icon_color: - updated_vals["icon_color"] = data.icon_color - if data.icon is None or chat["icon"] == data.icon: - app.supporter.create_post(chat_id, "Server", f"@{request.user} changed the icon of the group chat.", chat_members=chat["members"]) - if data.allow_pinning is not None: - updated_vals["allow_pinning"] = data.allow_pinning - - # Update chat - db.chats.update_one({"_id": chat_id}, {"$set": updated_vals}) - - # Send update chat event - app.cl.send_event("update_chat", updated_vals, usernames=chat["members"]) - - # Return chat - chat.update({ - "error": False, - "emojis": list(db.chat_emojis.find({ - "chat_id": chat["_id"] - }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})), - "stickers": list(db.chat_stickers.find({ - "chat_id": chat["_id"] - }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})) - }) - return chat, 200 - - -@chats_bp.delete("/") -async def leave_chat(chat_id): - # Check authorization - if not request.user: - abort(401) - - # Check ratelimit - if security.ratelimited(f"update_chat:{request.user}"): - abort(429) - - # Ratelimit - security.ratelimit(f"update_chat:{request.user}", 5, 5) - - # Get chat - chat = db.chats.find_one({"_id": chat_id, "members": request.user, "deleted": False}) - if not chat: - abort(404) - - if chat["type"] == 0: - # Remove member - chat["members"].remove(request.user) - - # Update chat if it's not empty, otherwise delete the chat - if len(chat["members"]) > 0: - # Transfer ownership, if owner - if chat["owner"] == request.user: - chat["owner"] = chat["members"][0] - - # Update chat - db.chats.update_one({"_id": chat_id}, { - "$set": {"owner": chat["owner"]}, - "$pull": {"members": request.user} - }) - - # Send update chat event - app.cl.send_event("update_chat", { - "_id": chat_id, - "owner": chat["owner"], - "members": chat["members"] - }, usernames=chat["members"]) - - # Send in-chat notification - app.supporter.create_post(chat_id, "Server", f"@{request.user} has left the group chat.", chat_members=chat["members"]) - else: - if chat["icon"]: - try: - delete_file(chat["icon"]) - except Exception as e: - log(f"Unable to delete icon: {e}") - db.posts.delete_many({"post_origin": chat_id, "isDeleted": False}) - db.chats.delete_one({"_id": chat_id}) - elif chat["type"] == 1: - # Remove chat from requester's active DMs list - db.user_settings.update_one({"_id": request.user}, { - "$pull": {"active_dms": chat_id} - }) - else: - abort(500) - - # Send delete event to client - app.cl.send_event("delete_chat", {"chat_id": chat_id}, usernames=[request.user]) - - return {"error": False}, 200 - - -@chats_bp.post("//typing") -async def emit_typing(chat_id): - # Check authorization - if not request.user: - abort(401) - - # Check ratelimit - if security.ratelimited(f"typing:{request.user}"): - abort(429) - - # Ratelimit - security.ratelimit(f"typing:{request.user}", 6, 5) - - # Check restrictions - if security.is_restricted(request.user, security.Restrictions.CHAT_POSTS): - return {"error": True, "type": "accountBanned"}, 403 - - # Get chat - if chat_id != "livechat": - chat = db.chats.find_one({ - "_id": chat_id, - "members": request.user, - "deleted": False - }, projection={"members": 1}) - if not chat: - abort(404) - - # Send typing event - app.cl.send_event("typing", { - "chat_id": chat_id, "username": request.user - }, usernames=(None if chat_id == "livechat" else chat["members"])) - - return {"error": False}, 200 - - -@chats_bp.put("//members/") -async def add_chat_member(chat_id, username): - # Check authorization - if not request.user: - abort(401) - - # Check ratelimit - if security.ratelimited(f"update_chat:{request.user}"): - abort(429) - - # Ratelimit - security.ratelimit(f"update_chat:{request.user}", 5, 5) - - # Check restrictions - if security.is_restricted(request.user, security.Restrictions.NEW_CHATS): - return {"error": True, "type": "accountBanned"}, 403 - - # Get chat - chat = db.chats.find_one({"_id": chat_id, "members": request.user, "deleted": False}) - if not chat: - abort(404) - - # Make sure the chat isn't full - if chat["type"] == 1 or len(chat["members"]) >= 256: - return {"error": True, "type": "chatFull"}, 403 - - # Make sure the user isn't already in the chat - if username in chat["members"]: - return {"error": True, "type": "chatMemberAlreadyExists"}, 409 - - # Make sure requested user exists and isn't deleted - user = db.usersv0.find_one({"_id": username}, projection={"permissions": 1}) - if (not user) or (user["permissions"] is None): - abort(404) - - # Make sure requested user isn't blocked or is blocking client - if db.relationships.count_documents({"$or": [ - { - "_id": {"from": request.user, "to": username}, - "state": 2 - }, - { - "_id": {"from": username, "to": request.user}, - "state": 2 - } - ]}, limit=1) > 0: - abort(403) - - # Update chat - chat["members"].append(username) - db.chats.update_one({"_id": chat_id}, {"$addToSet": {"members": username}}) - - # Send create chat event - app.cl.send_event("create_chat", chat, usernames=[username]) - - # Send update chat event - app.cl.send_event("update_chat", { - "_id": chat_id, - "members": chat["members"] - }, usernames=chat["members"]) - - # Send inbox message to user - app.supporter.create_post("inbox", username, f"You have been added to the group chat '{chat['nickname']}' by @{request.user}!") - - # Send in-chat notification - app.supporter.create_post(chat_id, "Server", f"@{request.user} added @{username} to the group chat.", chat_members=chat["members"]) - - # Return chat - chat.update({ - "error": False, - "emojis": list(db.chat_emojis.find({ - "chat_id": chat["_id"] - }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})), - "stickers": list(db.chat_stickers.find({ - "chat_id": chat["_id"] - }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})) - }) - return chat, 200 - - -@chats_bp.delete("//members/") -async def remove_chat_member(chat_id, username): - # Check authorization - if not request.user: - abort(401) - - # Check ratelimit - if security.ratelimited(f"update_chat:{request.user}"): - abort(429) - - # Ratelimit - security.ratelimit(f"update_chat:{request.user}", 5, 5) - - # Get chat - chat = db.chats.find_one({ - "_id": chat_id, - "members": {"$all": [request.user, username]}, - "deleted": False - }) - if not chat: - abort(404) - - # Make sure requester is owner - if chat["owner"] != request.user: - abort(403) - - # Update chat - chat["members"].remove(username) - db.chats.update_one({"_id": chat_id}, {"$pull": {"members": username}}) - - # Send delete chat event to user - app.cl.send_event("delete_chat", {"chat_id": chat_id}, usernames=[username]) - - # Send update chat event - app.cl.send_event("update_chat", { - "_id": chat_id, - "members": chat["members"] - }, usernames=chat["members"]) - - # Send inbox message to user - app.supporter.create_post("inbox", username, f"You have been removed from the group chat '{chat['nickname']}' by @{request.user}!") - - # Send in-chat notification - app.supporter.create_post(chat_id, "Server", f"@{request.user} removed @{username} from the group chat.", chat_members=chat["members"]) - - # Return chat - chat.update({ - "error": False, - "emojis": list(db.chat_emojis.find({ - "chat_id": chat["_id"] - }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})), - "stickers": list(db.chat_stickers.find({ - "chat_id": chat["_id"] - }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})) - }) - return chat, 200 - - -@chats_bp.post("//members//transfer") -async def transfer_chat_ownership(chat_id, username): - # Check authorization - if not request.user: - abort(401) - - # Check ratelimit - if security.ratelimited(f"update_chat:{request.user}"): - abort(429) - - # Ratelimit - security.ratelimit(f"update_chat:{request.user}", 5, 5) - - # Get chat - chat = db.chats.find_one({ - "_id": chat_id, - "members": {"$all": [request.user, username]}, - "deleted": False - }) - if not chat: - abort(404) - - # Make sure requester is owner - if chat["owner"] != request.user: - abort(403) - - # Make sure requested user isn't already owner - if chat["owner"] == username: - chat["error"] = False - return chat, 200 - - # Update chat - chat["owner"] = username - db.chats.update_one({"_id": chat_id}, {"$set": {"owner": username}}) - - # Send update chat event - app.cl.send_event("update_chat", { - "_id": chat_id, - "owner": chat["owner"] - }, usernames=chat["members"]) - - # Send in-chat notification - app.supporter.create_post(chat_id, "Server", f"@{request.user} transferred ownership of the group chat to @{username}.", chat_members=chat["members"]) - - # Return chat - chat.update({ - "error": False, - "emojis": list(db.chat_emojis.find({ - "chat_id": chat["_id"] - }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})), - "stickers": list(db.chat_stickers.find({ - "chat_id": chat["_id"] - }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})) - }) - return chat, 200 - - -@chats_bp.get("//pins") -@validate_querystring(GetPostsQueryArgs) -def get_chat_pins(chat_id, query_args: GetPostsQueryArgs): - # Check authorization - if not request.user: - abort(401) - - # Make sure chat exists and requester has access - if not db.chats.count_documents({ - "_id": chat_id, - "members": request.user, - "deleted": False - }, limit=1): - abort(404) - - # Get and return pinned posts - query = {"post_origin": chat_id, "pinned": True} - return { - "error": False, - "autoget": app.supporter.parse_posts_v0( - db.posts.find( - query, - sort=[("t.e", pymongo.DESCENDING)], - skip=(query_args.page-1)*25, - limit=25 - ), - include_replies=True, - requester=request.user - ), - "page#": query_args.page, - "pages": (get_total_pages("posts", query) if request.user else 1) - }, 200 - - -@chats_bp.get("//") -async def get_chat_emotes(chat_id: str, emote_type: Literal["emojis", "stickers"]): - # Make sure emote type is valid - if emote_type not in ["emojis", "stickers"]: - abort(404) - - # Check authorization - if not request.user: - abort(401) - - # Make sure chat exists and requester has access - if not db.chats.count_documents({ - "_id": chat_id, - "members": request.user, - "deleted": False - }, limit=1): - abort(404) - - # Get and return emotes - return { - "error": False, - "autoget": list(db[f"chat_{emote_type}"].find({ - "chat_id": chat_id - }, sort=[("created_at", pymongo.DESCENDING)], projection={ - "chat_id": 0, - "created_at": 0, - "created_by": 0 - })), - "page#": 1, - "pages": 1 - }, 200 - - -@chats_bp.get("///") -async def get_chat_emote(chat_id: str, emote_type: Literal["emojis", "stickers"], emote_id: str): - # Make sure emote type is valid - if emote_type not in ["emojis", "stickers"]: - abort(404) - - # Check authorization - if not request.user: - abort(401) - - # Make sure chat exists and requester has access - if not db.chats.count_documents({ - "_id": chat_id, - "members": request.user, - "deleted": False - }, limit=1): - abort(404) - - # Get emote - emote = db[f"chat_{emote_type}"].find_one({ - "_id": emote_id, - "chat_id": chat_id - }, projection={"chat_id": 0, "created_at": 0, "created_by": 0}) - if not emote: - abort(404) - - # Return emote - emote["error"] = False - return emote, 200 - - -@chats_bp.put("///") -@validate_request(EmoteBody) -async def create_chat_emote(chat_id: str, emote_type: Literal["emojis", "stickers"], emote_id: str, data: EmoteBody): - # Make sure emote type is valid - if emote_type not in ["emojis", "stickers"]: - abort(404) - - # Check authorization - if not request.user: - abort(401) - - # Check ratelimit - if security.ratelimited(f"update_chat:{request.user}"): - abort(429) - - # Ratelimit - security.ratelimit(f"update_chat:{request.user}", 5, 5) - - # Get chat - chat = db.chats.find_one({ - "_id": chat_id, - "members": {"$all": [request.user]}, - "deleted": False - }) - if not chat: - abort(404) - - # Make sure chat is DM or requester is owner - if chat["type"] != 1 and chat["owner"] != request.user: - abort(403) - - # Make sure there's not too many emotes in the chat (250 for emojis, 50 for stickers) - if emote_type == "emojis": - if db.chat_emojis.count_documents({"chat_id": chat_id}, limit=250) >= int(os.getenv("CHAT_EMOJIS_LIMIT", 250)): - return {"error": True, "type": "tooManyEmojis"}, 403 - elif emote_type == "stickers": - if db.chat_stickers.count_documents({"chat_id": chat_id}, limit=50) >= int(os.getenv("CHAT_STICKERS_LIMIT", 50)): - return {"error": True, "type": "tooManyStickers"}, 403 - - # Claim file - try: - file = claim_file(emote_id, emote_type) - except Exception as e: - log(f"Unable to claim emote: {e}") - return {"error": True, "type": "unableToClaimEmote"}, 500 - - # Fall back to filename if no name is specified - if not data.name: - name_pattern = re.compile(r'[^A-Za-z0-9\-\_]') - data.name = name_pattern.sub("_", file["filename"])[:20] - - # Create emote - emote = { - "_id": emote_id, - "chat_id": chat_id, - "name": data.name, - "animated": (file["mime"] == "image/gif"), - "created_at": int(time.time()), - "created_by": request.user - } - db[f"chat_{emote_type}"].insert_one(emote) - del emote["created_at"] - del emote["created_by"] - app.cl.send_event(f"create_{emote_type[:-1]}", emote, usernames=chat["members"]) - - # Return new emote - del emote["chat_id"] - emote["error"] = False - return emote, 200 - - -@chats_bp.patch("///") -@validate_request(EmoteBody) -async def update_chat_emote(chat_id: str, emote_type: Literal["emojis", "stickers"], emote_id: str, data: EmoteBody): - # Make sure emote type is valid - if emote_type not in ["emojis", "stickers"]: - abort(404) - - # Make sure a new name is being specified - if not data.name: - abort(400) - - # Check authorization - if not request.user: - abort(401) - - # Check ratelimit - if security.ratelimited(f"update_chat:{request.user}"): - abort(429) - - # Ratelimit - security.ratelimit(f"update_chat:{request.user}", 5, 5) - - # Get chat - chat = db.chats.find_one({ - "_id": chat_id, - "members": {"$all": [request.user]}, - "deleted": False - }) - if not chat: - abort(404) - - # Make sure chat is DM or requester is owner - if chat["type"] != 1 and chat["owner"] != request.user: - abort(403) - - # Get emote - emote = db[f"chat_{emote_type}"].find_one({ - "_id": emote_id, - "chat_id": chat_id - }, projection={"chat_id": 0, "created_at": 0, "created_by": 0}) - if not emote: - abort(404) - - # Update emote name - emote["name"] = data.name - db[f"chat_{emote_type}"].update_one({"_id": emote_id}, {"$set": {"name": data.name}}) - app.cl.send_event(f"update_{emote_type[:-1]}", { - "_id": emote_id, - "chat_id": chat_id, - "name": data.name - }, usernames=chat["members"]) - - # Return updated emote - emote["error"] = False - return emote, 200 - - -@chats_bp.delete("///") -async def delete_chat_emote(chat_id: str, emote_type: Literal["emojis", "stickers"], emote_id: str): - # Make sure emote type is valid - if emote_type not in ["emojis", "stickers"]: - abort(404) - - # Check authorization - if not request.user: - abort(401) - - # Check ratelimit - if security.ratelimited(f"update_chat:{request.user}"): - abort(429) - - # Ratelimit - security.ratelimit(f"update_chat:{request.user}", 5, 5) - - # Get chat - chat = db.chats.find_one({ - "_id": chat_id, - "members": {"$all": [request.user]}, - "deleted": False - }) - if not chat: - abort(404) - - # Make sure chat is DM or requester is owner - if chat["type"] != 1 and chat["owner"] != request.user: - abort(403) - - # Delete emote - result = db[f"chat_{emote_type}"].delete_one({"_id": emote_id, "chat_id": chat_id}) - if not result.deleted_count: - abort(404) - app.cl.send_event(f"delete_{emote_type[:-1]}", { - "_id": emote_id, - "chat_id": chat_id - }, usernames=chat["members"]) - delete_file(emote_id) - - return {"error": False}, 200 diff --git a/rest_api/v0/home.py b/rest_api/v0/home.py deleted file mode 100644 index 898697c..0000000 --- a/rest_api/v0/home.py +++ /dev/null @@ -1,142 +0,0 @@ -from quart import Blueprint, current_app as app, request, abort -from quart_schema import validate_querystring, validate_request -from pydantic import BaseModel, Field -from typing import Optional -import pymongo, copy - -import security -from database import db, get_total_pages -from uploads import claim_file -from utils import log - - -home_bp = Blueprint("home_bp", __name__, url_prefix="/home") - - -class GetHomeQueryArgs(BaseModel): - page: Optional[int] = Field(default=1, ge=1) - -class PostBody(BaseModel): - content: Optional[str] = Field(default="", max_length=4000) - nonce: Optional[str] = Field(default=None, max_length=64) - attachments: Optional[list[str]] = Field(default_factory=list) - reply_to: Optional[list[str]] = Field(default_factory=list) - stickers: Optional[list[str]] = Field(default_factory=list) - - class Config: - validate_assignment = True - str_strip_whitespace = True - - -@home_bp.get("/") -@validate_querystring(GetHomeQueryArgs) -async def get_home_posts(query_args: GetHomeQueryArgs): - if not request.user: - query_args.page = 1 - query = {"post_origin": "home", "isDeleted": False} - return { - "error": False, - "autoget": app.supporter.parse_posts_v0(db.posts.find( - query, - sort=[("t.e", pymongo.DESCENDING)], - skip=(query_args.page-1)*25, - limit=25 - ), requester=request.user), - "page#": query_args.page, - "pages": (get_total_pages("posts", query) if request.user else 1) - }, 200 - - -@home_bp.post("/") -@validate_request(PostBody) -async def create_home_post(data: PostBody): - # Check authorization - if not request.user: - abort(401) - - if not (request.flags & security.UserFlags.POST_RATELIMIT_BYPASS): - # Check ratelimit - if security.ratelimited(f"post:{request.user}"): - abort(429) - - # Ratelimit - security.ratelimit(f"post:{request.user}", 6, 5) - - # Check restrictions - if security.is_restricted(request.user, security.Restrictions.HOME_POSTS): - return {"error": True, "type": "accountBanned"}, 403 - - # Make sure there's not too many attachments - if len(data.attachments) > 10: - return {"error": True, "type": "tooManyAttachments"}, 400 - - # Make sure the post isn't replying to too many posts - if len(data.reply_to) > 10: - return {"error": True, "type": "tooManyReplies"}, 400 - - # Make sure there's not too many stickers - if len(data.stickers) > 10: - return {"error": True, "type": "tooManyStickers"}, 400 - - # Make sure stickers exist - for sticker_id in copy.copy(data.stickers): - if not db.chat_stickers.count_documents({"_id": sticker_id}, limit=1): - data.stickers.remove(sticker_id) - - # Make sure replied to post IDs exist and are unique - unique_reply_to_post_ids = [] - for post_id in data.reply_to: - if db.posts.count_documents({"_id": post_id, "post_origin": "home"}, limit=1) and \ - post_id not in unique_reply_to_post_ids: - unique_reply_to_post_ids.append(post_id) - - # Claim attachments - attachments = [] - for attachment_id in set(data.attachments): - try: - attachments.append(claim_file(attachment_id, "attachments")) - except Exception as e: - log(f"Unable to claim attachment: {e}") - return {"error": True, "type": "unableToClaimAttachment"}, 500 - - # Make sure the post has text content or at least 1 attachment or at least 1 sticker - if not data.content and not attachments and not data.stickers: - abort(400) - - # Create post - post = app.supporter.create_post( - "home", - request.user, - data.content, - attachments=attachments, - stickers=data.stickers, - nonce=data.nonce, - reply_to=unique_reply_to_post_ids - ) - - # Return new post - post["error"] = False - return app.supporter.parse_posts_v0([post], requester=request.user)[0], 200 - - -@home_bp.post("/typing") -async def emit_typing(): - # Check authorization - if not request.user: - abort(401) - - # Check ratelimit - if security.ratelimited(f"typing:{request.user}"): - abort(429) - - # Ratelimit - security.ratelimit(f"typing:{request.user}", 6, 5) - - # Check restrictions - if security.is_restricted(request.user, security.Restrictions.HOME_POSTS): - return {"error": True, "type": "accountBanned"}, 403 - - # Send new state - app.cl.send_event("typing", {"chat_id": "home", "username": request.user}) - - return {"error": False}, 200 diff --git a/rest_api/v0/posts.py b/rest_api/v0/posts.py deleted file mode 100644 index 5176432..0000000 --- a/rest_api/v0/posts.py +++ /dev/null @@ -1,722 +0,0 @@ -from quart import Blueprint, current_app as app, request, abort -from quart_schema import validate_querystring, validate_request -from pydantic import BaseModel, Field -from typing import Optional -from threading import Thread -from copy import copy -import pymongo, uuid, time, emoji - -import security -from database import db, get_total_pages -from uploads import claim_file, delete_file -from utils import log - - -posts_bp = Blueprint("posts_bp", __name__, url_prefix="/posts") - - -class PostIdQueryArgs(BaseModel): - id: str = Field() - -class PagedQueryArgs(BaseModel): - page: Optional[int] = Field(default=1, ge=1) - -class PostBody(BaseModel): - content: Optional[str] = Field(default="", max_length=4000) - nonce: Optional[str] = Field(default=None, max_length=64) - attachments: Optional[list[str]] = Field(default_factory=list) - reply_to: Optional[list[str]] = Field(default_factory=list) - stickers: Optional[list[str]] = Field(default_factory=list) - - class Config: - validate_assignment = True - str_strip_whitespace = True - -class ReportBody(BaseModel): - reason: str = Field(default="No reason provided", max_length=2000) - comment: str = Field(default="", max_length=2000) - - class Config: - validate_assignment = True - str_strip_whitespace = True - - -@posts_bp.get("/") -@validate_querystring(PostIdQueryArgs) -async def get_post(query_args: PostIdQueryArgs): - # Get post - post = db.posts.find_one({"_id": query_args.id, "isDeleted": False}) - if not post: - abort(404) - - # Check access - if (post["post_origin"] == "inbox") and (post["u"] not in ["Server", request.user]): - abort(404) - elif post["post_origin"] not in ["home", "inbox"]: - if not db.chats.count_documents({ - "_id": post["post_origin"], - "members": request.user, - "deleted": False - }, limit=1): - abort(404) - - # Return post - post["error"] = False - return app.supporter.parse_posts_v0([post], requester=request.user)[0], 200 - - -@posts_bp.patch("/") -@validate_querystring(PostIdQueryArgs) -@validate_request(PostBody) -async def update_post(query_args: PostIdQueryArgs, data: PostBody): - # Check authorization - if not request.user: - abort(401) - - if not (request.flags & security.UserFlags.POST_RATELIMIT_BYPASS): - # Check ratelimit - if security.ratelimited(f"post:{request.user}"): - abort(429) - - # Ratelimit - security.ratelimit(f"post:{request.user}", 6, 5) - - # Get post - post = db.posts.find_one({"_id": query_args.id, "isDeleted": False}) - if not post: - abort(404) - - # Check access - if (post["post_origin"] == "inbox") and (post["u"] != request.user): - abort(404) - elif post["post_origin"] not in ["home", "inbox"]: - chat = db.chats.find_one({ - "_id": post["post_origin"], - "members": request.user, - "deleted": False - }) - if not chat: - abort(404) - - # Check permissions - if post["post_origin"] == "inbox" or post["u"] != request.user: - abort(403) - - # Check restrictions - if post["post_origin"] == "home" and security.is_restricted(request.user, security.Restrictions.HOME_POSTS): - return {"error": True, "type": "accountBanned"}, 403 - elif post["post_origin"] != "home" and security.is_restricted(request.user, security.Restrictions.CHAT_POSTS): - return {"error": True, "type": "accountBanned"}, 403 - - # Make sure new content isn't the same as the old content - if post["p"] == data.content: - post["error"] = False - return app.supporter.parse_posts_v0([post], requester=request.user)[0], 200 - - # Make sure the post has text content - if not data.content: - abort(400) - - # Add revision - db.post_revisions.insert_one({ - "_id": str(uuid.uuid4()), - "post_id": post["_id"], - "old_content": post["p"], - "new_content": data.content, - "time": int(time.time()) - }) - - # Update post - post["edited_at"] = int(time.time()) - post["p"] = data.content - db.posts.update_one({"_id": query_args.id}, {"$set": { - "p": post["p"], - "edited_at": post["edited_at"] - }}) - - # Send update post event - app.cl.send_event("update_post", post, usernames=(None if post["post_origin"] == "home" else chat["members"])) - - # Return post - post["error"] = False - return app.supporter.parse_posts_v0([post], requester=request.user)[0], 200 - -@posts_bp.post("//report") -@validate_request(ReportBody) -async def report_post(post_id, data: ReportBody): - if not request.user: - abort(401) - post = db.posts.find_one({"_id": post_id}) - if not post: - abort(404) - - security.ratelimit(f"report:{request.user}", 3, 5) - - report = db.reports.find_one({ - "content_id": post_id, - "status": "pending", - "type": "post" - }) - - if not report: - report = { - "_id": str(uuid.uuid4()), - "type": "post", - "content_id": post_id, - "status": "pending", - "escalated": False, - "reports": [] - } - - for _report in report["reports"]: - if _report["user"] == request.user: - report["reports"].remove(_report) - break - - report["reports"].append({ - "user": request.user, - "ip": request.ip, - "reason": data.reason, - "comment": data.comment, - "time": int(time.time()) - }) - - db.reports.update_one({"_id": report["_id"]}, {"$set": report}, upsert=True) - - unique_ips = set([_report["ip"] for _report in report["reports"]]) - - if report["status"] == "pending" and not report["escalated"] and len(unique_ips) >= 3: - db.reports.update_one({"_id": report["_id"]}, {"$set": {"escalated": True}}) - db.posts.update_one({"_id": post_id, "isDeleted": False}, {"$set": { - "isDeleted": True, - "mod_deleted": True, - "deleted_at": int(time.time()) - }}) - - return {"error": False}, 200 - - -@posts_bp.post("//pin") -async def pin_post(post_id): - if not request.user: - abort(401) - post = db.posts.find_one({"_id": post_id}) - if not post: - abort(404) - query = {"_id": post["post_origin"]} - - has_perm = security.has_permission(request.permissions, security.AdminPermissions.EDIT_CHATS) - if not has_perm: - query["members"] = request.user - query["deleted"] = False - - - - chat = db.chats.find_one(query) - if not chat: - abort(401) - - if not (request.user == chat["owner"] or chat["allow_pinning"] or has_perm): - abort(401) - - db.posts.update_one({"_id": post_id}, {"$set": { - "pinned": True - }}) - - post["pinned"] = True - - app.cl.send_event("update_post", post, usernames=(None if post["post_origin"] == "home" else chat["members"])) - - post["error"] = False - return app.supporter.parse_posts_v0([post], requester=request.user)[0], 200 - - -@posts_bp.delete("//pin") -async def unpin_post(post_id): - if not request.user: - abort(401) - - post = db.posts.find_one({"_id": post_id}) - if not post: - abort(404) - - query = {"_id": post["post_origin"]} - has_perm = security.has_permission(request.permissions, security.AdminPermissions.EDIT_CHATS) - if not has_perm: - query["members"] = request.user - query["deleted"] = False - - chat = db.chats.find_one(query) - if not chat: - abort(401) - - if not (request.user == chat["owner"] or chat["allow_pinning"] or has_perm): - abort(401) - - - db.posts.update_one({"_id": post_id}, {"$set": { - "pinned": False - }}) - - post["pinned"] = False - - app.cl.send_event("update_post", post, usernames=(None if post["post_origin"] == "home" else chat["members"])) - - post["error"] = False - return app.supporter.parse_posts_v0([post], requester=request.user)[0], 200 - - -@posts_bp.delete("//attachments/") -async def delete_attachment(post_id: str, attachment_id: str): - # Check authorization - if not request.user: - abort(401) - - # Get post - post = db.posts.find_one({"_id": post_id, "isDeleted": False}) - if not post: - abort(404) - - # Check access - if (post["post_origin"] == "inbox") and (post["u"] != request.user): - abort(404) - elif post["post_origin"] not in ["home", "inbox"]: - chat = db.chats.find_one({ - "_id": post["post_origin"], - "members": request.user, - "deleted": False - }) - if not chat: - abort(404) - - # Check permissions - if post["post_origin"] == "inbox" or post["u"] != request.user: - abort(403) - - # Delete attachment - for attachment in copy(post["attachments"]): - if attachment["id"] == attachment_id: - try: - delete_file(attachment_id) - except Exception as e: - log(f"Unable to delete attachment: {e}") - post["attachments"].remove(attachment) - - if post["p"] or post["attachments"] > 0: - # Update post - db.posts.update_one({"_id": post_id}, {"$set": { - "attachments": post["attachments"] - }}) - - # Send update post event - app.cl.send_event("update_post", post, usernames=(None if post["post_origin"] == "home" else chat["members"])) - else: # delete post if no content and attachments remain - # Update post - db.posts.update_one({"_id": post_id}, {"$set": { - "isDeleted": True, - "deleted_at": int(time.time()) - }}) - - # Send delete post event - app.cl.send_event("delete_post", { - "chat_id": post["post_origin"], - "post_id": post_id - }, usernames=(None if post["post_origin"] == "home" else chat["members"])) - - # Return post - post["error"] = False - return app.supporter.parse_posts_v0([post], requester=request.user)[0], 200 - - -@posts_bp.delete("/") -@validate_querystring(PostIdQueryArgs) -async def delete_post(query_args: PostIdQueryArgs): - # Check authorization - if not request.user: - abort(401) - - if not (request.flags & security.UserFlags.POST_RATELIMIT_BYPASS): - # Check ratelimit - if security.ratelimited(f"post:{request.user}"): - abort(429) - - # Ratelimit - security.ratelimit(f"post:{request.user}", 6, 5) - - # Get post - post = db.posts.find_one({"_id": query_args.id, "isDeleted": False}) - if not post: - abort(404) - - # Check access - if post["post_origin"] not in {"home", "inbox"}: - chat = db.chats.find_one({ - "_id": post["post_origin"], - "members": request.user, - "deleted": False - }, projection={"owner": 1, "members": 1}) - if not chat: - abort(404) - if post["post_origin"] == "inbox" or post["u"] != request.user: - if (post["post_origin"] in ["home", "inbox"]) or (chat["owner"] != request.user): - abort(403) - - # Delete attachments - for attachment in post["attachments"]: - try: - delete_file(attachment["id"]) - except Exception as e: - log(f"Unable to delete attachment: {e}") - - # Update post - db.posts.update_one({"_id": query_args.id}, {"$set": { - "isDeleted": True, - "deleted_at": int(time.time()) - }}) - - # Send delete post event - app.cl.send_event("delete_post", { - "chat_id": post["post_origin"], - "post_id": query_args.id - }, usernames=(None if post["post_origin"] == "home" else chat["members"])) - - return {"error": False}, 200 - - -@posts_bp.get("/") -@validate_querystring(PagedQueryArgs) -async def get_chat_posts(chat_id, query_args: PagedQueryArgs): - # Check authorization - if not request.user: - abort(401) - - # Make sure chat exists - if not db.chats.count_documents({ - "_id": chat_id, - "members": request.user, - "deleted": False - }, limit=1): - abort(404) - - # Get and return posts - query = {"post_origin": chat_id, "isDeleted": False} - return { - "error": False, - "autoget": app.supporter.parse_posts_v0(db.posts.find( - query, - sort=[("t.e", pymongo.DESCENDING)], - skip=(query_args.page-1)*25, - limit=25 - ), requester=request.user), - "page#": query_args.page, - "pages": (get_total_pages("posts", query) if request.user else 1) - }, 200 - - -@posts_bp.post("/") -@validate_request(PostBody) -async def create_chat_post(chat_id, data: PostBody): - # Check authorization - if not request.user: - abort(401) - - if not (request.flags & security.UserFlags.POST_RATELIMIT_BYPASS): - # Check ratelimit - if security.ratelimited(f"post:{request.user}"): - abort(429) - - # Ratelimit - security.ratelimit(f"post:{request.user}", 6, 5) - - # Check restrictions - if security.is_restricted(request.user, security.Restrictions.CHAT_POSTS): - return {"error": True, "type": "accountBanned"}, 403 - - # Make sure there's not too many attachments - if len(data.attachments) > 10: - return {"error": True, "type": "tooManyAttachments"}, 400 - - # Make sure the post isn't replying to too many posts - if len(data.reply_to) > 10: - return {"error": True, "type": "tooManyReplies"}, 400 - - # Make sure there's not too many stickers - if len(data.stickers) > 10: - return {"error": True, "type": "tooManyStickers"}, 400 - - # Make sure stickers exist - for sticker_id in copy(data.stickers): - if not db.chat_stickers.count_documents({"_id": sticker_id}, limit=1): - data.stickers.remove(sticker_id) - - # Make sure replied to post IDs exist and are unique - unique_reply_to_post_ids = [] - if chat_id != "livechat": - for post_id in data.reply_to: - if db.posts.count_documents({"_id": post_id, "post_origin": chat_id}, limit=1) and \ - post_id not in unique_reply_to_post_ids: - unique_reply_to_post_ids.append(post_id) - - # Claim attachments - attachments = [] - if chat_id != "livechat": - for attachment_id in set(data.attachments): - try: - attachments.append(claim_file(attachment_id, "attachments")) - except Exception as e: - log(f"Unable to claim attachment: {e}") - return {"error": True, "type": "unableToClaimAttachment"}, 500 - - # Make sure the post has text content or at least 1 attachment or at least 1 sticker - if not data.content and not attachments and not data.stickers: - abort(400) - - if chat_id != "livechat": - # Get chat - chat = db.chats.find_one({ - "_id": chat_id, - "members": request.user, - "deleted": False - }, projection={"type": 1, "members": 1}) - if not chat: - abort(404) - - # DM stuff - if chat["type"] == 1: - # Check privacy options - if db.relationships.count_documents({"$or": [ - {"_id": {"from": chat["members"][0], "to": chat["members"][1]}}, - {"_id": {"from": chat["members"][1], "to": chat["members"][0]}} - ], "state": 2}, limit=1) > 0: - abort(403) - - # Update user settings - Thread(target=db.user_settings.bulk_write, args=([ - pymongo.UpdateMany({"$or": [ - {"_id": chat["members"][0]}, - {"_id": chat["members"][1]} - ]}, {"$pull": {"active_dms": chat_id}}), - pymongo.UpdateMany({"$or": [ - {"_id": chat["members"][0]}, - {"_id": chat["members"][1]} - ]}, {"$push": {"active_dms": { - "$each": [chat_id], - "$position": 0, - "$slice": -150 - }}}) - ],)).start() - - # Create post - post = app.supporter.create_post( - chat_id, - request.user, - data.content, - attachments=attachments, - stickers=data.stickers, - nonce=data.nonce, - chat_members=(None if chat_id == "livechat" else chat["members"]), - reply_to=unique_reply_to_post_ids - ) - - # Return new post - post["error"] = False - return app.supporter.parse_posts_v0([post], requester=request.user)[0], 200 - -@posts_bp.get("//reactions/") -@validate_querystring(PagedQueryArgs) -async def get_post_reactors(query_args: PagedQueryArgs, post_id: str, emoji_reaction: str): - # Get necessary post details and check access - post = db.posts.find_one({ - "_id": post_id, - "isDeleted": {"$ne": True} - }, projection={"_id": 1, "post_origin": 1, "u": 1}) - if not post: - abort(404) - elif post["post_origin"] != "home" and not request.user: - abort(404) - elif post["post_origin"] == "inbox" and post["u"] not in ["Server", request.user]: - abort(404) - elif post["post_origin"] not in ["home", "inbox"]: - if not db.chats.count_documents({ - "_id": post["post_origin"], - "members": request.user, - "deleted": False - }, limit=1): - abort(404) - - # Get and return reactors - query = {"_id.post_id": post_id, "_id.emoji": emoji_reaction} - return { - "error": False, - "autoget": [security.get_account(r["_id"]["user"]) for r in db.post_reactions.find( - query, - sort=[("time", pymongo.DESCENDING)], - skip=(query_args.page-1)*25, - limit=25 - )], - "page#": query_args.page, - "pages": (get_total_pages("post_reactions", query) if request.user else 1) - }, 200 - -@posts_bp.post("//reactions/") -async def add_post_reaction(post_id: str, emoji_reaction: str): - # Check authorization - if not request.user: - abort(401) - - # Ratelimit - if security.ratelimited(f"react:{request.user}"): - abort(429) - security.ratelimit(f"react:{request.user}", 5, 5) - - # Check if the emoji is only one emoji, with support for variants - if not (emoji.purely_emoji(emoji_reaction) and len(emoji.distinct_emoji_list(emoji_reaction)) == 1): - # Check if the emoji is a custom emoji - if not db.chat_emojis.count_documents({"_id": emoji_reaction}, limit=1): - abort(400) - - # Get necessary post details and check access - post = db.posts.find_one({ - "_id": post_id, - "isDeleted": {"$ne": True} - }, projection={ - "_id": 1, - "post_origin": 1, - "u": 1, - "reactions": 1 - }) - if not post: - abort(404) - elif post["post_origin"] == "inbox" and post["u"] not in ["Server", request.user]: - abort(404) - elif post["post_origin"] not in ["home", "inbox"]: - if not db.chats.count_documents({ - "_id": post["post_origin"], - "members": request.user, - "deleted": False - }, limit=1): - abort(404) - - # Make sure there's not too many reactions (50) - if len(post["reactions"]) >= 50: - return {"error": True, "type": "tooManyReactions"}, 403 - - # Add reaction - db.post_reactions.update_one({"_id": { - "post_id": post["_id"], - "emoji": emoji_reaction, - "user": request.user - }}, {"$set": {"time": int(time.time())}}, upsert=True) - - # Update post - existing_reaction = None - for reaction in post["reactions"]: - if reaction["emoji"] == emoji_reaction: - existing_reaction = reaction - break - if existing_reaction: - existing_reaction["count"] = db.post_reactions.count_documents({ - "_id.post_id": post["_id"], - "_id.emoji": reaction["emoji"] - }) - else: - post["reactions"].append({ - "emoji": emoji_reaction, - "count": 1 - }) - db.posts.update_one({"_id": post["_id"]}, {"$set": { - "reactions": post["reactions"] - }}) - - # Send event - app.cl.send_event("post_reaction_add", { - "chat_id": post["post_origin"], - "post_id": post["_id"], - "emoji": emoji_reaction, - "username": request.user - }) - - return {"error": False}, 200 - -@posts_bp.delete("//reactions//") -async def remove_post_reaction(post_id: str, emoji_reaction: str, username: str): - # Check authorization - if not request.user: - abort(401) - - # @me -> requester - if username == "@me": - username = request.user - - # Ratelimit - if security.ratelimited(f"react:{request.user}"): - abort(429) - security.ratelimit(f"react:{request.user}", 5, 5) - - # Make sure reaction exists - if not db.post_reactions.count_documents({"_id": { - "post_id": post_id, - "emoji": emoji_reaction, - "user": username - }}, limit=1): - abort(404) - - # Get necessary post details and check access - post = db.posts.find_one({ - "_id": post_id, - "isDeleted": {"$ne": True} - }, projection={ - "_id": 1, - "post_origin": 1, - "u": 1, - "reactions": 1 - }) - if not post: - abort(404) - elif post["post_origin"] == "inbox" and post["u"] not in ["Server", request.user]: - abort(404) - elif post["post_origin"] not in ["home", "inbox"]: - chat = db.chats.find_one({ - "_id": post["post_origin"], - "members": request.user, - "deleted": False - }, projection={"owner": 1}) - if not chat: - abort(404) - - # Make sure requester can remove the reaction - if request.user != username: - if (post["post_origin"] in ["home", "inbox"]) or (chat["owner"] != request.user): - abort(403) - - # Remove reaction - db.post_reactions.delete_one({"_id": { - "post_id": post["_id"], - "emoji": emoji_reaction, - "user": username - }}) - - # Update post - for reaction in post["reactions"]: - if reaction["emoji"] != emoji_reaction: - continue - reaction["count"] = db.post_reactions.count_documents({ - "_id.post_id": post["_id"], - "_id.emoji": reaction["emoji"] - }) - if not reaction["count"]: - post["reactions"].remove(reaction) - break - db.posts.update_one({"_id": post["_id"]}, {"$set": { - "reactions": post["reactions"] - }}) - - # Send event - app.cl.send_event("post_reaction_remove", { - "chat_id": post["post_origin"], - "post_id": post["_id"], - "emoji": emoji_reaction, - "username": username - }) - - return {"error": False}, 200 diff --git a/rest_api/v0/users.py b/rest_api/v0/users.py deleted file mode 100644 index b5bc890..0000000 --- a/rest_api/v0/users.py +++ /dev/null @@ -1,240 +0,0 @@ -from quart import Blueprint, current_app as app, request, abort -from quart_schema import validate_querystring, validate_request -from pydantic import BaseModel, Field -from typing import Literal, Optional -import pymongo -import uuid -import time - -import security -from database import db, get_total_pages - - -users_bp = Blueprint("users_bp", __name__, url_prefix="/users/") - -class GetPostsQueryArgs(BaseModel): - page: Optional[int] = Field(default=1, ge=1) - -class UpdateRelationshipBody(BaseModel): - state: Literal[ - 0, # no relationship - #1, # following (doesn't do anything yet) - 2, # blocking - ] - - class Config: - validate_assignment = True - -class ReportBody(BaseModel): - reason: str = Field(default="No reason provided", min_length=1, max_length=2000) - comment: str = Field(default="", max_length=2000) - - class Config: - validate_assignment = True - str_strip_whitespace = True - -@users_bp.before_request -async def check_user_exists(): - username = request.view_args.get("username") - user = db.usersv0.find_one({"lower_username": username.lower()}, projection={"_id": 1, "flags": 1}) - if (not user) or (user["flags"] & security.UserFlags.DELETED == security.UserFlags.DELETED): - abort(404) - else: - request.view_args["username"] = user["_id"] - - -@users_bp.get("/") -async def get_user(username): - account = security.get_account(username, (request.user and request.user.lower() == username.lower())) - account["error"] = False - return account, 200 - - -@users_bp.get("/posts") -@validate_querystring(GetPostsQueryArgs) -async def get_user_posts(username, query_args: GetPostsQueryArgs): - query = {"post_origin": "home", "isDeleted": False, "u": username} - return { - "error": False, - "autoget": app.supporter.parse_posts_v0(db.posts.find( - query, - sort=[("t.e", pymongo.DESCENDING)], - skip=(query_args.page-1)*25, - limit=25 - ), requester=request.user), - "page#": query_args.page, - "pages": get_total_pages("posts", query) - }, 200 - - -@users_bp.get("/relationship") -async def get_relationship(username): - # Check authorization - if not request.user: - abort(401) - - # Make sure the requested user isn't the requester - if request.user == username: - abort(400) - - # Get relationship - relationship = db.relationships.find_one({"_id": {"from": request.user, "to": username}}) - - # Return relationship - if relationship: - del relationship["_id"] - return relationship, 200 - else: - return { - "state": 0, - "updated_at": None - }, 200 - - -@users_bp.patch("/relationship") -@validate_request(UpdateRelationshipBody) -async def update_relationship(username, data: UpdateRelationshipBody): - # Check authorization - if not request.user: - abort(401) - - # Check ratelimit - if security.ratelimited(f"relationships:{request.user}"): - abort(429) - - # Ratelimit - security.ratelimit(f"relationships:{request.user}", 10, 15) - - # Make sure the requested user isn't the requester - if request.user == username: - abort(400) - - # Get relationship - relationship = db.relationships.find_one({"_id": {"from": request.user, "to": username}}) - if not relationship: - relationship = { - "_id": {"from": request.user, "to": username}, - "state": 0, - "updated_at": None - } - - # Make sure the state relationship state changed - if data.state == relationship["state"]: - del relationship["_id"] - return relationship, 200 - - # Update relationship - relationship["state"] = data.state - relationship["updated_at"] = int(time.time()) - if data.state == 0: - db.relationships.delete_one({"_id": {"from": request.user, "to": username}}) - else: - db.relationships.update_one({"_id": {"from": request.user, "to": username}}, {"$set": relationship}, upsert=True) - - # Sync relationship between sessions - app.cl.send_event("update_relationship", { - "username": username, - "state": relationship["state"], - "updated_at": relationship["updated_at"] - }, usernames=[request.user]) - - # Return updated relationship - del relationship["_id"] - return relationship, 200 - -@users_bp.post("/report") -@validate_request(ReportBody) -async def report_post(username, data: ReportBody): - if not request.user: - abort(401) - - security.ratelimit(f"report:{request.user}", 3, 5) - - report = db.reports.find_one({ - "content_id": username, - "status": "pending", - "type": "user" - }) - - if not report: - report = { - "_id": str(uuid.uuid4()), - "type": "user", - "content_id": username, - "status": "pending", - "escalated": False, - "reports": [] - } - - for _report in report["reports"]: - if _report["user"] == request.user: - report["reports"].remove(_report) - break - - report["reports"].append({ - "user": request.user, - "ip": request.ip, - "reason": data.reason, - "comment": data.comment, - "time": int(time.time()) - }) - - db.reports.update_one({"_id": report["_id"]}, {"$set": report}, upsert=True) - - return {"error": False}, 200 - - -@users_bp.get("/dm") -async def get_dm_chat(username): - # Check authorization - if not request.user: - abort(401) - - # Check ratelimit - if security.ratelimited(f"create_chat:{request.user}"): - abort(429) - - # Ratelimit - security.ratelimit(f"create_chat:{request.user}", 5, 30) - - # Make sure the requested user isn't the requester - if request.user == username: - abort(400) - - # Get existing chat or create new chat - chat = db.chats.find_one({ - "members": {"$all": [request.user, username]}, - "type": 1, - "deleted": False - }) - if not chat: - # Check restrictions - if security.is_restricted(request.user, security.Restrictions.NEW_CHATS): - return {"error": True, "type": "accountBanned"}, 403 - - # Create chat - chat = { - "_id": str(uuid.uuid4()), - "type": 1, - "nickname": None, - "owner": None, - "members": [request.user, username], - "created": int(time.time()), - "last_active": 0, - "deleted": False - } - db.chats.insert_one(chat) - - # Return chat - if chat["last_active"] == 0: - chat["last_active"] = int(time.time()) - chat.update({ - "error": False, - "emojis": list(db.chat_emojis.find({ - "chat_id": chat["_id"] - }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})), - "stickers": list(db.chat_stickers.find({ - "chat_id": chat["_id"] - }, projection={"chat_id": 0, "created_at": 0, "created_by": 0})) - }) - return chat, 200