commit d35d43a4943408bb1057e4f658b150cd06ff2e80 Author: alex Date: Tue Sep 3 23:48:12 2024 +0200 init project diff --git a/commit_and_push.sh b/commit_and_push.sh new file mode 100755 index 0000000..554786f --- /dev/null +++ b/commit_and_push.sh @@ -0,0 +1,7 @@ +git add * + +read -p "Commit message: " commit_message + +git commit -m "$commit_message" + +git push -u origin main \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9af116a --- /dev/null +++ b/go.mod @@ -0,0 +1,40 @@ +module lms.de/backend + +go 1.21.0 + +require ( + git.ex.umbach.dev/Alex/roese-utils v1.0.21 + github.com/gofiber/fiber/v2 v2.52.5 + github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 + github.com/rs/zerolog v1.31.0 + golang.org/x/crypto v0.14.0 + gorm.io/driver/mysql v1.5.7 + gorm.io/gorm v1.25.11 +) + +require ( + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/fasthttp/websocket v1.5.3 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.15.5 // indirect + github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/gofiber/websocket/v2 v2.2.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.17.0 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.17.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..32b5e91 --- /dev/null +++ b/go.sum @@ -0,0 +1,90 @@ +git.ex.umbach.dev/Alex/roese-utils v1.0.21 h1:ae1AHQh8UHJVLbpk5Gf4S5IP0EEWGD1JFIeX9cIcYcc= +git.ex.umbach.dev/Alex/roese-utils v1.0.21/go.mod h1:hFcnKQl6nuGFEMCxK/eVQBUD6ixBFlAaiy4E2aQqUL8= +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +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/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek= +github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.15.5 h1:LEBecTWb/1j5TNY1YYG2RcOUN3R7NLylN+x8TTueE24= +github.com/go-playground/validator/v10 v10.15.5/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo= +github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w= +github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +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.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +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/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A= +github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk= +github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= +gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= diff --git a/main.go b/main.go new file mode 100644 index 0000000..96a653d --- /dev/null +++ b/main.go @@ -0,0 +1,111 @@ +package main + +import ( + "fmt" + "os" + "time" + + "git.ex.umbach.dev/Alex/roese-utils/rsconfig" + "git.ex.umbach.dev/Alex/roese-utils/rslogger" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/websocket/v2" + "lms.de/backend/modules/config" + "lms.de/backend/modules/database" + "lms.de/backend/modules/logger" + "lms.de/backend/modules/structs" + "lms.de/backend/modules/utils" + "lms.de/backend/routers/router" + "lms.de/backend/socketserver" +) + +func init() { + fmt.Println("Server is starting...") + + rsconfig.CreateEnvConfigFileIfNotExists(`DEBUG=false +COLORIZED_OUTPUT=true +HOST=127.0.0.1 +PORT=8080 + +LOG_MANAGER_SERVER_URL=http://localhost:50110 + +# Folder paths +FOLDER_PUBLIC_STATIC=./public/ + +# MariaDB +MARIADB_HOSTNAME=127.0.0.1 +MARIADB_PORT=3306 +MARIADB_USERNAME=db_user +MARIADB_PASSWORD=db_password +MARIADB_DATABASE_NAME=db_database_name`) + + config.LoadConfig() + + rslogger.InitLogger(config.Cfg.Debug, config.Cfg.ColorizedOutput, config.Cfg.LogManagerServerUrl) + + if os.Getenv("DOCKER") != "" { + fmt.Println("Waiting for mariadb docker") + // waiting for the start of mariadb docker + time.Sleep(10 * time.Second) + } + + utils.ValidatorInit() + database.InitDatabase() +} + +func main() { + app := fiber.New(fiber.Config{ + BodyLimit: 100 * 1024 * 1024, + }) + + app.Use(cors.New()) + + router.SetupRoutes(app) + + app.Use("/ws", func(c *fiber.Ctx) error { + // IsWebSocketUpgrade returns true if the client + // requested upgrade to the WebSocket protocol. + if websocket.IsWebSocketUpgrade(c) { + sessionId := c.Query("auth") + // needed for a user who uses multiple tabs in the browser + // with the same session id because otherwise the last browser + // tab would subscribe to the topic and the other tabs would + // not receive any messages + browserTabSession := c.Query("bts") + + if len(sessionId) != utils.LenHeaderXAuthorization || + len(browserTabSession) != utils.LenHeaderXAuthorization { + return c.SendStatus(fiber.StatusUnauthorized) + } + + // validate ws session + var userSession structs.UserSession + + database.DB.Select("user_id").First(&userSession, "session = ?", sessionId) + + if userSession.UserId != "" { + var user structs.User + + database.DB.First(&user, "id = ?", userSession.UserId) + + if user.Id != "" { + c.Locals("sessionId", sessionId) + c.Locals("browserTabSession", browserTabSession) + c.Locals("userId", user.Id) + c.Locals("organizationId", user.OrganizationId) + } + } + + return c.Next() + } + + return fiber.ErrUpgradeRequired + }) + + go socketserver.RunHub() + socketserver.WebSocketServer(app) + + logger.AddSystemLog(rslogger.LogTypeInfo, "Server started") + + app.Listen(config.Cfg.Host + ":" + config.Cfg.Port) +} diff --git a/modules/cache/socketclient.go b/modules/cache/socketclient.go new file mode 100644 index 0000000..9220c11 --- /dev/null +++ b/modules/cache/socketclient.go @@ -0,0 +1,53 @@ +package cache + +import ( + "sync" + + "github.com/gofiber/websocket/v2" + "lms.de/backend/modules/structs" +) + +var socketClients []*structs.SocketClient +var mu sync.RWMutex + +func AddSocketClient(socketClient *structs.SocketClient) { + mu.Lock() + socketClients = append(socketClients, socketClient) + mu.Unlock() +} + +func DeleteClientByConn(conn *websocket.Conn) { + mu.Lock() + + for i := 0; i < len(socketClients); i++ { + if socketClients[i].Conn == conn { + socketClients = removeSocketClient(socketClients, i) + break + } + } + + mu.Unlock() +} + +func removeSocketClient(s []*structs.SocketClient, i int) []*structs.SocketClient { + return append(s[:i], s[i+1:]...) +} + +func GetSocketClients() []*structs.SocketClient { + mu.RLock() + defer mu.RUnlock() + + return socketClients +} + +func SubscribeSocketClientToTopic(browserTabSession string, topic string) { + mu.Lock() + defer mu.Unlock() + + for _, socketClient := range socketClients { + if socketClient.BrowserTabSession == browserTabSession { + socketClient.SubscribedTopic = topic + break + } + } +} diff --git a/modules/config/config.go b/modules/config/config.go new file mode 100644 index 0000000..42f6b55 --- /dev/null +++ b/modules/config/config.go @@ -0,0 +1,69 @@ +package config + +import ( + "fmt" + "os" + + "github.com/joho/godotenv" +) + +var Cfg Config + +type Config struct { + Debug bool + ColorizedOutput bool + Host string + Port string + LogManagerServerUrl string + FolderPaths FolderPaths + MariaDB MariaDB +} + +type FolderPaths struct { + PublicStatic string +} + +type MariaDB struct { + Hostname string + Port string + Username string + Password string + DatabaseName string +} + +func LoadConfig() { + // used to determine server was startet in docker or not + if os.Getenv("DOCKER") == "" { + fmt.Println("Load env from file") + godotenv.Load(".env") + } else { + fmt.Println("Load env from system") + } + + config := Config{ + Debug: os.Getenv("DEBUG") == "true", + ColorizedOutput: os.Getenv("COLORIZED_OUTPUT") == "true", + LogManagerServerUrl: os.Getenv("LOG_MANAGER_SERVER_URL"), + FolderPaths: FolderPaths{ + PublicStatic: os.Getenv("FOLDER_PUBLIC_STATIC"), + }, + MariaDB: MariaDB{ + Hostname: os.Getenv("MARIADB_HOSTNAME"), + Port: os.Getenv("MARIADB_PORT"), + Username: os.Getenv("MARIADB_USERNAME"), + Password: os.Getenv("MARIADB_PASSWORD"), + DatabaseName: os.Getenv("MARIADB_DATABASE_NAME"), + }, + } + + // load default values if not in docker + if os.Getenv("DOCKER") == "" { + config.Host = os.Getenv("HOST") + config.Port = os.Getenv("PORT") + } else { // load from docker env + config.Host = "0.0.0.0" + config.Port = "80" + } + + Cfg = config +} diff --git a/modules/database/database.go b/modules/database/database.go new file mode 100644 index 0000000..698f386 --- /dev/null +++ b/modules/database/database.go @@ -0,0 +1,49 @@ +package database + +import ( + "fmt" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "lms.de/backend/modules/config" + "lms.de/backend/modules/structs" +) + +var DB *gorm.DB + +func InitDatabase() { + cfg := config.Cfg + + var logMode logger.LogLevel + + if cfg.Debug { + logMode = logger.Error + } else { + logMode = logger.Silent + } + + db, err := gorm.Open(mysql.Open( + fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", cfg.MariaDB.Username, cfg.MariaDB.Password, cfg.MariaDB.Hostname, cfg.MariaDB.Port, cfg.MariaDB.DatabaseName)), + &gorm.Config{ + Logger: logger.Default.LogMode(logMode), + }) + + if err != nil { + panic(err) + } + + DB = db + + db.AutoMigrate(&structs.Organization{}) + db.AutoMigrate(&structs.Role{}) + db.AutoMigrate(&structs.RolePermission{}) + db.AutoMigrate(&structs.User{}) + db.AutoMigrate(&structs.UserSession{}) + db.AutoMigrate(&structs.Lesson{}) + db.AutoMigrate(&structs.LessonContent{}) + db.AutoMigrate(&structs.Question{}) + db.AutoMigrate(&structs.QuestionLike{}) + db.AutoMigrate(&structs.QuestionReply{}) + db.AutoMigrate(&structs.QuestionReplyLike{}) +} diff --git a/modules/logger/logger.go b/modules/logger/logger.go new file mode 100644 index 0000000..4aeda5f --- /dev/null +++ b/modules/logger/logger.go @@ -0,0 +1,14 @@ +package logger + +import ( + "fmt" + + "git.ex.umbach.dev/Alex/roese-utils/rslogger" + "github.com/gofiber/fiber/v2" +) + +func AddSystemLog(logType string, format string, v ...any) { + go rslogger.LogManagerRequestClient(fiber.MethodPost, rslogger.LogManagerRequestBody{ + Type: "lms-system", + Logs: []string{logType + rslogger.GetTime() + fmt.Sprintf(format, v...)}}) +} diff --git a/modules/structs/lessons.go b/modules/structs/lessons.go new file mode 100644 index 0000000..426b349 --- /dev/null +++ b/modules/structs/lessons.go @@ -0,0 +1,108 @@ +package structs + +import "time" + +const ( + LessonStateActive = 1 + LessonStateDraft = 2 +) + +type Lesson struct { + Id string `gorm:"primaryKey;type:varchar(36)"` + OrganizationId string `gorm:"type:varchar(36)"` + State uint8 `gorm:"type:tinyint(1)"` + Title string `gorm:"type:varchar(255)"` + ThumbnailUrl string `gorm:"type:varchar(255)"` + CreatorUserId string `gorm:"type:varchar(36)"` + CreatedAt time.Time + UpdatedAt time.Time +} + +type LessonContent struct { + Id string `gorm:"primaryKey;type:varchar(36)"` + LessonId string `gorm:"type:varchar(36)"` + Page uint16 `gorm:"type:smallint(5)"` // Page number + Position uint16 `gorm:"type:smallint(5)"` // Position on the page + Type uint8 // Type of content, like text, image, video, etc + Data string `gorm:"type:text"` + CreatedAt time.Time + UpdatedAt time.Time +} + +// swagger:model LessonResponse +type LessonResponse struct { + Id string + State uint8 + Title string + ThumbnailUrl string + CreatorUserId string + CreatedAt time.Time +} + +// swagger:model CreateLessonResponse +type CreateLessonResponse struct { + Id string +} + +// swagger:model LessonSettings +type LessonSettings struct { + Title string + ThumbnailUrl string + State uint8 +} + +// swagger:model UpdateLessonPreviewTitleRequest +type UpdateLessonPreviewTitleRequest struct { + Title string +} + +// swagger:model UpdateLessonStateRequest +type UpdateLessonStateRequest struct { + State uint8 +} + +// swagger:model AddLessonContentRequest +type AddLessonContentRequest struct { + Type uint8 + Data string +} + +// swagger:model AddLessonContentResponse +type AddLessonContentResponse struct { + Id string +} + +// swagger:model UpdateLessonContentRequest +type UpdateLessonContentRequest struct { + Type uint8 + Data string +} + +// swagger:model UpdateLessonContentResponse +type LessonIdParam struct { + LessonId string +} + +type LessonIdAndContentIdParam struct { + LessonId string + ContentId string +} + +// swagger:model GetLessonContentsResponse +type GetLessonContentsResponse struct { + Id string + Page uint16 + Position uint16 + Type uint8 + Data string +} + +type UpdateLessonContentPositionRequest struct { + Position uint16 +} + +type UploadLessonContentFileParam struct { + LessonId string + ContentId string + Type string +} diff --git a/modules/structs/organizations.go b/modules/structs/organizations.go new file mode 100644 index 0000000..89d58f6 --- /dev/null +++ b/modules/structs/organizations.go @@ -0,0 +1,36 @@ +package structs + +import "time" + +type Organization struct { + Id string `gorm:"primaryKey;type:varchar(36)"` + Subdomain string `gorm:"type:varchar(255)"` + OwnerUserId string `gorm:"type:varchar(36)"` + CompanyName string `gorm:"type:varchar(255)"` + PrimaryColor string `gorm:"type:varchar(6)"` + LogoUrl string `gorm:"type:varchar(255)"` + BannerUrl string `gorm:"type:varchar(255)"` + SignUpScreenUrl string `gorm:"type:varchar(255)"` + CreatedAt time.Time + UpdatedAt time.Time +} + +// swagger:model CreateOrganizationRequest +type CreateOrganizationRequest struct { + Email string + Password string +} + +// swagger:model CreateOrganizationResponse +type CreateOrganizationResponse struct { + OrganizationSubdomain string + Session string +} + +type GetOrganizationSettingsResponse struct { + Subdomain string + CompanyName string + PrimaryColor string + LogoUrl string + BannerUrl string +} diff --git a/modules/structs/questions.go b/modules/structs/questions.go new file mode 100644 index 0000000..768b712 --- /dev/null +++ b/modules/structs/questions.go @@ -0,0 +1,38 @@ +package structs + +import "time" + +type Question struct { + Id string `gorm:"primaryKey;type:varchar(36)"` + LessonId string `gorm:"type:varchar(36)"` + Question string `gorm:"type:text"` + Likes uint16 `gorm:"type:smallint(5)"` + CreatorUserId string `gorm:"type:varchar(36)"` + CreatedAt time.Time + UpdatedAt time.Time +} + +type QuestionLike struct { + Id string `gorm:"primaryKey;type:varchar(36)"` + QuestionId string `gorm:"type:varchar(36)"` + CreatorUserId string `gorm:"type:varchar(36)"` + CreatedAt time.Time + UpdatedAt time.Time +} + +type QuestionReply struct { + Id string `gorm:"primaryKey;type:varchar(36)"` + QuestionId string `gorm:"type:varchar(36)"` + Reply string `gorm:"type:text"` + CreatorUserId string `gorm:"type:varchar(36)"` + CreatedAt time.Time + UpdatedAt time.Time +} + +type QuestionReplyLike struct { + Id string `gorm:"primaryKey;type:varchar(36)"` + QuestionReplyId string `gorm:"type:varchar(36)"` + CreatorUserId string `gorm:"type:varchar(36)"` + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/modules/structs/roles.go b/modules/structs/roles.go new file mode 100644 index 0000000..4c27183 --- /dev/null +++ b/modules/structs/roles.go @@ -0,0 +1,13 @@ +package structs + +type Role struct { + Id string `gorm:"primaryKey;type:varchar(36)"` + OrganizationId string `gorm:"type:varchar(36)"` + Name string `gorm:"type:varchar(255)"` +} + +type RolePermission struct { + Id string `gorm:"primaryKey;type:varchar(36)"` + RoleId string `gorm:"type:varchar(36)"` + Permission string `gorm:"type:varchar(255)"` +} diff --git a/modules/structs/socket.go b/modules/structs/socket.go new file mode 100644 index 0000000..5505c42 --- /dev/null +++ b/modules/structs/socket.go @@ -0,0 +1,104 @@ +package structs + +import ( + "encoding/json" + "errors" + "sync" + "time" + + "github.com/gofiber/websocket/v2" + "github.com/rs/zerolog/log" +) + +const ( + // Note: Status codes in the range 4000-4999 are reserved for private use + SocketCloseCodeUnauthorized = 4001 + SocketCloseCodeSessionClosed = 4002 // when user logs out of the session and has multiple tabs with the same session open +) + +type SocketClient struct { + SessionId string + BrowserTabSession string + UserId string + Conn *websocket.Conn + connMu sync.Mutex + SubscribedTopic string +} + +type SocketMessage struct { + Conn *websocket.Conn + Msg []byte +} + +type SendSocketMessage struct { + Cmd int + Body any +} + +type ReceivedMessage struct { + Cmd int + Body map[string]interface{} +} + +func (socketClient *SocketClient) SendSessionClosedMessage() error { + return socketClient.writeMessage(websocket.CloseMessage, SendSocketMessage{}, SocketCloseCodeSessionClosed) +} + +func (socketClient *SocketClient) SendUnauthorizedCloseMessage() error { + return socketClient.writeMessage(websocket.CloseMessage, SendSocketMessage{}, SocketCloseCodeUnauthorized) +} + +func (socketClient *SocketClient) SendMessage(message SendSocketMessage) error { + return socketClient.writeMessage(websocket.TextMessage, message, 0) +} + +func (socketClient *SocketClient) writeMessage(messageType int, message SendSocketMessage, closeMessageCode int) error { + var marshaledMessage []byte + var err error + + if closeMessageCode > 0 { + marshaledMessage = websocket.FormatCloseMessage(closeMessageCode, "") + } else { + marshaledMessage, err = json.Marshal(message) + + if err != nil { + log.Error().Msgf("Failed to marshal ws message, err: %s", err) + return err + } + } + + socketClient.connMu.Lock() + defer socketClient.connMu.Unlock() + + if socketClient.Conn == nil { + log.Error().Msgf("Failed to ws message because conn is nil") + return errors.New("ws client conn is nil") + } + + err = socketClient.Conn.WriteMessage(messageType, marshaledMessage) + + if err != nil { + log.Error().Msgf("Failed to write ws message, err: %s", err) + return err + } + + return nil +} + +type AllUsers struct { + Id string + RoleId string + Avatar string + Username string + ConnectionStatus uint8 + Deactivated bool + LastOnline time.Time +} + +type UserSessionSocket struct { + IdForDeletion string + UserAgent string + ConnectionStatus uint8 + LastUsed time.Time + ExpiresAt time.Time +} diff --git a/modules/structs/users.go b/modules/structs/users.go new file mode 100644 index 0000000..ef81d8c --- /dev/null +++ b/modules/structs/users.go @@ -0,0 +1,56 @@ +package structs + +import "time" + +type User struct { + Id string `gorm:"primaryKey;type:varchar(36)"` + OrganizationId string `gorm:"type:varchar(36)"` + State uint8 `gorm:"type:tinyint(1)"` + Active bool `gorm:"type:tinyint(1)"` + RoleId string `gorm:"type:varchar(36)"` + FirstName string `gorm:"type:varchar(255)"` + LastName string `gorm:"type:varchar(255)"` + Email string `gorm:"type:varchar(255)"` + Password string `gorm:"type:varchar(255)"` + ProfilePictureUrl string `gorm:"type:varchar(255)"` + LastOnlineAt time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type UserSession struct { + Id string `gorm:"primaryKey;type:varchar(36)"` + UserId string `gorm:"type:varchar(36)"` + OrganizationId string `gorm:"type:varchar(36)"` + Session string `gorm:"type:varchar(36)"` + UserAgent string `gorm:"type:varchar(255)"` + ExpiresAt time.Time + LastUsedAt time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type GetUserResponse struct { + AvatarUrl string +} + +// swagger:model UserLoginRequest +type UserLoginRequest struct { + Email string + Password string +} + +// swagger:model UserLoginResponse +type UserLoginResponse struct { + Session string +} + +// swagger:model TeamMember +type TeamMember struct { + Id string + FirstName string + LastName string + Email string + RoleId string + ProfilePictureUrl string +} diff --git a/modules/utils/globals.go b/modules/utils/globals.go new file mode 100644 index 0000000..cbd6149 --- /dev/null +++ b/modules/utils/globals.go @@ -0,0 +1,40 @@ +package utils + +const ( + minPassword = "6" + MinPassword = 6 + maxPassword = "64" + MaxPassword = 64 + + LenHeaderXAuthorization = 36 + lenHeaderXAuthorization = "36" + LenUserId = 36 + LenHeaderXApiKey = 36 + + HeaderXAuthorization = "X-Authorization" + HeaderXApiKey = "X-Api-Key" + + SessionExpiresAtTime = 7 * 24 * 60 * 60 // 1 week + + MaxImageSize = 25 * 1024 * 1024 // 25MB + MaxVideoSize = 50 * 1024 * 1024 // 50MB +) + +var ( + AcceptedImageFileTypes = []string{ + "image/png", + "image/jpeg", + "image/jpg", + "image/webp"} + + AcceptedVideoFileTypes = []string{ + "video/mp4", + "video/webm", + "video/mkv"} +) + +const ( + LessonContentTypeHeader uint8 = 1 + LessonContentTypeText = 2 + LessonContentTypeImage +) diff --git a/modules/utils/utils.go b/modules/utils/utils.go new file mode 100644 index 0000000..af04e80 --- /dev/null +++ b/modules/utils/utils.go @@ -0,0 +1,130 @@ +package utils + +import ( + "crypto/rand" + "fmt" + "math/big" + "os" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/rs/zerolog/log" + "lms.de/backend/modules/config" + "lms.de/backend/modules/database" + "lms.de/backend/modules/structs" +) + +func GetXAuhorizationHeader(c *fiber.Ctx) string { + // check if header is set + if len(c.GetReqHeaders()[HeaderXAuthorization]) == 0 { + return "" + } + + return c.GetReqHeaders()[HeaderXAuthorization][0] +} + +func GetXApiKeyHeader(c *fiber.Ctx) string { + // check if header is set + if len(c.GetReqHeaders()[HeaderXApiKey]) == 0 { + return "" + } + + return c.GetReqHeaders()[HeaderXApiKey][0] +} + +func GetSessionExpiresAtTime() time.Time { + return time.Now().Add(time.Second * SessionExpiresAtTime) +} + +func IsPasswordLengthValid(password string) bool { + lenPassword := len(password) + + if lenPassword < MinPassword || lenPassword > MaxPassword { + log.Error().Msg("Password length not valid") + return false + } + + return true +} + +// generates a random subdomain and check if it is already in use +func GenerateSubdomain() string { + var subdomain string + var err error + + for { + subdomain, err = GenerateCode(6) + + if err != nil { + log.Error().Msg("Failed to generate subdomain") + return "" + } + + if !IsSubdomainAlreadyInUse(subdomain) { + break + } + + log.Info().Msg("Subdomain already in use, generating new one") + } + + return subdomain +} + +func IsSubdomainAlreadyInUse(subdomain string) bool { + var organization structs.Organization + + if err := database.DB.Where("subdomain = ?", subdomain).First(&organization).Error; err != nil { + return false + } + + return true +} + +func GenerateCode(codeLength int) (string, error) { + var letters = "abcdefghijklmnopqrstuvwxyz" + + r := make([]byte, codeLength) + + for i := 0; i < codeLength; i++ { + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) + + if err != nil { + log.Error().Msgf("Failed to session: %v", err) + return "", err + } + + r[i] = letters[num.Int64()] + } + + return string(r), nil +} + +func IsFileTypeAllowed(contentType string, allowedContentTypes []string) bool { + for _, aType := range allowedContentTypes { + if aType == contentType { + return true + } + } + + return false +} + +func DeleteFile(filePath string) { + os.Remove(filePath) +} + +func CreateFolderStructureIfNotExists(folderPath string) { + if _, err := os.Stat(folderPath); os.IsNotExist(err) { + if err := os.MkdirAll(folderPath, os.ModePerm); err != nil { + log.Error().Msgf("Failed to create folder structure, err: %v", err) + } + } +} + +// GetFullImagePath returns the database path and the public path for the image +func GetFullImagePath(organizationId string, lessonId string) (databasePath string, publicPath string) { + return fmt.Sprintf( + "o/%s/l/%s/", organizationId, lessonId), + fmt.Sprintf( + "%s/o/%s/l/%s/", config.Cfg.FolderPaths.PublicStatic, organizationId, lessonId) +} diff --git a/modules/utils/validator.go b/modules/utils/validator.go new file mode 100644 index 0000000..7ba25d0 --- /dev/null +++ b/modules/utils/validator.go @@ -0,0 +1,5 @@ +package utils + +func ValidatorInit() { + +} diff --git a/public/demo/lesson_thumbnail.webp b/public/demo/lesson_thumbnail.webp new file mode 100644 index 0000000..cc92ad7 Binary files /dev/null and b/public/demo/lesson_thumbnail.webp differ diff --git a/public/demo/organization_banner.jpeg b/public/demo/organization_banner.jpeg new file mode 100644 index 0000000..0fb868c Binary files /dev/null and b/public/demo/organization_banner.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/0237d52c-48e6-4b1b-adbd-8e2e3baebc64.png b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/0237d52c-48e6-4b1b-adbd-8e2e3baebc64.png new file mode 100644 index 0000000..dc2c067 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/0237d52c-48e6-4b1b-adbd-8e2e3baebc64.png differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/0319ea9d-6445-47b4-9838-1d964ec9a85d.jpeg b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/0319ea9d-6445-47b4-9838-1d964ec9a85d.jpeg new file mode 100644 index 0000000..2a94dd0 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/0319ea9d-6445-47b4-9838-1d964ec9a85d.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/0a07091b-ff33-4d87-8c54-c89db8928d68.jpeg b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/0a07091b-ff33-4d87-8c54-c89db8928d68.jpeg new file mode 100644 index 0000000..eda4c37 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/0a07091b-ff33-4d87-8c54-c89db8928d68.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/1bdfcc75-3247-44b0-8870-f227ffbf16ba.png b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/1bdfcc75-3247-44b0-8870-f227ffbf16ba.png new file mode 100644 index 0000000..d5fbe42 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/1bdfcc75-3247-44b0-8870-f227ffbf16ba.png differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/1d65fc4b-2bbf-4b77-9304-f48000cd2d79.jpeg b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/1d65fc4b-2bbf-4b77-9304-f48000cd2d79.jpeg new file mode 100644 index 0000000..d8430ef Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/1d65fc4b-2bbf-4b77-9304-f48000cd2d79.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/2111f8fa-3d23-445c-b22a-cedec641cc11.png b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/2111f8fa-3d23-445c-b22a-cedec641cc11.png new file mode 100644 index 0000000..f98a510 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/2111f8fa-3d23-445c-b22a-cedec641cc11.png differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/21eb2fa2-a95d-4206-a38a-1515f7e1956d.jpeg b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/21eb2fa2-a95d-4206-a38a-1515f7e1956d.jpeg new file mode 100644 index 0000000..86d5491 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/21eb2fa2-a95d-4206-a38a-1515f7e1956d.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/4f971b24-9682-49c5-85d7-09a24ac77d7b.png b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/4f971b24-9682-49c5-85d7-09a24ac77d7b.png new file mode 100644 index 0000000..d4d298d Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/4f971b24-9682-49c5-85d7-09a24ac77d7b.png differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/58551f83-945d-44c4-94d5-762ef7bf85a8.png b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/58551f83-945d-44c4-94d5-762ef7bf85a8.png new file mode 100644 index 0000000..eac6853 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/58551f83-945d-44c4-94d5-762ef7bf85a8.png differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/5a808f7a-130d-49a6-b801-0ec07812ea07.jpeg b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/5a808f7a-130d-49a6-b801-0ec07812ea07.jpeg new file mode 100644 index 0000000..dbd2157 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/5a808f7a-130d-49a6-b801-0ec07812ea07.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/5d9aa2d0-a556-49e5-b685-f4a215bd51f9.jpeg b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/5d9aa2d0-a556-49e5-b685-f4a215bd51f9.jpeg new file mode 100644 index 0000000..89410cf Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/5d9aa2d0-a556-49e5-b685-f4a215bd51f9.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/603b3618-f421-499a-a06f-55081247cac0.jpeg b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/603b3618-f421-499a-a06f-55081247cac0.jpeg new file mode 100644 index 0000000..9587388 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/603b3618-f421-499a-a06f-55081247cac0.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/6181a60d-753e-4a5f-8467-acf2806a4d9b.jpeg b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/6181a60d-753e-4a5f-8467-acf2806a4d9b.jpeg new file mode 100644 index 0000000..dbd2157 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/6181a60d-753e-4a5f-8467-acf2806a4d9b.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/75551350-7e40-456d-b601-5f638a7a80c9.jpeg b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/75551350-7e40-456d-b601-5f638a7a80c9.jpeg new file mode 100644 index 0000000..49cb27d Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/75551350-7e40-456d-b601-5f638a7a80c9.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/81305d5d-1645-462d-8e16-5a34a2a93e5c.mp4 b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/81305d5d-1645-462d-8e16-5a34a2a93e5c.mp4 new file mode 100644 index 0000000..328cf00 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/81305d5d-1645-462d-8e16-5a34a2a93e5c.mp4 differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/88d9bf4c-c637-402d-a944-26e664305c68.jpeg b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/88d9bf4c-c637-402d-a944-26e664305c68.jpeg new file mode 100644 index 0000000..2adc836 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/88d9bf4c-c637-402d-a944-26e664305c68.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/8961c5a1-1250-417d-a5d2-087b7ca9a981.webp b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/8961c5a1-1250-417d-a5d2-087b7ca9a981.webp new file mode 100644 index 0000000..ac077e4 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/8961c5a1-1250-417d-a5d2-087b7ca9a981.webp differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/8a2cf386-0a0d-47ce-99b6-455017858eaf.jpeg b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/8a2cf386-0a0d-47ce-99b6-455017858eaf.jpeg new file mode 100644 index 0000000..99fc244 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/8a2cf386-0a0d-47ce-99b6-455017858eaf.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/8af6ea3f-b8f4-4d44-9bab-0ca3675b6293.png b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/8af6ea3f-b8f4-4d44-9bab-0ca3675b6293.png new file mode 100644 index 0000000..71848bc Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/8af6ea3f-b8f4-4d44-9bab-0ca3675b6293.png differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/8b2b2a67-aa74-4824-89af-41cfb9e87028.webp b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/8b2b2a67-aa74-4824-89af-41cfb9e87028.webp new file mode 100644 index 0000000..73e8cb1 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/8b2b2a67-aa74-4824-89af-41cfb9e87028.webp differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/900cd3d0-6f44-4dad-aeb8-433e3b80093e.jpeg b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/900cd3d0-6f44-4dad-aeb8-433e3b80093e.jpeg new file mode 100644 index 0000000..cfed304 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/900cd3d0-6f44-4dad-aeb8-433e3b80093e.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/95e95455-d47f-4f46-8a6a-c1f53a2a077f.jpeg b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/95e95455-d47f-4f46-8a6a-c1f53a2a077f.jpeg new file mode 100644 index 0000000..0fb868c Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/95e95455-d47f-4f46-8a6a-c1f53a2a077f.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/9a776f10-688d-44a4-9a81-4f939896e603.jpeg b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/9a776f10-688d-44a4-9a81-4f939896e603.jpeg new file mode 100644 index 0000000..2a94dd0 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/9a776f10-688d-44a4-9a81-4f939896e603.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/9b9910bb-131c-4ae2-9131-5220676598c3.png b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/9b9910bb-131c-4ae2-9131-5220676598c3.png new file mode 100644 index 0000000..f1921d9 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/9b9910bb-131c-4ae2-9131-5220676598c3.png differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/a5cac1db-0302-4c71-9686-887fa94bc80b.png b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/a5cac1db-0302-4c71-9686-887fa94bc80b.png new file mode 100644 index 0000000..d5fbe42 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/a5cac1db-0302-4c71-9686-887fa94bc80b.png differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/b0ec0fb6-b13c-4580-b455-f2b3978e5c59.webp b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/b0ec0fb6-b13c-4580-b455-f2b3978e5c59.webp new file mode 100644 index 0000000..cc92ad7 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/b0ec0fb6-b13c-4580-b455-f2b3978e5c59.webp differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/b861ef46-4ff3-4e23-816f-c063b33ff4e0.jpeg b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/b861ef46-4ff3-4e23-816f-c063b33ff4e0.jpeg new file mode 100644 index 0000000..ef5e471 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/b861ef46-4ff3-4e23-816f-c063b33ff4e0.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/c58c18be-1fed-4f1b-9f20-b822bc744fe0.webp b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/c58c18be-1fed-4f1b-9f20-b822bc744fe0.webp new file mode 100644 index 0000000..9113c0f Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/c58c18be-1fed-4f1b-9f20-b822bc744fe0.webp differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/d52d5916-f3b8-412e-aca2-c7b3c732308a.png b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/d52d5916-f3b8-412e-aca2-c7b3c732308a.png new file mode 100644 index 0000000..ca9e908 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/d52d5916-f3b8-412e-aca2-c7b3c732308a.png differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/d70b0f2c-184b-4f20-ba25-e305cb82fe11.png b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/d70b0f2c-184b-4f20-ba25-e305cb82fe11.png new file mode 100644 index 0000000..e8f841a Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/d70b0f2c-184b-4f20-ba25-e305cb82fe11.png differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/d7cc33ee-0a65-496c-99c4-9865992c40e3.png b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/d7cc33ee-0a65-496c-99c4-9865992c40e3.png new file mode 100644 index 0000000..a99f100 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/d7cc33ee-0a65-496c-99c4-9865992c40e3.png differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/d87e254d-920b-4acf-97b7-469bedc342ac.png b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/d87e254d-920b-4acf-97b7-469bedc342ac.png new file mode 100644 index 0000000..4215588 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/d87e254d-920b-4acf-97b7-469bedc342ac.png differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/dfb4f48c-7b3f-4af4-9b29-8996b0fddf27.jpeg b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/dfb4f48c-7b3f-4af4-9b29-8996b0fddf27.jpeg new file mode 100644 index 0000000..0fb868c Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/dfb4f48c-7b3f-4af4-9b29-8996b0fddf27.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/e4f515c0-c9ef-47b8-b1de-93ab96f591ad.webp b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/e4f515c0-c9ef-47b8-b1de-93ab96f591ad.webp new file mode 100644 index 0000000..73e8cb1 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/e4f515c0-c9ef-47b8-b1de-93ab96f591ad.webp differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/ec820ad9-347a-4713-b1cd-d53576ca8a3c.mp4 b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/ec820ad9-347a-4713-b1cd-d53576ca8a3c.mp4 new file mode 100644 index 0000000..9d16a15 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/ec820ad9-347a-4713-b1cd-d53576ca8a3c.mp4 differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/ed26cfdb-f086-496c-b59d-09486fa9dba2.png b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/ed26cfdb-f086-496c-b59d-09486fa9dba2.png new file mode 100644 index 0000000..cdb72fa Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/ed26cfdb-f086-496c-b59d-09486fa9dba2.png differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/ed36f52c-4ec2-4e42-89d5-a982bdbb288c.webp b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/ed36f52c-4ec2-4e42-89d5-a982bdbb288c.webp new file mode 100644 index 0000000..73e8cb1 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/ed36f52c-4ec2-4e42-89d5-a982bdbb288c.webp differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/ef50e687-2728-4a28-91d2-eda3683b0a9c.jpeg b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/ef50e687-2728-4a28-91d2-eda3683b0a9c.jpeg new file mode 100644 index 0000000..a386d16 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/ef50e687-2728-4a28-91d2-eda3683b0a9c.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/ef8e8762-1c40-4622-9902-89757f8b9446.png b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/ef8e8762-1c40-4622-9902-89757f8b9446.png new file mode 100644 index 0000000..b1d776a Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/ef8e8762-1c40-4622-9902-89757f8b9446.png differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/f7824a80-9f48-442f-b317-bd471e43c4f7.png b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/f7824a80-9f48-442f-b317-bd471e43c4f7.png new file mode 100644 index 0000000..08ad189 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/f7824a80-9f48-442f-b317-bd471e43c4f7.png differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/fd86b12f-7b32-4f78-b73f-88cfc1ab57f1.webp b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/fd86b12f-7b32-4f78-b73f-88cfc1ab57f1.webp new file mode 100644 index 0000000..93d6291 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/08aed7d5-d947-43dc-b5df-b6e3828528a0/fd86b12f-7b32-4f78-b73f-88cfc1ab57f1.webp differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/121aff3f-b14e-43bb-8f7d-71621372f5f6/2dd841d8-dd39-4d4d-863a-134a23047601.png b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/121aff3f-b14e-43bb-8f7d-71621372f5f6/2dd841d8-dd39-4d4d-863a-134a23047601.png new file mode 100644 index 0000000..d5fbe42 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/121aff3f-b14e-43bb-8f7d-71621372f5f6/2dd841d8-dd39-4d4d-863a-134a23047601.png differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/121aff3f-b14e-43bb-8f7d-71621372f5f6/550f9c3b-7bcd-4e9c-a8de-37a8c451a004.png b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/121aff3f-b14e-43bb-8f7d-71621372f5f6/550f9c3b-7bcd-4e9c-a8de-37a8c451a004.png new file mode 100644 index 0000000..d4c885b Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/121aff3f-b14e-43bb-8f7d-71621372f5f6/550f9c3b-7bcd-4e9c-a8de-37a8c451a004.png differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/121aff3f-b14e-43bb-8f7d-71621372f5f6/e709cf91-8a98-4ff9-b7e6-0a20fc778744.jpeg b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/121aff3f-b14e-43bb-8f7d-71621372f5f6/e709cf91-8a98-4ff9-b7e6-0a20fc778744.jpeg new file mode 100644 index 0000000..6d7678e Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/121aff3f-b14e-43bb-8f7d-71621372f5f6/e709cf91-8a98-4ff9-b7e6-0a20fc778744.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/172e4e7f-ca35-4254-853a-3cb3ed055580/292972f5-9b0b-4bf7-9622-ec4e1f612285.png b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/172e4e7f-ca35-4254-853a-3cb3ed055580/292972f5-9b0b-4bf7-9622-ec4e1f612285.png new file mode 100644 index 0000000..6a5a2d5 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/172e4e7f-ca35-4254-853a-3cb3ed055580/292972f5-9b0b-4bf7-9622-ec4e1f612285.png differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/172e4e7f-ca35-4254-853a-3cb3ed055580/9f455086-be73-44ae-9fe9-4e5cf95aa5cb.png b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/172e4e7f-ca35-4254-853a-3cb3ed055580/9f455086-be73-44ae-9fe9-4e5cf95aa5cb.png new file mode 100644 index 0000000..8ba6126 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/172e4e7f-ca35-4254-853a-3cb3ed055580/9f455086-be73-44ae-9fe9-4e5cf95aa5cb.png differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/23229171-1e50-423b-b22e-8f60798b16a0/0d216cb5-8c37-40b1-9373-cd217f04cfd8.png b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/23229171-1e50-423b-b22e-8f60798b16a0/0d216cb5-8c37-40b1-9373-cd217f04cfd8.png new file mode 100644 index 0000000..10bd83e Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/23229171-1e50-423b-b22e-8f60798b16a0/0d216cb5-8c37-40b1-9373-cd217f04cfd8.png differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/23229171-1e50-423b-b22e-8f60798b16a0/6d1ab029-bd80-41cd-88e7-ec71ced49126.png b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/23229171-1e50-423b-b22e-8f60798b16a0/6d1ab029-bd80-41cd-88e7-ec71ced49126.png new file mode 100644 index 0000000..f741a79 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/23229171-1e50-423b-b22e-8f60798b16a0/6d1ab029-bd80-41cd-88e7-ec71ced49126.png differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/23229171-1e50-423b-b22e-8f60798b16a0/f3685155-1a1d-4a24-b875-133abf0f6a2a.png b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/23229171-1e50-423b-b22e-8f60798b16a0/f3685155-1a1d-4a24-b875-133abf0f6a2a.png new file mode 100644 index 0000000..103b93e Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/23229171-1e50-423b-b22e-8f60798b16a0/f3685155-1a1d-4a24-b875-133abf0f6a2a.png differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/32e2b880-6c64-4bd1-8aba-8bad5e27ac38/8c0f2b62-ac7d-45fd-ab28-4bcd88022fa9.webp b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/32e2b880-6c64-4bd1-8aba-8bad5e27ac38/8c0f2b62-ac7d-45fd-ab28-4bcd88022fa9.webp new file mode 100644 index 0000000..cb57c26 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/32e2b880-6c64-4bd1-8aba-8bad5e27ac38/8c0f2b62-ac7d-45fd-ab28-4bcd88022fa9.webp differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/32e2b880-6c64-4bd1-8aba-8bad5e27ac38/e39f0b65-1f9d-471d-b740-1b47ef9ea0f7.jpeg b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/32e2b880-6c64-4bd1-8aba-8bad5e27ac38/e39f0b65-1f9d-471d-b740-1b47ef9ea0f7.jpeg new file mode 100644 index 0000000..51c08b4 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/32e2b880-6c64-4bd1-8aba-8bad5e27ac38/e39f0b65-1f9d-471d-b740-1b47ef9ea0f7.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/35637d36-b456-4169-8819-456bfbbbf03b/89946e81-e617-47af-babd-9e6671244d6c.jpeg b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/35637d36-b456-4169-8819-456bfbbbf03b/89946e81-e617-47af-babd-9e6671244d6c.jpeg new file mode 100644 index 0000000..5fef9fd Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/35637d36-b456-4169-8819-456bfbbbf03b/89946e81-e617-47af-babd-9e6671244d6c.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/448be855-dbc9-4794-8c38-c9a5d47c9e3e/c27f3c7e-2eb3-4562-9a88-168a70ab7c1c.jpeg b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/448be855-dbc9-4794-8c38-c9a5d47c9e3e/c27f3c7e-2eb3-4562-9a88-168a70ab7c1c.jpeg new file mode 100644 index 0000000..ebf80d9 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/448be855-dbc9-4794-8c38-c9a5d47c9e3e/c27f3c7e-2eb3-4562-9a88-168a70ab7c1c.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/4888a43c-9ebe-441a-9724-8621ad966a96/9123d070-4388-4131-9e2c-8c1d7bcbfb0d.jpeg b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/4888a43c-9ebe-441a-9724-8621ad966a96/9123d070-4388-4131-9e2c-8c1d7bcbfb0d.jpeg new file mode 100644 index 0000000..f7491be Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/4888a43c-9ebe-441a-9724-8621ad966a96/9123d070-4388-4131-9e2c-8c1d7bcbfb0d.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/99f5b84d-3f04-4cfa-baa5-84a1e00537d9/c1b8c0fb-51a6-4694-9038-d790ec9960e2.jpeg b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/99f5b84d-3f04-4cfa-baa5-84a1e00537d9/c1b8c0fb-51a6-4694-9038-d790ec9960e2.jpeg new file mode 100644 index 0000000..0fb868c Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/99f5b84d-3f04-4cfa-baa5-84a1e00537d9/c1b8c0fb-51a6-4694-9038-d790ec9960e2.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/99f5b84d-3f04-4cfa-baa5-84a1e00537d9/c22779cc-b56c-4e65-9581-c43c6cf7976f.jpeg b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/99f5b84d-3f04-4cfa-baa5-84a1e00537d9/c22779cc-b56c-4e65-9581-c43c6cf7976f.jpeg new file mode 100644 index 0000000..0fb868c Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/99f5b84d-3f04-4cfa-baa5-84a1e00537d9/c22779cc-b56c-4e65-9581-c43c6cf7976f.jpeg differ diff --git a/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/aksdmaskdmsad/e6a35310-3256-4e02-a3e8-28c76731613c.png b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/aksdmaskdmsad/e6a35310-3256-4e02-a3e8-28c76731613c.png new file mode 100644 index 0000000..84acf98 Binary files /dev/null and b/public/o/809fe37e-8c41-4a44-98d1-d9247affd531/l/aksdmaskdmsad/e6a35310-3256-4e02-a3e8-28c76731613c.png differ diff --git a/public/o/e6f5d471-1d02-4fc9-a9e4-8d0d6e073854/l/b90af9cf-f276-488d-b4a4-23fec647e405/c2f94b70-e605-4e00-9a8c-770e4884c28b.jpeg b/public/o/e6f5d471-1d02-4fc9-a9e4-8d0d6e073854/l/b90af9cf-f276-488d-b4a4-23fec647e405/c2f94b70-e605-4e00-9a8c-770e4884c28b.jpeg new file mode 100644 index 0000000..0fb868c Binary files /dev/null and b/public/o/e6f5d471-1d02-4fc9-a9e4-8d0d6e073854/l/b90af9cf-f276-488d-b4a4-23fec647e405/c2f94b70-e605-4e00-9a8c-770e4884c28b.jpeg differ diff --git a/routers/router/api/v1/lessons/lessons.go b/routers/router/api/v1/lessons/lessons.go new file mode 100644 index 0000000..44e8a51 --- /dev/null +++ b/routers/router/api/v1/lessons/lessons.go @@ -0,0 +1,673 @@ +package lessons + +import ( + "sort" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "gorm.io/gorm" + "lms.de/backend/modules/database" + "lms.de/backend/modules/structs" + "lms.de/backend/modules/utils" +) + +func GetLessons(c *fiber.Ctx) error { + // swagger:operation GET /v1/lessons lessons GetLessons + // --- + // summary: Get lessons. + // consumes: + // - application/json + // produces: + // - application/json + // responses: + // '200': + // description: Lessons retrieved successfully. + // schema: + // type: array + // items: + // "$ref": "#/definitions/LessonResponse" + // '500': + // description: Failed to retrieve lessons. + + var lessons []structs.LessonResponse + + database.DB.Model(&structs.Lesson{}).Where("organization_id = ?", c.Locals("organizationId")).Find(&lessons) + + return c.JSON(lessons) +} + +func CreateLesson(c *fiber.Ctx) error { + // swagger:operation POST /v1/lessons lessons CreateLesson + // --- + // summary: Create lesson. + // consumes: + // - application/json + // produces: + // - application/json + // responses: + // '200': + // description: Lesson created successfully. + // schema: + // "$ref": "#/definitions/CreateLessonResponse" + // '500': + // description: Failed to create lesson. + + lesson := structs.Lesson{ + Id: uuid.New().String(), + OrganizationId: c.Locals("organizationId").(string), + State: structs.LessonStateDraft, + Title: "Test", + CreatorUserId: c.Locals("userId").(string), + } + + database.DB.Create(&lesson) + + return c.JSON( + structs.CreateLessonResponse{ + Id: lesson.Id, + }, + ) +} + +func GetLessonContents(c *fiber.Ctx) error { + // swagger:operation GET /v1/lessons/{lessonId}/contents lessons GetLessonContents + // --- + // summary: Get lesson contents. + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: lessonId + // in: path + // required: true + // type: string + // responses: + // '200': + // description: Lesson contents retrieved successfully. + // schema: + // type: array + // items: + // "$ref": "#/definitions/LessonContent" + // '500': + // description: Failed to retrieve lesson contents. + + var params structs.LessonIdParam + + if err := c.ParamsParser(¶ms); err != nil { + return c.SendStatus(fiber.StatusBadRequest) + } + + var lessonContents []structs.GetLessonContentsResponse + + database.DB.Model(&structs.LessonContent{}).Where("lesson_id = ?", params.LessonId).Find(&lessonContents) + + // sort contents by position + + sort.SliceStable(lessonContents, func(i, j int) bool { + return lessonContents[i].Position < lessonContents[j].Position + }) + + return c.JSON(lessonContents) +} + +func GetLessonSettings(c *fiber.Ctx) error { + // swagger:operation GET /v1/lessons/{lessonId}/settings lessons GetLessonSettings + // --- + // summary: Get lesson settings. + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: lessonId + // in: path + // required: true + // type: string + // responses: + // '200': + // description: Lesson settings retrieved successfully. + // schema: + // "$ref": "#/definitions/LessonSettings" + // '500': + // description: Failed to retrieve lesson settings. + + var params structs.LessonIdParam + + if err := c.ParamsParser(¶ms); err != nil { + return c.SendStatus(fiber.StatusBadRequest) + } + + var lessonSettings structs.LessonSettings + + database.DB.Model(&structs.Lesson{}).Where("id = ?", params.LessonId).First(&lessonSettings) + + return c.JSON(lessonSettings) +} + +func UpdateLessonPreviewTitle(c *fiber.Ctx) error { + // swagger:operation PATCH /v1/lessons/{lessonId}/preview/title lessons UpdateLessonPreviewTitle + // --- + // summary: Update lesson preview title. + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: lessonId + // in: path + // required: true + // type: string + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateLessonPreviewRequest" + // responses: + // '200': + // description: Lesson preview updated successfully. + // schema: + // "$ref": "#/definitions/UpdateLessonPreviewResponse" + // '500': + // description: Failed to update lesson preview. + + var params structs.LessonIdParam + + if err := c.ParamsParser(¶ms); err != nil { + return c.SendStatus(fiber.StatusBadRequest) + } + + var body structs.UpdateLessonPreviewTitleRequest + + if err := c.BodyParser(&body); err != nil { + return c.SendStatus(fiber.StatusBadRequest) + } + + database.DB.Model(&structs.Lesson{}).Where("id = ?", params.LessonId).Update("title", body.Title) + + return c.JSON( + fiber.Map{ + "message": "Lesson preview updated successfully", + }, + ) +} + +func UpdateLessonPreviewThumbnail(c *fiber.Ctx) error { + // swagger:operation POST /v1/lessons/{lessonId}/preview/thumbnail lessons UpdateLessonPreviewThumbnail + // --- + // summary: Update lesson preview thumbnail. + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: lessonId + // in: path + // required: true + // type: string + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateLessonPreviewThumbnailRequest" + // responses: + // '200': + // description: Lesson preview updated successfully. + // '500': + // description: Failed to update lesson preview. + + var params structs.LessonIdParam + + if err := c.ParamsParser(¶ms); err != nil { + return c.SendStatus(fiber.StatusBadRequest) + } + + fileHeader, err := c.FormFile("file") + + if err != nil { + return c.SendStatus(fiber.StatusBadRequest) + } + + if fileHeader.Size > utils.MaxImageSize { + return c.SendStatus(fiber.StatusBadRequest) + } + + if !utils.IsFileTypeAllowed(fileHeader.Header.Get("Content-Type"), utils.AcceptedImageFileTypes) { + return c.SendStatus(fiber.StatusBadRequest) + } + + // get current thumbnail + + lesson := structs.Lesson{} + + database.DB.Model(&structs.Lesson{}).Where("id = ?", params.LessonId).First(&lesson) + + // delete current thumbnail + + if lesson.ThumbnailUrl != "" { + utils.DeleteFile(lesson.ThumbnailUrl) + } + + fileName := uuid.New().String() + "." + strings.Split(fileHeader.Header["Content-Type"][0], "/")[1] + + databasePath, publicPath := utils.GetFullImagePath(c.Locals("organizationId").(string), params.LessonId) + + utils.CreateFolderStructureIfNotExists(publicPath) + + database.DB.Model(&structs.Lesson{}).Where("id = ?", params.LessonId).Update("thumbnail_url", databasePath+fileName) + + return c.SaveFile(fileHeader, publicPath+fileName) +} + +func UpdateLessonState(c *fiber.Ctx) error { + // swagger:operation PATCH /v1/lessons/{lessonId}/state lessons UpdateLessonState + // --- + // summary: Update lesson state. + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: lessonId + // in: path + // required: true + // type: string + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateLessonStateRequest" + // responses: + // '200': + // description: Lesson state updated successfully. + // '500': + // description: Failed to update lesson state. + + var params structs.LessonIdParam + + if err := c.ParamsParser(¶ms); err != nil { + return c.SendStatus(fiber.StatusBadRequest) + } + + var body structs.UpdateLessonStateRequest + + if err := c.BodyParser(&body); err != nil { + return c.SendStatus(fiber.StatusBadRequest) + } + + database.DB.Model(&structs.Lesson{}).Where("id = ?", params.LessonId).Update("state", body.State) + + return c.JSON( + fiber.Map{ + "message": "Lesson state updated successfully", + }, + ) +} + +func AddLessonContent(c *fiber.Ctx) error { + // swagger:operation POST /v1/lessons/{lessonId}/contents lessons AddLessonContent + // --- + // summary: Add lesson content. + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: lessonId + // in: path + // required: true + // type: string + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/AddLessonContentRequest" + // responses: + // '200': + // description: Lesson content added successfully. + // schema: + // "$ref": "#/definitions/AddLessonContentResponse" + // '500': + // description: Failed to add lesson content. + + var params structs.LessonIdParam + + if err := c.ParamsParser(¶ms); err != nil { + return c.SendStatus(fiber.StatusBadRequest) + } + + var body structs.AddLessonContentRequest + + if err := c.BodyParser(&body); err != nil { + return c.SendStatus(fiber.StatusBadRequest) + } + + // get last position + + var lastContent structs.LessonContent + + database.DB.Select("position").Model(&structs.LessonContent{}).Where("lesson_id = ?", params.LessonId).Order("position desc").First(&lastContent) + + // create new content + + content := structs.LessonContent{ + Id: uuid.New().String(), + LessonId: params.LessonId, + Page: 1, + Position: lastContent.Position + 1, + Type: body.Type, + Data: body.Data, + } + + database.DB.Create(&content) + + return c.JSON( + structs.AddLessonContentResponse{ + Id: content.Id, + }, + ) +} + +func UploadLessonContentFile(c *fiber.Ctx) error { + // swagger:operation POST /v1/lessons/{lessonId}/contents/{contentId}/file/{type} lessons UploadLessonContentFile + // --- + // summary: Upload lesson content file. + // consumes: + // - multipart/form-data + // produces: + // - application/json + // parameters: + // - name: lessonId + // in: path + // required: true + // type: string + // - name: contentId + // in: path + // required: true + // type: string + // - name: file + // in: formData + // required: true + // type: file + // responses: + // '200': + // description: Lesson content file uploaded successfully. + // '500': + // description: Failed to upload lesson content file. + + var params structs.UploadLessonContentFileParam + + if err := c.ParamsParser(¶ms); err != nil { + return c.SendStatus(fiber.StatusBadRequest) + } + + fileHeader, err := c.FormFile("file") + + if err != nil { + return c.SendStatus(fiber.StatusBadRequest) + } + + if params.Type == "image" { + if fileHeader.Size > utils.MaxImageSize { + return c.SendStatus(fiber.StatusBadRequest) + } + + if !utils.IsFileTypeAllowed(fileHeader.Header.Get("Content-Type"), utils.AcceptedImageFileTypes) { + return c.SendStatus(fiber.StatusBadRequest) + } + } else if params.Type == "video" { + if fileHeader.Size > utils.MaxVideoSize { + return c.SendStatus(fiber.StatusBadRequest) + } + + if !utils.IsFileTypeAllowed(fileHeader.Header.Get("Content-Type"), utils.AcceptedVideoFileTypes) { + return c.SendStatus(fiber.StatusBadRequest) + } + } else { + return c.SendStatus(fiber.StatusBadRequest) + } + + // get current file + + content := structs.LessonContent{} + + database.DB.Select("type", "data").Model(&structs.LessonContent{}).Where("id = ?", params.ContentId).First(&content) + + // delete current image + + if content.Type == utils.LessonContentTypeImage && content.Data != "" { + utils.DeleteFile(content.Data) + } + + fileName := uuid.New().String() + "." + strings.Split(fileHeader.Header["Content-Type"][0], "/")[1] + + databasePath, publicPath := utils.GetFullImagePath(c.Locals("organizationId").(string), params.LessonId) + + utils.CreateFolderStructureIfNotExists(publicPath) + + database.DB.Model(&structs.LessonContent{}).Where("id = ?", params.ContentId).Update("data", databasePath+fileName) + + if err := c.SaveFile(fileHeader, publicPath+fileName); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to save file", + }) + } + + return c.JSON(fiber.Map{ + "Data": databasePath + fileName, + }) +} + +func UpdateLessonContent(c *fiber.Ctx) error { + // swagger:operation PATCH /v1/lessons/{lessonId}/contents/{contentId} lessons UpdateLessonContent + // --- + // summary: Update lesson content. + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: lessonId + // in: path + // required: true + // type: string + // - name: contentId + // in: path + // required: true + // type: string + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateLessonContentRequest" + // responses: + // '200': + // description: Lesson content updated successfully. + // '500': + // description: Failed to update lesson content. + + var params structs.LessonIdAndContentIdParam + + if err := c.ParamsParser(¶ms); err != nil { + return c.SendStatus(fiber.StatusBadRequest) + } + + var body structs.UpdateLessonContentRequest + + if err := c.BodyParser(&body); err != nil { + return c.SendStatus(fiber.StatusBadRequest) + } + + database.DB.Model(&structs.LessonContent{}).Where("id = ?", params.ContentId).Update("data", body.Data) + + return c.JSON( + fiber.Map{ + "message": "Lesson content updated successfully", + }, + ) +} + +func UpdateLessonContentPosition(c *fiber.Ctx) error { + // swagger:operation PATCH /v1/lessons/{lessonId}/contents/{contentId}/position lessons UpdateLessonContentPosition + // --- + // summary: Update lesson content position. + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: lessonId + // in: path + // required: true + // type: string + // - name: contentId + // in: path + // required: true + // type: string + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateLessonContentPositionRequest" + // responses: + // '200': + // description: Lesson content position updated successfully. + // '500': + // description: Failed to update lesson content position. + + var params structs.LessonIdAndContentIdParam + + if err := c.ParamsParser(¶ms); err != nil { + return c.SendStatus(fiber.StatusBadRequest) + } + + var body structs.UpdateLessonContentPositionRequest + + if err := c.BodyParser(&body); err != nil { + return c.SendStatus(fiber.StatusBadRequest) + } + + // update position + + newPosition := body.Position + + // Begin a transaction to ensure consistency + tx := database.DB.Begin() + if tx.Error != nil { + return c.SendStatus(fiber.StatusInternalServerError) + } + + // Fetch the current position of the content being moved + var currentContent structs.LessonContent + if err := tx.Model(&structs.LessonContent{}).Where("id = ?", params.ContentId).First(¤tContent).Error; err != nil { + tx.Rollback() + return c.SendStatus(fiber.StatusNotFound) + } + + oldPosition := currentContent.Position + + if oldPosition == newPosition { + // No need to update if the position hasn't changed + return c.JSON(fiber.Map{ + "message": "Lesson content position updated successfully", + }) + } + + if oldPosition < newPosition { + // Items between oldPosition and newPosition need to be shifted down + if err := tx.Model(&structs.LessonContent{}).Where("lesson_id = ?", params.LessonId). + Where("position > ? AND position <= ?", oldPosition, newPosition). + Update("position", gorm.Expr("position - 1")).Error; err != nil { + tx.Rollback() + return c.SendStatus(fiber.StatusInternalServerError) + } + } else { + // Items between newPosition and oldPosition need to be shifted up + if err := tx.Model(&structs.LessonContent{}).Where("lesson_id = ?", params.LessonId). + Where("position >= ? AND position < ?", newPosition, oldPosition). + Update("position", gorm.Expr("position + 1")).Error; err != nil { + tx.Rollback() + return c.SendStatus(fiber.StatusInternalServerError) + } + } + + // Update the position of the moved content + if err := tx.Model(&structs.LessonContent{}).Where("id = ?", params.ContentId).Update("position", newPosition).Error; err != nil { + tx.Rollback() + return c.SendStatus(fiber.StatusInternalServerError) + } + + // Commit the transaction + if err := tx.Commit().Error; err != nil { + return c.SendStatus(fiber.StatusInternalServerError) + } + + return c.JSON( + fiber.Map{ + "message": "Lesson content position updated successfully", + }, + ) +} + +func DeleteLessonContent(c *fiber.Ctx) error { + // swagger:operation DELETE /v1/lessons/{lessonId}/contents/{contentId} lessons DeleteLessonContent + // --- + // summary: Delete lesson content. + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: lessonId + // in: path + // required: true + // type: string + // - name: contentId + // in: path + // required: true + // type: string + // responses: + // '200': + // description: Lesson content deleted successfully. + // '500': + // description: Failed to delete lesson content. + + var params structs.LessonIdAndContentIdParam + + if err := c.ParamsParser(¶ms); err != nil { + return c.SendStatus(fiber.StatusBadRequest) + } + + // Begin a transaction to ensure consistency + tx := database.DB.Begin() + if tx.Error != nil { + return c.SendStatus(fiber.StatusInternalServerError) + } + + // Fetch the current position of the content being deleted + var content structs.LessonContent + if err := tx.Model(&structs.LessonContent{}).Where("id = ?", params.ContentId).First(&content).Error; err != nil { + tx.Rollback() + return c.SendStatus(fiber.StatusNotFound) + } + + // Delete the content + if err := tx.Delete(&content).Error; err != nil { + tx.Rollback() + return c.SendStatus(fiber.StatusInternalServerError) + } + + // Shift down the positions of all contents with a higher position + if err := tx.Model(&structs.LessonContent{}).Where("lesson_id = ?", params.LessonId). + Where("position > ?", content.Position). + Update("position", gorm.Expr("position - 1")).Error; err != nil { + tx.Rollback() + return c.SendStatus(fiber.StatusInternalServerError) + } + + // Commit the transaction + if err := tx.Commit().Error; err != nil { + return c.SendStatus(fiber.StatusInternalServerError) + } + + return c.JSON( + fiber.Map{ + "message": "Lesson content deleted successfully", + }, + ) +} diff --git a/routers/router/api/v1/organization/organization.go b/routers/router/api/v1/organization/organization.go new file mode 100644 index 0000000..5165c23 --- /dev/null +++ b/routers/router/api/v1/organization/organization.go @@ -0,0 +1,105 @@ +package organization + +import ( + "encoding/base64" + "time" + + "git.ex.umbach.dev/Alex/roese-utils/rsutils" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/rs/zerolog/log" + "golang.org/x/crypto/bcrypt" + "lms.de/backend/modules/database" + "lms.de/backend/modules/structs" + "lms.de/backend/modules/utils" +) + +func CreateOrganization(c *fiber.Ctx) error { + // swagger:operation POST /organization organization createOrganization + // --- + // summary: Create organization + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateOrganizationRequest" + // responses: + // '200': + // description: Organization created successfully + // schema: + // "$ref": "#/definitions/CreateOrganizationResponse" + // '400': + // description: Invalid request body + // '500': + // description: Failed to create organization + + var body structs.CreateOrganizationRequest + + if err := rsutils.BodyParserHelper(c, &body); err != nil { + return c.SendStatus(fiber.StatusBadRequest) + } + + decodedPassword, err := base64.StdEncoding.DecodeString(body.Password) + + if err != nil { + log.Error().Msg("Failed to decode base64 password, err: " + err.Error()) + return c.SendStatus(fiber.StatusBadRequest) + } + + if passwordValid := utils.IsPasswordLengthValid(string(decodedPassword)); !passwordValid { + return c.SendStatus(fiber.StatusBadRequest) + } + + hashedPassword, err := bcrypt.GenerateFromPassword(decodedPassword, bcrypt.DefaultCost) + + if err != nil { + log.Error().Msg("Failed to hash password, err: " + err.Error()) + return c.SendStatus(fiber.StatusInternalServerError) + } + + organizationId := uuid.New().String() + userId := uuid.New().String() + subdomain := utils.GenerateSubdomain() + + database.DB.Create(&structs.Organization{ + Id: organizationId, + Subdomain: subdomain, + OwnerUserId: userId, + CompanyName: "Mustermann GmbH", + }) + + database.DB.Create(&structs.User{ + Id: userId, + OrganizationId: organizationId, + Active: true, + FirstName: "Max", + LastName: "Mustermann", + Email: body.Email, + Password: string(hashedPassword), + }) + + session, err := rsutils.GenerateSession() + + if err != nil { + return c.SendStatus(fiber.StatusInternalServerError) + } + + database.DB.Create(&structs.UserSession{ + Id: session, + OrganizationId: organizationId, + Session: uuid.New().String(), + UserId: userId, + UserAgent: string(c.Context().UserAgent()), + ExpiresAt: utils.GetSessionExpiresAtTime(), + LastUsedAt: time.Now(), + }) + + return c.JSON(structs.CreateOrganizationResponse{ + OrganizationSubdomain: subdomain, + Session: session, + }) +} diff --git a/routers/router/api/v1/organization/settings.go b/routers/router/api/v1/organization/settings.go new file mode 100644 index 0000000..b34f242 --- /dev/null +++ b/routers/router/api/v1/organization/settings.go @@ -0,0 +1,32 @@ +package organization + +import ( + "github.com/gofiber/fiber/v2" + "lms.de/backend/modules/database" + "lms.de/backend/modules/structs" +) + +func GetOrganizationSettings(c *fiber.Ctx) error { + // swagger:operation GET /organization/settings organization getOrganizationSettings + // --- + // summary: Get settings + // consumes: + // - application/json + // produces: + // - application/json + // responses: + // '200': + // description: Settings fetched successfully + // schema: + // "$ref": "#/definitions/GetOrganizationSettingsResponse" + // '400': + // description: Invalid request body + // '500': + // description: Failed to fetch settings + + var organizationSettings structs.GetOrganizationSettingsResponse + + database.DB.Model(&structs.Organization{}).Select("subdomain", "company_name", "primary_color", "logo_url", "banner_url").First(&organizationSettings) + + return c.JSON(organizationSettings) +} diff --git a/routers/router/api/v1/organization/team.go b/routers/router/api/v1/organization/team.go new file mode 100644 index 0000000..125b5f2 --- /dev/null +++ b/routers/router/api/v1/organization/team.go @@ -0,0 +1,34 @@ +package organization + +import ( + "github.com/gofiber/fiber/v2" + "lms.de/backend/modules/database" + "lms.de/backend/modules/structs" +) + +func GetTeamMembers(c *fiber.Ctx) error { + // swagger:operation GET /organization/team/members organization getTeamMembers + // --- + // summary: Get team members + // consumes: + // - application/json + // produces: + // - application/json + // responses: + // '200': + // description: Team members fetched successfully + // schema: + // type: array + // items: + // "$ref": "#/definitions/TeamMember" + // '400': + // description: Invalid request body + // '500': + // description: Failed to fetch team members + + var users []structs.TeamMember + + database.DB.Model(&structs.User{}).Where("organization_id = ?", c.Locals("organizationId")).Find(&users) + + return c.JSON(users) +} diff --git a/routers/router/api/v1/user/auth.go b/routers/router/api/v1/user/auth.go new file mode 100644 index 0000000..0e73690 --- /dev/null +++ b/routers/router/api/v1/user/auth.go @@ -0,0 +1,97 @@ +package user + +import ( + "encoding/base64" + + "git.ex.umbach.dev/Alex/roese-utils/rslogger" + "git.ex.umbach.dev/Alex/roese-utils/rsutils" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/rs/zerolog/log" + "golang.org/x/crypto/bcrypt" + "lms.de/backend/modules/database" + "lms.de/backend/modules/logger" + "lms.de/backend/modules/structs" + "lms.de/backend/modules/utils" +) + +func UserLogin(c *fiber.Ctx) error { + // swagger:operation POST /user/auth/login user userLogin + // --- + // summary: Login user + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UserLoginRequest" + // responses: + // '200': + // description: User logged in successfully + // schema: + // "$ref": "#/definitions/UserLoginResponse" + // '400': + // description: Invalid request body + // '401': + // description: Incorrect password or user deactivated + // '500': + // description: Failed to login user + + var body structs.UserLoginRequest + + if err := rsutils.BodyParserHelper(c, &body); err != nil { + return c.SendStatus(fiber.StatusBadRequest) + } + + decodedPassword, err := base64.StdEncoding.DecodeString(body.Password) + + if err != nil { + log.Error().Msg("Failed to decode base64 password, err: " + err.Error()) + return c.SendStatus(fiber.StatusBadRequest) + } + + if passwordValid := utils.IsPasswordLengthValid(string(decodedPassword)); !passwordValid { + return c.SendStatus(fiber.StatusBadRequest) + } + + var user structs.User + + organizationId := c.Locals("organizationId").(string) + + database.DB.Select("id", "active", "password").First(&user, "email = ? AND organization_id = ?", body.Email, organizationId) + + if user.Id == "" { + log.Error().Msg("User not found") + return c.SendStatus(fiber.StatusBadRequest) + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), decodedPassword); err != nil { + log.Error().Msg("Incorrect password") + return c.SendStatus(fiber.StatusBadRequest) + } + + if !user.Active { + return c.SendStatus(fiber.StatusUnauthorized) + } + + session, err := rsutils.GenerateSession() + + if err != nil { + return c.SendStatus(fiber.StatusInternalServerError) + } + + database.DB.Create(&structs.UserSession{ + Id: uuid.New().String(), + OrganizationId: organizationId, + Session: session, + UserId: user.Id, + UserAgent: string(c.Context().UserAgent()), + ExpiresAt: utils.GetSessionExpiresAtTime()}) + + logger.AddSystemLog(rslogger.LogTypeInfo, "User %s has logged in", user.Id) + + return c.JSON(structs.UserLoginResponse{Session: session}) +} diff --git a/routers/router/api/v1/user/user.go b/routers/router/api/v1/user/user.go new file mode 100644 index 0000000..c2d8736 --- /dev/null +++ b/routers/router/api/v1/user/user.go @@ -0,0 +1,13 @@ +package user + +import ( + "github.com/gofiber/fiber/v2" + "lms.de/backend/modules/structs" +) + +func GetUser(c *fiber.Ctx) error { + + return c.JSON(structs.GetUserResponse{ + AvatarUrl: "", + }) +} diff --git a/routers/router/router.go b/routers/router/router.go new file mode 100644 index 0000000..c2055e4 --- /dev/null +++ b/routers/router/router.go @@ -0,0 +1,112 @@ +package router + +import ( + "strings" + + "github.com/gofiber/fiber/v2" + "lms.de/backend/modules/config" + "lms.de/backend/modules/database" + "lms.de/backend/modules/structs" + "lms.de/backend/modules/utils" + "lms.de/backend/routers/router/api/v1/lessons" + "lms.de/backend/routers/router/api/v1/organization" + "lms.de/backend/routers/router/api/v1/user" +) + +func SetupRoutes(app *fiber.App) { + v1 := app.Group("/v1") + + o := v1.Group("/organization") + o.Post("/", organization.CreateOrganization) + o.Get("/team/members", handleOrganizationSubdomain, requestAccessValidation, organization.GetTeamMembers) + o.Get("/settings", handleOrganizationSubdomain, requestAccessValidation, organization.GetOrganizationSettings) + + u := v1.Group("/user") + u.Get("/", handleOrganizationSubdomain, requestAccessValidation, user.GetUser) + u.Post("/auth/login", handleOrganizationSubdomain, user.UserLogin) + + l := v1.Group("/lessons") + l.Get("/", handleOrganizationSubdomain, requestAccessValidation, lessons.GetLessons) + l.Post("/", handleOrganizationSubdomain, requestAccessValidation, lessons.CreateLesson) + l.Get("/:lessonId/contents", handleOrganizationSubdomain, requestAccessValidation, lessons.GetLessonContents) + l.Get("/:lessonId/settings", handleOrganizationSubdomain, requestAccessValidation, lessons.GetLessonSettings) + l.Patch("/:lessonId/preview/title", handleOrganizationSubdomain, requestAccessValidation, lessons.UpdateLessonPreviewTitle) + l.Post("/:lessonId/preview/thumbnail", handleOrganizationSubdomain, requestAccessValidation, lessons.UpdateLessonPreviewThumbnail) + l.Patch("/:lessonId/state", handleOrganizationSubdomain, requestAccessValidation, lessons.UpdateLessonState) + l.Post("/:lessonId/contents", handleOrganizationSubdomain, requestAccessValidation, lessons.AddLessonContent) + l.Patch("/:lessonId/contents/:contentId", handleOrganizationSubdomain, requestAccessValidation, lessons.UpdateLessonContent) + l.Patch("/:lessonId/contents/:contentId/position", handleOrganizationSubdomain, requestAccessValidation, lessons.UpdateLessonContentPosition) + l.Delete("/:lessonId/contents/:contentId", handleOrganizationSubdomain, requestAccessValidation, lessons.DeleteLessonContent) + l.Post("/:lessonId/contents/:contentId/file/:type", handleOrganizationSubdomain, requestAccessValidation, lessons.UploadLessonContentFile) + + app.Static("/static", config.Cfg.FolderPaths.PublicStatic) +} + +func userSessionValidation(c *fiber.Ctx) error { + xAuthorization := utils.GetXAuhorizationHeader(c) + + if len(xAuthorization) != utils.LenHeaderXAuthorization { + return fiber.ErrUnauthorized + } + + var userSession structs.UserSession + + database.DB.Select("session", "user_id").First(&userSession, "session = ? AND organization_id = ?", xAuthorization, c.Locals("organizationId")) + + if userSession.Session != xAuthorization { + return fiber.ErrUnauthorized + } + + c.Locals("userId", userSession.UserId) + c.Locals("organizationId", c.Locals("organizationId")) + + return c.Next() +} + +func requestAccessValidation(c *fiber.Ctx) error { + // user session + xAuthorization := utils.GetXAuhorizationHeader(c) + + if len(xAuthorization) == utils.LenHeaderXAuthorization { + return userSessionValidation(c) + } + + // api key + /*xApiKey := utils.GetXApiKeyHeader(c) + + if len(xApiKey) == utils.LenHeaderXApiKey { + return userApikeyTokenValidation(c) + } */ + + return c.SendStatus(fiber.StatusUnauthorized) +} + +// gets the organization id by subdomain and sets it in the locals +func handleOrganizationSubdomain(c *fiber.Ctx) error { + host := c.Hostname() + + // split the hostname into parts + parts := strings.Split(host, ".") + + // check if we have at least three parts (subdomain, domain, tld) + if len(parts) >= 3 { + // the first part is the subdomain + subdomain := parts[0] + + // get organization id by subdomain from database + organization := structs.Organization{} + + database.DB.Select("id").First(&organization, "subdomain = ?", subdomain) + + // if organization not found + if organization.Id == "" { + return c.SendStatus(fiber.StatusUnauthorized) + } + + c.Locals("organizationId", organization.Id) + + return c.Next() + } + + return c.SendStatus(fiber.StatusBadRequest) +} diff --git a/socketserver/hub.go b/socketserver/hub.go new file mode 100644 index 0000000..5f7dc39 --- /dev/null +++ b/socketserver/hub.go @@ -0,0 +1,98 @@ +package socketserver + +import ( + "encoding/json" + "fmt" + + "time" + + "git.ex.umbach.dev/Alex/roese-utils/rslogger" + "github.com/gofiber/websocket/v2" + "github.com/rs/zerolog/log" + "lms.de/backend/modules/cache" + "lms.de/backend/modules/database" + "lms.de/backend/modules/logger" + "lms.de/backend/modules/structs" + "lms.de/backend/modules/utils" +) + +var register = make(chan *structs.SocketClient) +var broadcast = make(chan structs.SocketMessage) +var unregister = make(chan *websocket.Conn) + +func RunHub() { + for { + select { + case newSocketClient := <-register: + userId := fmt.Sprintf("%v", newSocketClient.Conn.Locals("userId")) + browserTabSession := fmt.Sprintf("%v", newSocketClient.Conn.Locals("browserTabSession")) + sessionId := fmt.Sprintf("%v", newSocketClient.Conn.Locals("sessionId")) + + // close connection instantly if sessionId is empty + if sessionId == "" { + newSocketClient.SendUnauthorizedCloseMessage() + continue + } + + newSocketClient.SessionId = sessionId + newSocketClient.BrowserTabSession = browserTabSession + newSocketClient.UserId = userId + + cache.AddSocketClient(newSocketClient) + + // check that user session is not expired + var userSession structs.UserSession + + database.DB.Select("expires_at").First(&userSession, "session = ?", sessionId) + + if !userSession.ExpiresAt.IsZero() && time.Now().After(userSession.ExpiresAt) { + newSocketClient.SendUnauthorizedCloseMessage() + database.DB.Delete(&structs.UserSession{}, "session = ?", sessionId) + continue + } + + // update session last used time + database.DB.Model(&structs.UserSession{}).Where("session = ?", sessionId).Updates(structs.UserSession{ + LastUsedAt: time.Now(), + ExpiresAt: utils.GetSessionExpiresAtTime(), + }) + + // socketclients.UpdateUserSessionsForUser(userId, sessionId) + + logger.AddSystemLog(rslogger.LogTypeInfo, "User %v has come online", userId) + + case data := <-broadcast: + var receivedMessage structs.ReceivedMessage + + if err := json.Unmarshal(data.Msg, &receivedMessage); err != nil { + log.Error().Msgf("Failed to unmarshal received msg, err: %s", err) + continue + } + + log.Debug().Msgf("Received message: %v %v", receivedMessage, receivedMessage.Cmd) + + switch receivedMessage.Cmd { + case 1: + break + default: + log.Error().Msgf("Received unknown message: %v", receivedMessage) + } + + case connection := <-unregister: + cache.DeleteClientByConn(connection) + + if connection.Locals("userId") != nil && connection.Locals("sessionId") != nil { + userId := connection.Locals("userId").(string) + // sessionId := connection.Locals("sessionId").(string) + + database.DB.Model(&structs.User{}).Where("id = ?", userId).Updates(structs.User{ + LastOnlineAt: time.Now(), + }) + + // socketclients.UpdateUserSessionsForUser(userId, sessionId) + + logger.AddSystemLog(rslogger.LogTypeInfo, "User %s has gone offline", userId) + } + } + } +} diff --git a/socketserver/server.go b/socketserver/server.go new file mode 100644 index 0000000..71f639c --- /dev/null +++ b/socketserver/server.go @@ -0,0 +1,37 @@ +package socketserver + +import ( + "github.com/gofiber/fiber/v2" + "github.com/gofiber/websocket/v2" + "github.com/rs/zerolog/log" + "lms.de/backend/modules/structs" +) + +func WebSocketServer(app *fiber.App) { + app.Get("/ws", websocket.New(func(c *websocket.Conn) { + defer func() { + unregister <- c + c.Close() + }() + + register <- &structs.SocketClient{Conn: c} + + for { + messageType, msg, err := c.ReadMessage() + + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Error().Msgf("Read err: %s", err) + } + + return + } + + if messageType == websocket.TextMessage { + broadcast <- structs.SocketMessage{Conn: c, Msg: msg} + } else { + log.Error().Msgf("websocket message received of type %v", messageType) + } + } + })) +}